Skip to content

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):

FieldExampleNotes
applearnfixed per app
route/ielts/reading/:idthe 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
releasec01e414eabcdVITE_RELEASE_SHA, set from GITHUB_SHA by .github/scripts/deploy-app.sh (the script both ci.yml and deploy-app.yml run); dev otherwise
devicemobile / tablet / desktopfrom viewport width and coarse pointer
pida1b2c3d4e5f60718random page-load id, new on every load and never stored in the browser
metricsLCP, INP, CLS, TTFBsent 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 (local vite dev/vite preview, e2e on 127.0.0.1): developer page loads never reach the dataset.
  • Sent with navigator.sendBeacon as text/plain JSON (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):

EventWhere
signup_completedworkers/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_startedworkers/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_completedsame 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.

Columnvitalfunnel
index1appapp
blob1vitalfunnel
blob2appapp
blob3route patternempty
blob4metricevent name
blob5releaseempty
blob6deviceempty
blob7page-load id (pid; the server makes one up for a client that sent none)empty
double1value (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 the RUM binding 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 by pid), then p75 via quantileExactWeighted and page-load counts via SUM(_sample_interval); release dev is excluded. Without the token it returns { configured: false, reason } and the admin tab shows that message.

What the owner must configure ​

  1. Dataset: nothing to click. The analytics_engine_datasets binding in workers/api/wrangler.jsonc creates nemo12_rum on the first write after the api worker is deployed. Analytics Engine must be enabled on the account (Workers Paid includes it).
  2. 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_ID is already a var in wrangler.jsonc.
  3. Release SHA: nothing to do; .github/scripts/deploy-app.sh exports VITE_RELEASE_SHA from GITHUB_SHA on every deploy (the normal one after a merge and the manual deploy-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).