Real-user monitoring (SRC-1397)
What real visitors experience (Core Web Vitals) and a three-step learning funnel, both as anonymous aggregates in Workers Analytics Engine. Read in admin: Ops > Web vitals.
What is collected
Vitals (browser, packages/rum, wired in apps/web and apps/learn main.tsx):
| Field | Example | Notes |
|---|---|---|
app | learn | fixed per app |
route | /ielts/reading/:id | the LANDING route of the page load, as a pattern: no query, no hash, id-like and email-like segments become :id (a segment after verify, certificates, student-portrait, u, invite, preferences always does), max 6 segments. Frozen at load: learn and web are SPAs, and LCP/TTFB belong to the hard load, not to the page open when the tab is hidden |
release | c01e414eabcd | VITE_RELEASE_SHA, set from GITHUB_SHA by .github/scripts/deploy-app.sh (the script both ci.yml and deploy-app.yml run); dev otherwise |
device | mobile / tablet / desktop | from viewport width and coarse pointer |
pid | a1b2c3d4e5f60718 | random page-load id, new on every load and never stored in the browser |
metrics | LCP, INP, CLS, TTFB | sent when the page is hidden; web-vitals re-reports the grown CLS/INP on every later hide, so one load can send several beacons, and the admin query keeps the LAST value per pid and metric |
- Sampled: 25% of page loads (decided once per load).
- Not collected at all when Do Not Track or Global Privacy Control is on, nor off
*.nemo12.com(localvite dev/vite preview, e2e on 127.0.0.1): developer page loads never reach the dataset. - Sent with
navigator.sendBeaconastext/plainJSON (no CORS preflight, no cookie needed). - Nothing personal: no user id, learner id, IP, full URL or query string. The server rejects any unknown field (
.strict()schema), so a client cannot add one by mistake.
Funnel (server, counted where the event happens, never sent from the browser):
| Event | Where |
|---|---|
signup_completed | workers/api/src/modules/auth/routes.ts, first Google/Apple login that creates the account (is_new), under the app the request came from (its Origin: learn, marlins, coral, dolphin as mentors...). Unknown origin: not counted |
first_lesson_started | workers/api/src/modules/curriculum/routes.ts, PUT stage done=true when the learner had no stage completions anywhere before; at most once per learner |
lesson_completed | same route, when the tick makes all four stages (orient, connect, dig, close) of a lesson done; at most once per learner and lesson |
Funnel points carry only the app and the event name. Scope: curriculum lessons on learn. IELTS practice and AI Teen steps are not counted as "lessons" here.
"Once" is kept by D1 table funnel_marks (migration 0356: learner id, event, lesson id): unticking a stage deletes its completion row and the UI treats a second click as an untick, so the completion rows alone cannot tell "first". The point is written only when the marker's INSERT OR IGNORE inserted a row, which also makes two racing ticks count once. The marker never leaves D1 and is deleted with the learner.
Storage
Workers Analytics Engine dataset nemo12_rum, binding RUM in workers/api/wrangler.jsonc.
| Column | vital | funnel |
|---|---|---|
index1 | app | app |
blob1 | vital | funnel |
blob2 | app | app |
blob3 | route pattern | empty |
blob4 | metric | event name |
blob5 | release | empty |
blob6 | device | empty |
blob7 | page-load id (pid; the server makes one up for a client that sent none) | empty |
double1 | value (ms; CLS unitless, capped at 10) | 1 |
Analytics Engine keeps data for 3 months.
Endpoints
POST /v1/rum: public. Body max 2 KB. 400 on bad JSON or schema, 413 when too large, 429 after 30 beacons per minute from one IP (in-memory per isolate; the IP is never stored), 204 otherwise. Without theRUMbinding it still answers 204 and writes nothing.GET /v1/admin/rum: admin role. Runs two Analytics Engine SQL queries, last 7 days. Vitals: an inner query keeps one value per page load and metric (argMax(double1, timestamp)grouped bypid), then p75 viaquantileExactWeightedand page-load counts viaSUM(_sample_interval); releasedevis excluded. Without the token it returns{ configured: false, reason }and the admin tab shows that message.
What the owner must configure
- Dataset: nothing to click. The
analytics_engine_datasetsbinding inworkers/api/wrangler.jsonccreatesnemo12_rumon the first write after the api worker is deployed. Analytics Engine must be enabled on the account (Workers Paid includes it). - API token for the admin view: Cloudflare dashboard > My Profile > API Tokens > Create token > Custom, permission Account > Account Analytics > Read, for the Nemo12 account only. Then set it on the api worker (dashboard > Workers > nemo12-api > Settings > Variables and Secrets > Add secret, name
RUM_SQL_TOKEN).CF_ACCOUNT_IDis already a var inwrangler.jsonc. - Release SHA: nothing to do;
.github/scripts/deploy-app.shexportsVITE_RELEASE_SHAfromGITHUB_SHAon every deploy (the normal one after a merge and the manualdeploy-app.yml).
Check after deploy: open learn on a phone, switch tabs (the beacon flushes on hide), wait a minute, then Ops > Web vitals. With a 25% sample you may need a few page loads.
Adding another app
Add "@nemo12/rum": "*" to the app's package.json, call startRum({ app: "<name>", endpoint, release: import.meta.env.VITE_RELEASE_SHA }) once in main.tsx, and add the name to RUM_APPS in workers/api/src/modules/rum/service.ts (unknown apps are rejected).