---
url: https://docs.nemo12.com/ops/rum.md
description: >-
  Anonymous Core Web Vitals beacons and three funnel counts in Workers Analytics
  Engine, the admin Web vitals tab, and owner setup.
---

# 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`
  (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):

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