Quickstart
Undercurrent Analytics is a platform that allows you to completely delegate product analytics to an AI agent. You can also delegate setup.
- Register and receive your onboarding credentials file.
- Move your onboarding credentials JSON file into your repo and add it to
.gitignore. - Ask your AI harness to set up the analytics client library so that your app sends events to
https://ingest.undercurrentanalytics.dev. - Ask your AI harness to create dashboards to visualize user activation, retention, and specific engagement metrics, so you can view them at grafana.undercurrentanalytics.dev.
1. Register and receive your onboarding credentials file
Register by entering your email at https://undercurrentanalytics.dev/#get-access. We’ll then send you an email with an attached JSON credentials file. The file contains the following:
- Your non-secret project token, which is a string that identifies events sent by your app.
- Your secret Grafana sign-in credentials, which let you log into the Grafana service at grafana.undercurrentanalytics.dev.
- A secret Grafana service account token, which permits write-access to your Grafana dashboards.
2. Move the onboarding credentials to your app repo
mv ~/Downloads/undercurrent-analytics-*.json /path/to/your/app/repo
echo 'undercurrent-analytics-*.json' >> .gitignore
git add .gitignore
git commit -m "Ignore Undercurrent Analytics credentials file" .gitignore
Note: Keep your onboarding credentials file a secret. You can change the Grafana-related credentials within Grafana. You get your own Grafana organization, so you control the credentials within it.
3. Ask your AI harness to set up the analytics client library
Undercurrent’s ingest is wire-compatible with Mixpanel, so you set up event tracking by adding the Mixpanel client library to your project and pointing it at Undercurrent’s event ingest endpoint, https://ingest.undercurrentanalytics.dev.
You can delegate this set up to your AI harness using this prompt:
Set up mobile analytics in this app for use with Undercurrent Analytics, which is wire-compatible with Mixpanel. Use the official Mixpanel client library and point it at Undercurrent's ingest endpoint, https://ingest.undercurrentanalytics.dev. Follow this step-by-step process: 1. Add the Mixpanel client library for this app's platform as a dependency. Reference: https://docs.mixpanel.com/docs/quickstart/install-mixpanel#code 2. Decide how you will introduce the Mixpanel analytics client. If the app already uses another analytics service, including Mixpanel, add Undercurrent Analytics ingest _alongside_ it in all cases. Do not remove any existing analytics clients or change their setup. You may need to perform a refactoring to create a single, reusable analytics service to track events through, so that they reach both any existing analytics services, and the Undercurrent Analytics-configured Mixpanel client. Strongly avoid calling the third party Mixpanel library directly throughout the app - use a wrapping analytics service instead. When designing the wrapping analytics service, avoid using a singleton - prefer to create an object that is injected to the components where it's needed. That said, conform to the existing design - if there isn't a clean way to inject an instance of the analytics service to the UI components that need it, then you may fall back to using a singleton. 3. Ensure the new Mixpanel client to be used for Undercurrent Analytics is initialized with these settings: - Server URL: https://ingest.undercurrentanalytics.dev - Project token: Take this from the Undercurrent Analytics credentials JSON file in this repo. 4. Identify each user with a stable, anonymous distinct ID. If there is other analytics code which requires a distinct ID, reuse what those other services are using. Otherwise, if some form of device ID is available, that's an ideal value to use. The distinct ID needs to be provided to the new Mixpanel client as per the Mixpanel documentation. 5. Now that the infrastructure is in place, instrument the key user interactions in this app with clearly named events and useful properties (for example: screen name, the action taken, and any relevant context). 6. Build the app to verify that everything compiles. 7. If there are tests, run them. Be judicious in your choice of tests to run. At least one high-level UI test that launches the app as it would run in production, and a fast running set of unit tests (perhaps all of them, if there are relatively few). 8. Once you're finished, summarise: - How the Mixpanel dependency was added - Where the new Mixpanel client is initialised - The list of events you added, where they fire, and most importantly, why you added them
In essence, this involves the following steps:
- Installing the Mixpanel client library by following the official guide for your platform: Install Mixpanel → Code.
- Initializing the client with these two overridden values: (1) Server URL:
https://ingest.undercurrentanalytics.dev(2) Project token: The project token in your onboarding credentials file.
When you’re done with this step, your app codebase should:
- Have a dependency on the Mixpanel client library for your platform (iOS, Android, React Native, or Flutter).
- Initialise Mixpanel using your project token and with
serverURLset tohttps://ingest.undercurrentanalytics.dev.- Identify each user with a stable distinct ID.
- Use the Mixpanel library to send analytics events for the interactions you care about.
4. Ask your AI harness to create the dashboards
With events flowing in, the next step is to turn them into dashboards that reveal the metrics that matter for your app. Typically, these are related to activation, engagement, and retention.
While it is perfectly possible for you to build Grafana dashboards and write the supporting SQL queries yourself (people have been using Grafana this way for years), this is something you can very effectively delegate to an AI coding harness such as Claude Code using a frontier model.
Provide these details:
- Grafana URL:
https://grafana.undercurrentanalytics.dev - Service account token: The service account token from your onboarding credentials file.
Undercurrent Analytics is a mobile analytics service that uses Grafana for analytics dashboards. There is an Undercurrent Analytics credentials JSON file in this repo, containing a Grafana Service Account token with Editor permissions, and the login/password credentials for a Grafana org admin, which you can use to interact with Grafana over the HTTP API.
Your task is to create a new Grafana dashboard using the HTTP API, which shows several different panels containing valuable user analytics metrics for this app.
Follow this step-by-step process:
1. Find the Undercurrent Analytics credentials JSON file in this repo. If you can't find it, stop here and report that you need it to create the dashboards. If you do find it, ensure it is ignored by version control and not checked in.
2. Find the existing R2 SQL data source in the Grafana instance: https://grafana.undercurrentanalytics.dev. If you can't find it, stop here and report what went wrong.
3. Verify that the data source works by running the query `SELECT * FROM $__table LIMIT 1`. If it breaks, stop here and report the error and what went wrong.
4. Look through the source code to find the events that the app tracks. If you can't find any, then stop here, because we need to register analytics events to track before we can create dashboards.
5. Based on the purpose of the app and its intended user base, design the dashboards you will create along with the set of panels in each dashboard. Prefer to make fewer dashboards if possible - ideally just a single one. These are the most important areas to cover when choosing the metrics to visualize: (1) Activation: Are new users reaching the point at which the app becomes useful to them? (2) Engagement: Which features and screens are used, and how often? Are users benefitting from the app's key features? (3) Retention: Are users coming back over time? Which features do they come back to?
6. Using an edited version of the `build_dashboard.py` Python snippet mentioned in the notes below, create the dashboard(s) and panels you designed in the previous step. Don't add `build_dashboard.py` to version control - either ignore it, or put it somewhere that is already not tracked by version control. The same goes for any other Python-related files that are now present in the repo, such as `__pycache__/`.
7. Smoke test the dashboards and panels against the live data source.
8. Once you're finished, list the dashboards with their Grafana URLs, and the panels you created, the question each one answers, and how they're relevant for growing the app's usage.
Here are some essential notes required to complete this process successfully:
# Writing SQL queries for Undercurrent Analytics
## The tracking events table: `$__table`
During query execution, `$__table` is expanded into a view that is already filtered to show only your data, so:
- In queries, write `FROM $__table` (optionally followed by WHERE/GROUP BY/ORDER BY/HAVING/
LIMIT/OFFSET/JOIN…ON).
- Do NOT alias it (no `$__table t`); reference its columns unqualified.
## The time filter
Include `WHERE $__timeFilter(time)` in queries when reading from $__table, so you are only reading events within the selected time range.
## The table schema ($__table columns)
| Column | Type | Non-null | Notes |
|-----------------------------|----------------|----------|--------------------------------------------------------|
| `event` | string | ✅ | Event name |
| `time` | timestamp (ms) | ✅ | Event time |
| `distinct_id` | string | | Identifies a unique installation of the app |
| `properties` | string | | JSON string of custom properties attached to the event |
| `app_build_number` | string | | The app's build number |
| `app_version_string` | string | | Mixpanel `$app_version_string` |
| `carrier` | string | | Mixpanel `$carrier` |
| `city` | string | | Mixpanel `$city` |
| `device_id` | string | | Mixpanel `$device_id` |
| `had_persisted_distinct_id` | bool | | Mixpanel `$had_persisted_distinct_id` |
| `lib_version` | string | | Mixpanel `$lib_version` |
| `manufacturer` | string | | Mixpanel `$manufacturer` |
| `model` | string | | Mixpanel `$model` |
| `os` | string | | Mixpanel `$os` |
| `os_version` | string | | Mixpanel `$os_version` |
| `radio` | string | | Mixpanel `$radio` |
| `region` | string | | Mixpanel `$region` |
| `screen_height` | int64 | | Mixpanel `$screen_height` |
| `screen_width` | int64 | | Mixpanel `$screen_width` |
| `user_id` | string | | Mixpanel `$user_id` |
| `wifi` | bool | | Mixpanel `$wifi` |
| `mp_country_code` | string | | Mixpanel `mp_country_code` |
| `mp_lib` | string | | Mixpanel `mp_lib` |
| `mp_processing_time_ms` | int64 | | Mixpanel `mp_processing_time_ms` |
| `mp_event_size` | int64 | | Mixpanel `$mp_event_size` |
(There are other columns, but they are irrelevant, so never use them.)
## SQL dialect
Uses Cloudflare R2 SQL. See: https://developers.cloudflare.com/r2-sql/sql-reference/, but note that you must only use the table '$__table', and you cannot run SHOW or DESCRIBE statements.
- SELECT statements only.
- Use WITH to join and self-reference the events table.
- Don't use WITH RECURSIVE.
## Example: An "events per hour" timeseries panel's SQL query
```sql
SELECT date_trunc('hour', time) AS hour, COUNT(*) AS event_count
FROM $__table
WHERE $__timeFilter(time)
GROUP BY date_trunc('hour', time)
ORDER BY date_trunc('hour', time)
```
# `build_dashboard.py` Python script
```python
#!/usr/bin/env python3
"""Deploy a Grafana dashboard via the HTTP API.
Usage:
python3 build_dashboard.py smoke # validate each panel query against the datasource
python3 build_dashboard.py deploy # smoke-test, then create/update the dashboard
"""
import json, sys, time, urllib.request, urllib.error
# ── Configuration ──────────────────────────────────────────────────────────────
GRAFANA_URL = "https://grafana.undercurrentanalytics.dev"
API_KEY = "<your Grafana API key here>"
DATASOURCE = {"type": "agentified-r2sql-datasource", "uid": "<datasource UID here>"}
DASHBOARD_UID = "<dashboard UID if exists>" # existing dashboard; update in place
DASHBOARD_TITLE = "<dashboard title>"
DASHBOARD_TAGS = [] # List of string tags
# Appended to every $__timeFilter(time) clause. Set to `None` to disable.
EXTRA_FILTER = None
# ── App-specific SQL fragments ─────────────────────────────────────────────────
# ...
# ── Panels ─────────────────────────────────────────────────────────────────────
# Each entry: (title, viz_type, rawSql, fieldConfig_overrides, options, w, h)
# viz_type – Grafana panel type id, e.g. "timeseries", "stat", "barchart", "piechart", "table"
# fieldConfig_overrides – merged into defaults; use None for no overrides
# options – panel options dict; use None for defaults
# w, h – grid units (grid is 24 wide)
# Two examples included below. Replace them with the dashboards for this app.
PANELS = [
("Daily active users", "timeseries",
"SELECT date_trunc('day', time) AS day, COUNT(DISTINCT distinct_id) AS dau "
"FROM $__table WHERE event='app_launch' AND $__timeFilter(time) "
"GROUP BY date_trunc('day', time) ORDER BY day",
{"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10,
"pointSize": 6, "showPoints": "always"}, "unit": "short"}, None, 16, 8),
("Event volume per day", "timeseries",
"SELECT date_trunc('day', time) AS day, COUNT(*) AS events FROM $__table "
"WHERE $__timeFilter(time) GROUP BY date_trunc('day', time) ORDER BY day",
{"custom": {"drawStyle": "bars", "fillOpacity": 70, "lineWidth": 1},
"unit": "short"}, None, 12, 8),
]
# ── Infrastructure ─────────────────────────────────────────────────────────────
def _inject_filter(sql):
if EXTRA_FILTER:
return sql.replace("$__timeFilter(time)", f"$__timeFilter(time) AND {EXTRA_FILTER}")
return sql
def http(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
GRAFANA_URL + path, data=data, method=method,
headers={"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"User-Agent": "curl/8.4.0"})
def parse(raw):
try:
return json.loads(raw or "{}")
except json.JSONDecodeError:
return {"_raw": raw}
try:
with urllib.request.urlopen(req, timeout=60) as r:
return r.status, parse(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, parse(e.read().decode())
def smoke():
now = int(time.time() * 1000)
frm = now - 400 * 86400 * 1000
ok = True
for title, _vt, sql, *_ in PANELS:
body = {"from": str(frm), "to": str(now), "queries": [
{"refId": "A", "datasource": DATASOURCE,
"rawSql": _inject_filter(sql), "format": "table"}]}
status, resp = http("POST", "/api/ds/query", body)
r = resp.get("results", {}).get("A", {})
st = r.get("status", status)
if st == 200:
frames = r.get("frames", [])
vals = frames[0]["data"]["values"] if frames and frames[0].get("data") else []
print(f" OK [{len(vals)}c x {len(vals[0]) if vals else 0}r] {title}")
else:
ok = False
print(f" FAIL [{st}] {title}\n {str(r.get('error') or resp)[:200]}")
return ok
def build_model():
panels = []
x = y = row_h = 0
for i, (title, vt, sql, fc, opts, w, h) in enumerate(PANELS, start=1):
if x + w > 24:
x = 0
y += row_h
row_h = 0
defaults = {"color": {"mode": "palette-classic"}, "custom": {}, "unit": "short"}
for k, v in (fc or {}).items():
if isinstance(v, dict) and isinstance(defaults.get(k), dict):
defaults[k] = {**defaults[k], **v}
else:
defaults[k] = v
panel = {
"datasource": DATASOURCE,
"fieldConfig": {"defaults": defaults, "overrides": []},
"gridPos": {"h": h, "w": w, "x": x, "y": y},
"id": i,
"title": title,
"type": vt,
"targets": [{"datasource": DATASOURCE, "rawSql": _inject_filter(sql),
"refId": "A", "format": "table"}],
}
if opts:
panel["options"] = opts
panels.append(panel)
x += w
row_h = max(row_h, h)
return {
"annotations": {"list": []},
"editable": True,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": panels,
"refresh": "",
"schemaVersion": 39,
"tags": DASHBOARD_TAGS,
"templating": {"list": []},
"time": {"from": "now-30d", "to": "now"},
"timepicker": {},
"timezone": "",
"title": DASHBOARD_TITLE,
"uid": DASHBOARD_UID,
"weekStart": "",
}
def deploy():
print("Smoke-testing panel queries...")
if not smoke():
print("\nAborting deploy: one or more panel queries failed.")
sys.exit(1)
print("\nAll queries OK. Creating dashboard...")
status, resp = http("POST", "/api/dashboards/db",
{"dashboard": build_model(), "overwrite": True})
print(status, json.dumps(resp, indent=2))
if resp.get("url"):
print(f"\nOpen: {GRAFANA_URL}{resp['url']}")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "smoke"
if cmd == "smoke":
smoke()
elif cmd == "deploy":
deploy()
else:
print(__doc__)
```
Logging into Grafana to view the dashboards
Go to grafana.undercurrentanalytics.dev and sign in with the username and password from your onboarding email. From there you can browse any dashboards your harness has created, tweak panels, or build new ones directly.
When you’ve completed the steps in this quickstart guide, you should have:
- Your app sending events to Undercurrent about how users are interacting with
- Detailed Grafana dashboards that show you how your app is being used