diff --git a/INNOBOX_SPEC.md b/INNOBOX_SPEC.md index 90fff1b..7f28cd8 100644 --- a/INNOBOX_SPEC.md +++ b/INNOBOX_SPEC.md @@ -122,7 +122,7 @@ TypeScript monorepo (pnpm workspaces — `@innobox/shared`, `@innobox/web`, leader-only sweeps include the notification and webhook-delivery sweeps (every 30 s), the system-log alert (every 5 min, §14.7), and the **hourly housekeeping sweep** (presence rollup and daily snapshot §14.5, system-log trim §14.7, webhook-delivery trim §12.4, stale - upload sessions §11). Pure-DB sweeps are scheduled before the Entra-credential check, + upload sessions §11, the real-user-monitoring rollup and trims §14.12). Pure-DB sweeps are scheduled before the Entra-credential check, so a missing Graph/S3 configuration never skips them. Only work that calls Microsoft Graph (reconciliation, photo sync, the Graph mail transport) depends on the Entra credentials; the notification sweep (SMTP needs no Entra), the ClamAV scan sweep, and @@ -749,6 +749,9 @@ these rules close the generic web-platform paths by which it could. minute** (the `survey` bucket; its 429 warning omits the user id); - submitting a bug report (§13.10) — **3 per minute and 20 per day** (the `bug_report` bucket, both limits enforced; its 429 warning omits the user id too); + - sending a real-user-monitoring beacon (§14.12) — **20 per minute** (the `rum` + bucket; its 429s are **not** recorded in the system log — telemetry must never + turn into log noise); - every other state-changing API request — **120 per minute**. An exceeded limit answers **429** with `Retry-After` and a plain message ("Too many requests — try again shortly."). Rate-limit rejections are **logged** (structured @@ -1073,7 +1076,7 @@ shown (§13.6). | Role | Scope | Powers | |---|---|---| -| **Platform Admin** | global | Everything below in every namespace + platform settings (§14.3), namespace CRUD, role mappings, impact-area management; **feature/unfeature challenges on Home** (§13.2); **channel webhooks** (§12.4); GDPR erasure with assignment hand-over (§3); identity-sync diagnostics (§14.10); audit integrity verification (§15) | +| **Platform Admin** | global | Everything below in every namespace + platform settings (§14.3), namespace CRUD, role mappings, impact-area management; **feature/unfeature challenges on Home** (§13.2); **channel webhooks** (§12.4); GDPR erasure with assignment hand-over (§3); identity-sync diagnostics (§14.10); real user monitoring (§14.12); audit integrity verification (§15) | | **Namespace Admin** | per-namespace | Triage queue; assign/unassign; **free any→any status set** (audited override); anonymity reveal (audited); comment deletion; bulk actions; CSV export | | **Committee Member** | per-namespace | Move challenges/solutions of the namespace through the **enforced** state machines (§7.2, §8.2); comment with a committee badge. No assignment, no reveal, no bulk/config | | **Member** | per-namespace | Namespace membership only: read the namespace's restricted content and submit into it (§4.3). No powers beyond the authenticated-user baseline. Every user is an implicit member of `global` | @@ -1369,11 +1372,24 @@ Entities (Postgres; key fields only — types/constraints finalized in migration the rolled-up daily distinct-active-user count behind the §14.5 chart, plus the rolling 7- and 30-day counts the daily snapshot writes once per closed day (§14.5). Carries **no user ids** and is retained indefinitely. +- **`rum_views`** — the §14.12 raw page views: id, received_at, day (UTC date), + session_id (a random per-tab id, joined to nothing), route (template only), + hard_load, lcp_ms, inp_ms, nav_ms, ttfb_ms, cls, api_ms (int[]), api_calls, + api_errors, client_errors. **No user id, no FK.** Mutable working data, **deleted + by the worker once older than 7 days**. +- **`rum_daily`** — (day, route) unique, with a `route = '*'` row per day for all + routes: views, sessions, the p75 of each measure, api_calls, api_errors, + client_errors (§14.12). The permanent, aggregate-only form of the above; recomputed + hourly for the open 7-day window, retained indefinitely. +- **`rum_client_errors`** — fingerprint unique, message, source, frame, route, + first_seen, last_seen, count — the §14.12 deduplicated browser errors. Mutable; + deleted 90 days after `last_seen`. - **`platform_settings`** (called `settings` elsewhere in this document) — key/value platform configuration (§14.3); also holds the `system_banner` singleton (§14.6), the `system_log_notify_at` watermark (§14.7), the - `featured_limit` (§13.2, §14.3), and the **`scim_last_request_at`** stamp (ISO UTC; - written by the worker on accepted SCIM requests, at most once a minute — §14.10). + `featured_limit` (§13.2, §14.3), the **`scim_last_request_at`** stamp (ISO UTC; + written by the worker on accepted SCIM requests, at most once a minute — §14.10), + and the §14.12 `rum_enabled` / `rum_sample_rate` pair. - **`audit_log`** — append-only (§15): id, actor_user_id, action, target_type, target_id, before, after, created_at, plus the hash-chain columns **chain_seq** (bigint, unique where set), **prev_hash** and **row_hash** (hex SHA-256). All three @@ -4463,9 +4479,10 @@ All auth-required (401 otherwise), Origin-checked mutations (§2.4). The **Administration console** (`/admin`) is the hub: namespace admins and platform admins land here (platform-admin-only cards — settings, audit, system banner §14.6, system log §14.7, channel webhooks §12.4, identity sync §14.10, survey results -§14.11, the trash §10.4, public submissions §24.5 — are hidden from -namespace admins, §4). Its sub-pages — the triage queue (§14.1), platform settings -(§14.3), the audit browser (§15), and the system log (§14.7) — each render a +§14.11, the trash §10.4, public submissions §24.5, real user monitoring §14.12 — are +hidden from namespace admins, §4). Its sub-pages — the triage queue (§14.1), platform +settings (§14.3), the audit browser (§15), the system log (§14.7) and real user +monitoring (§14.12) — each render a **breadcrumb** above the page title, via a shared component: **Administration** (a link back to `/admin`) › *current page* (the trailing crumb is plain text, not a link). Every present and future `/admin/*` sub-page carries it, so the way back to the console is consistent. The @@ -4629,6 +4646,10 @@ delegated per namespace.** - **Hype** — one switch, *Weekly hype*, **off by default** (§26.8): shows or hides every §26 surface and closes the hype route; existing hypes, awards and badges are kept. Audited as `settings.hype_enabled_changed`. +- **Real user monitoring** — the `rum_enabled` switch (**off** by default) and the + `rum_sample_rate` (100 / 50 / 25 / 10 / 1 %, default 100). Both are edited on the + monitoring page itself (`/admin/monitoring`, §14.12), not here; the settings page + carries only a link. Audited as `settings.telemetry_changed`. All settings changes are audited. @@ -5247,6 +5268,315 @@ across namespaces. switch's audit row. e2e: an admin sees the funnel and, with the seed topped past 5, the question cards and the comment; the switch is usable while the card is collapsed. +### §14.12 Real user monitoring (platform admin) + +How InnoBox *feels* to the people using it, measured in their browsers: page views and +sessions per page, the Core Web Vitals, the time the app's own API calls take and how +often they fail, and the JavaScript errors that reach the console. A sub-page, +**`/admin/monitoring`**, linked from the Administration console under System log, +with the §14 breadcrumb. **Platform admins only** — never shown to namespace admins, +and every endpoint of this section answers **403** to anyone else. It complements the +§14.7 system log, which sees only what the server refused: a page that is slow, a +layout that jumps, a call that never came back or a script that threw on the client +leaves no server-side trace at all. + +#### Decisions + +1. **Off by default.** Nothing is sent until a platform admin switches collection on. + The sampling and the switch are the admin's; the shape of the data is not + configurable. +2. **Anonymous by construction.** A beacon carries **no user id** and the server never + derives one: the beacon handler reads the session only to authenticate, and the + insert does not see it. Views are grouped by a **random per-tab session id** that is + never joined to anything. Erasure (§3) has nothing to scrub here, and the §14.5 + position — *InnoBox stores no per-user browsing log* — stays true. +3. **Route templates only.** A view names its page by the **matched template** + (`/challenges/[number]`), never the concrete path, the number or the title. The + per-route table needs nothing finer, and no challenge or solution — anonymous or + not — can be correlated with a browsing session. +4. **Raw for 7 days, a per-route daily rollup forever.** Percentiles cannot be + re-aggregated, so the **7d** range — the only one the raw window covers — computes + a true p75 from the raw views, and **30d, 90d and All** read the daily rollup, + labelled as such (below). +5. **No new monitoring channel.** Nothing here reaches Prometheus `/metrics`, the + inbox or e-mail; the page is the surface. + +#### The switch and the sample rate + +- **`platform_settings`** gains **`rum_enabled`** (default **`false`**) and + **`rum_sample_rate`** (integer percent, one of **100 / 50 / 25 / 10 / 1**, default + **100**). Both live on the monitoring page itself — a control row above the chart with + the **Collect telemetry** switch, the **Sample** selector and, read-only, the flush + schedule (*"beacons every 17 s while active, backing off to 157 s when idle"*). The + Platform settings page (§14.3) lists neither; its index entry points here. +- **One audit row per change** — **`settings.telemetry_changed`** with `after: { + enabled, sampleRate }`. Opening the page, changing the range and refreshing are + **not audited**: the data carries no user ids, so a read discloses nothing about + anyone (the §14.5 chart rule, not the online-list rule). +- **Takes effect per session.** `GET /api/me` carries `rum: { enabled, sampleRate }`. + A tab decides **once, when the collector starts**, whether its session is sampled; + the decision is kept with the session id (below), so turning the rate down affects + new sessions only. Switching collection **off** stops new sessions from starting a + collector and makes the beacon endpoint **discard** everything it receives + (**204**, no row) — a tab opened before the switch flipped sends into the void until + it reloads. Switching on starts collecting from the next page load. + +#### Where the collector runs, and where it never does + +- The collector is a client component mounted by the **authenticated app shell** and + nothing else. The signed-out Home (`/`), the public form (`/submit`, + `/submit/confirm`) and the agent-access routes (§25) carry no collector and send no + beacon — the only unauthenticated surfaces stay the ones §2.1 names. +- It **does not run** while `rum_enabled` is false, while the session is not sampled, + or when the browser lacks the APIs it needs (`PerformanceObserver`, `sendBeacon`): + it then does nothing, silently. It never throws into the page: every observer and + the flush are wrapped, and a collector failure is at most a `console.debug`. +- **Admins are measured like everyone else**, their own monitoring page included. + +#### Sessions and sampling + +- A **session is one browser tab.** When the collector starts in a tab it reads + `sessionStorage`; finding nothing, it draws a random id (`crypto.randomUUID()`), + decides sampling with one draw against `rum_sample_rate`, and stores both. The + entry lives as long as the tab: a reload or an in-tab navigation keeps it, a closed + tab ends it, a duplicated tab (browsers copy `sessionStorage`) continues it. No + inactivity cut — a tab left open overnight is one session. +- The id is **never stored elsewhere** (no cookie, no `localStorage`, nothing on the + user row) and the server keeps it only to count distinct sessions. Sampling is + decided per session so that a sampled session's views are complete: a 10 % sample + is one in ten *people-tabs*, not one in ten page views. + +#### What a view records + +A **view** starts on a hard load or on a client-side route change and ends at the next +route change or when the page is hidden or unloaded. Each view becomes one row, with +these fields; a measurement that does not apply to the view is **null** and renders as +"—": + +- **`route`** — the matched template (decision 3); **`day`** — the view's UTC day. +- **LCP, CLS and TTFB** — **hard-load views only**, by their standard definitions, + measured with the **`web-vitals`** library (Apache-2.0, §21.1). A soft navigation has + no navigation response and no "largest paint" of its own, so these stay null there + rather than being faked. CLS is the hard load's final value (reported when the page + is hidden, so a hard-load view's CLS arrives with its last flush). +- **INP** — **per view**, over the interactions that happened during it, by the + `web-vitals` INP rule (the worst interaction, after discarding the single worst per + 50 interactions): the collector observes `event` entries (duration threshold 40 ms) + and attributes each to the view in progress. A view with no interaction has no INP. +- **Navigation (`nav_ms`)** — how long the page took to appear. Hard load: from + navigation start to `domContentLoadedEventEnd` (Navigation Timing). Soft navigation: + from the click or tap on the internal link (or the `history` push) that started it to + the first render of the new route. Both are "how long did I wait", measured at the + user's end. +- **API** — every same-origin `/api/*` request the page makes during the view, observed + by the collector wrapping `window.fetch` (the app's calls go through it whether they + use the caching helper or not): **`api_ms`** — the durations, + an integer array capped at **50** per view (the rest are counted but not timed); + **`api_calls`**; **`api_errors`** — a status **≥ 500**, a request that never got a + response (network failure) or one the app aborted on its **own** timeout. A 4xx is + not an error here — 403, 409, 413, 422 and 429 are the app saying no, and the + §14.7 system log already records them — and a request the browser cancelled because + the user navigated away counts as neither a call nor an error. The shell's 30-second + pulse (`GET /api/me/pulse`, §12.2) is excluded, exactly as §14.5 excludes it: it is + not the page's work. Opening the bell's inbox list is a person acting, and counts. +- **`client_errors`** — the number of error occurrences during the view (next + heading). +- Nothing else: no user agent, no viewport, no referrer, no query string, no IP. + +The chart and the table use **p75** throughout — the usual Web Vitals threshold +("good" is LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1 at the 75th percentile) — and colour +each vitals cell **good / needs improvement / poor** by those published thresholds +(TTFB 800 / 1 800 ms; navigation and API use the same three-step dot with 1 000 / +3 000 ms and 500 / 1 500 ms, InnoBox's own bars). The dot sits beside the number — a +colour alone never carries the verdict (§2.2). + +#### Client errors + +- The collector listens for **`error`** and **`unhandledrejection`** on `window`. Each + occurrence records: **`message`** (truncated to **500** characters), **`source`** — + the script file (its path only, never a query string) and line, **`frame`** — the top + stack frame's function and location (one line, truncated to 200), the **`route`** in + progress, and the moment. Never a form value, a request body, a URL carrying an id, + or anything from the DOM. A rejection whose reason is not an `Error` records its + `String()` form, truncated the same way, with no source and the fixed frame + *"(unhandled rejection)"* (it has no stack to read, and the extension rule below + would otherwise drop it as sourceless). +- **Deduped on the server** into **`rum_client_errors`**: one row per + **fingerprint** — the SHA-256 of the normalised message (digits and quoted strings + collapsed, so a message that embeds a number is one error, not a thousand) plus the + source file and line — with `message`, `source`, `frame`, `route` (the most recent + one), **`first_seen`**, **`last_seen`** and **`count`**. A repeat bumps `count` and + `last_seen`; nothing else changes. Occurrences are also counted per view (above), so + the routes table can show *where* errors happen without a second table. +- **Noise control.** A view reports at most **10** occurrences; the eleventh onward is + counted (`client_errors`) but not described. Errors thrown by browser extensions + (a `source` outside the app's own origin, `moz-extension://`, `chrome-extension://`, + or no source at all with an empty frame) are **dropped on the client** — they are + not InnoBox's errors — and `ResizeObserver loop completed with undelivered + notifications`, the one benign error every app sees, is dropped by name. +- **The list** on the page shows the fingerprint rows for the selected range (rows whose + `last_seen` is inside it), **sorted by Last seen or by Count** (a two-way toggle, + default Last seen), **capped at 50** with a muted *"showing 50 of 212"*; each row + shows the message, the route name, the source and frame in monospace, the count and a + relative last-seen. Expanding a row shows the full 500-character message and the + first-seen time. Everything is rendered as text — a message is attacker-influenced + (anyone can throw) and is **never** interpreted as HTML. +- **Retention:** a fingerprint row is deleted **90 days after its `last_seen`** by the + hourly sweep (below) — the system log's horizon. + +#### Beacons + +- **Batched, then sent.** Finished views and error occurrences queue in the collector + and go out together as one `POST /api/rum` — **`text/plain`** body holding JSON (so + `sendBeacon` needs no preflight; the handler parses it regardless of the declared + type) — of at most **50 views, 50 errors and 64 KB**; anything beyond waits for the + next flush. The view in progress is sent **open** only when the page is hidden or + unloaded (then via `navigator.sendBeacon`, with its INP and CLS so far); every other + flush sends completed views only. +- **Schedule.** While something was sent on the last tick, the next flush is **17 s** + away; after an empty tick the interval steps through **23, 37, 59, 97, 157 s** and + stays at 157 s until there is something to send, which resets it to 17 s. The primes + keep thousands of tabs from flushing in step. `visibilitychange` → hidden and + `pagehide` flush immediately, ahead of the schedule. +- **Server side.** The handler is session-authenticated like every `/api` route + (§2.4; the **`rum`** rate-limit bucket — **20 per minute** per user, its 429s + **not recorded** in the system log, as a flood must not become a log flood), same- + origin checked like every mutation, and **validates every field**: an unknown route + template is coerced to **`other`**, numbers are bounded (durations 0–120 000 ms, CLS + 0–10, arrays at their caps), strings are truncated to their limits, unknown fields + are dropped, and a body that is not a JSON object with a `views` array is **400**. It + never reads the user id into a row (decision 2). A beacon arriving while collection + is off is **204** and discarded. The route is **not** in the §14.5 presence allowlist: + a flush is not the person using InnoBox. +- `connect-src 'self'` (§2.4) already allows it; no CSP change. + +#### Data + +- **`rum_views`** (migration 0052) — id, received_at, **day** (date, UTC), session_id + (text), route, hard_load (bool), lcp_ms, inp_ms, nav_ms, ttfb_ms (int, nullable), + cls (numeric(6,4), nullable), api_ms (int[]), api_calls, api_errors, client_errors + (int). No user id, no FK to anything. Indexed on (day, route). **Deleted once older + than 7 days** by the hourly sweep. +- **`rum_daily`** — (day, route) unique: views, sessions (distinct session ids that + day), p75_lcp_ms, p75_inp_ms, p75_cls, p75_ttfb_ms, p75_nav_ms, p75_api_ms + (nullable), api_calls, api_errors, client_errors. One row per route that had a view, + plus **one row with `route = '*'`** holding the same measures over **all** views of + the day, so the all-routes line needs no weighted guess. Carries no session ids. + Retained indefinitely. +- **`rum_client_errors`** — fingerprint (text, unique), message, source, frame, route, + first_seen, last_seen, count. Retained 90 days past `last_seen`. +- All three are **mutable working data**, like `system_events`: no append-only + trigger, trimmed by the worker, untouched by the §10.3 cascade and by erasure + (nothing references a challenge, a solution or a user). + +#### The worker's part + +In the **hourly housekeeping sweep** (§2, §14.5), leader-only and pure-DB: + +1. **Rollup.** For every UTC day still inside the raw window (**today and the 7 days + before**), recompute its `rum_daily` rows from `rum_views` — true `percentile_cont + (0.75)` per measure, per route and for `*` — and **upsert** them. Recomputing the + open days each hour means a day's row is always as complete as the raw data, and a + day whose late beacons arrived after midnight (a tab hidden at 23:58 and flushed at + 00:01 carries its view's own `day`) is repaired by the next sweep. A day with no + views gets no row; the chart draws it as 0 views and breaks the vitals lines. +2. **Trim.** Delete `rum_views` older than 7 days and `rum_client_errors` whose + `last_seen` is older than 90 days. + +The recompute window and the retention are the same 7 days, and the rollup runs +before the trim in each sweep, so every day gets its final recompute from complete raw +rows before any of them is deleted. **No backfill**: the series starts the day +collection is switched on. + +#### The page — `/admin/monitoring` + +Top to bottom: + +1. **Control row** — the **Collect telemetry** switch, the **Sample** selector, the + read-only flush schedule, a **range** group **7d / 30d / 90d / All** (**default + 30d**, not persisted) and **Refresh**. **No auto-refresh**; a muted *"as of 10:04"* + stamp next to Refresh, as on §14.5. Changing the switch or the sample rate saves + immediately (`PATCH /api/admin/rum/settings`) with a toast. +2. **Chart** — per **UTC day** over the selected range, from `rum_daily` (`*` rows), + today's point computed live from `rum_views`: **page views as bars** on the left + axis and **p75 LCP and p75 INP as lines** on a right-hand milliseconds axis, + through the shared ECharts wrapper (§14.5: resolved brand tokens, `useUTC`, the + calendar-day axis labels, legend toggles, reduced-motion, the one-sentence aria + summary; no data-zoom — the range buttons are the window). Each line breaks on a + day without that measure. Under **7 days** of rollup the chart is replaced by the + muted *"Not enough history yet — the chart starts filling from the day collection + was switched on."* note, the controls staying in place. +3. **Routes table** — one row per route seen in the range, the **All routes** row + pinned first and the rest sorted by **views, descending** by default; every column + header sorts (click again to flip). Columns: **Route** (the page name), **Views**, + **Sessions**, **p75 LCP**, **p75 INP**, **p75 CLS**, **p75 TTFB**, **p75 Nav**, + **p75 API**, **API errors** (percent of calls, one decimal), **Client errors** + (occurrences). Vitals cells carry the three-step dot. Null → "—". On a narrow + viewport the table scrolls horizontally inside its card with the Route column + sticky. + - **7d** computes from `rum_views`: true p75 (`percentile_cont`), distinct + sessions over the whole range, sums for the counts. + - **30d, 90d and All** read `rum_daily` (today's rows computed live from + `rum_views`, as the chart does): sums for views, calls, errors and occurrences; + **sessions** as the sum of daily distinct sessions (a tab spanning midnight counts + on both days — stated in the caption); the vitals as the **views-weighted mean of + each day's p75** (API latency weighted by calls), because a percentile cannot be + re-aggregated. The caption under the table heading says so in those words for + these ranges, so the number is never mistaken for a true percentile. + - A route whose only views in the range are soft navigations shows "—" for LCP, + CLS and TTFB and a value for INP, navigation and API — not a bug, decision-level + behaviour (above). +4. **Client errors** — the list of the previous heading, headed *"Client errors · + 212"* (the row count in range), with the Last seen / Count toggle. +5. **A muted disclosure line** at the foot: that performance beacons are collected + from the app in the browser only while the switch is on, carry no user identity, + keep detail for 7 days and daily totals thereafter, and are visible to platform + admins only. + +**Route names.** A **registry** in the web package maps each page template to its +name — Home, Challenges, Challenge, New challenge, Search, Leaderboard, Profile, +Quick start, What's new, AI agents, Administration, Triage queue, Platform settings, +Audit log, System log, Trash, Public submissions, Real user monitoring — and the +collector resolves the concrete pathname to its template through the same registry +before the view ever leaves the browser. An unlisted template (a page added without a +registry entry, or a 404) is **Other**. A unit test walks `app/**/page.tsx` and fails +when an authenticated page has no registry entry, so Other stays empty in practice. + +#### API, audit, tests + +- **API (§16).** `POST /api/rum` (any signed-in user; the beacon, above) → **204**; + `GET /api/admin/rum/summary?range=7d|30d|90d|all` → `{ range, asOf, source: "raw" | + "rollup", routes: [{ route, name, views, sessions, p75: { lcp, inp, cls, ttfb, nav, + api }, apiCalls, apiErrors, clientErrors }], total: }`; + `GET /api/admin/rum/series?range=` → `{ range, enoughHistory, points: [{ day, views, + p75Lcp, p75Inp }] }` (contiguous UTC days, today live); `GET + /api/admin/rum/errors?range=&sort=last_seen|count` → `{ errors: [{ fingerprint, + message, source, frame, route, name, firstSeen, lastSeen, count }], total }` (capped + at 50); `GET`/`PATCH /api/admin/rum/settings` → `{ enabled, sampleRate }` (`PATCH` + validates the rate against the fixed set, **422** otherwise). The admin endpoints are + platform-admin only, **403** otherwise; `GET /api/me` gains `rum`. +- **Audit (§15).** `settings.telemetry_changed` only. Reads are not audited (above). +- **Tests.** *Unit:* the route matcher and the registry-completeness walk; the + session/sampling decision (stored once, honoured on reload, a 1 % rate samples + about one session in a hundred over a seeded run); the flush schedule (17 → 23 → 37 + → 59 → 97 → 157, reset to 17 after a non-empty tick, immediate on hide); beacon + validation and coercion (unknown route → `other`, bounds, caps, truncation, a + non-object body → 400); the error fingerprint (digits and quoted strings collapse, + source and line distinguish, extension sources and the ResizeObserver message are + dropped); the views-weighted mean and the p75 SQL fragment; the chart option builder + (bars + two lines, a null breaks the line, no data-zoom, the aria sentence); the + threshold dots at each boundary. *Integration (DB):* a beacon inserts rows without a + user id even though the request carries a session; a beacon while off is 204 and + inserts nothing; the rate-limit bucket; the rollup is idempotent, writes the `*` row, + repairs an open day after late beacons, and leaves closed days alone once outside + the window; the trims; `summary` for 7d and 30d on seeded data (`source` flips, a + true p75 vs a weighted mean), sessions distinct in raw and summed in rollup; the + errors endpoint dedupes and sorts; every admin endpoint is 403 to a namespace admin. + *E2e* (`e2e/admin-monitoring.spec.ts`): a platform admin opens the page, turns + collection on (the switch persists across a reload), sees the control row, the "not + enough history" note on a young install and the routes table with its All routes + row; a namespace admin is refused. + --- ## §15 Audit @@ -5297,7 +5627,10 @@ anomalies. Since v0.10 also: rotation are not audited; - **hype** — `hype.given` (actor the hyper, target the item, `after: { weight }`; under All only, as `like.*`) and `settings.hype_enabled_changed` (§26.11). The awards, the - Talent Scout badge and event 16 are not audited, as no award is. + Talent Scout badge and event 16 are not audited, as no award is; +- **real user monitoring** — `settings.telemetry_changed` (`after: { enabled, + sampleRate }`, §14.12). Beacons and the page's reads are not audited: the data holds + no user ids, so a read discloses nothing about anyone. **Deliberately not audited:** the worker's scheduled sweeps, webhook deliveries (their trace is the delivery row and, on final failure, the system log), identity-sync reads @@ -5545,8 +5878,10 @@ REST under `/api`, session-authenticated, JSON, UTC ISO timestamps. Resource gro `me/survey/start`, `me/survey/close`, `me/survey/responses` and `me/survey/bug` drive the §13.10 feedback survey and bug reports, and `GET` carries `openSurvey`, `selfSurvey` and `bugReports`, plus the §23 - `gamification` block and `newAchievements`; `me/achievements-seen` `POST` advances the - §23.7 toast marker), `profile` (own and other users' + `gamification` block and `newAchievements`, and the §14.12 `rum: { enabled, sampleRate }`; + `me/achievements-seen` `POST` advances the + §23.7 toast marker), `rum` (`POST` — the §14.12 beacon: any signed-in user, `text/plain` + JSON, **204**, never a row with a user id), `profile` (own and other users' profiles, §13.5, each with its §23 gamification block; `PATCH` carries the e-mail switch, the §12.1 `emailDelivery` choice, the three §12.1 per-event toggles, the §13.10 `surveysEnabled` switch and the §23 `gamificationPublic` and @@ -5565,7 +5900,9 @@ REST under `/api`, session-authenticated, JSON, UTC ISO timestamps. Resource gro only (**403** for namespace admins), anonymity-masked locations, §14.5; **`admin/audit` with `q`/`category`/`from`/`to` + `admin/audit/export`** (§15); **`admin/system-banner` `PUT`/`DELETE`** (§14.6); **`admin/system-log` + - `admin/system-log/export` + mark-seen** (§14.7) — all platform admin only; + `admin/system-log/export` + mark-seen** (§14.7); **`admin/rum/summary`, + `admin/rum/series`, `admin/rum/errors` (`GET`) and `admin/rum/settings` + (`GET`/`PATCH`)** (§14.12) — all platform admin only; since v0.10: **`admin/audit/verify`** (`POST`, NDJSON stream, one run at a time — **409** otherwise, §15); **`admin/identity-sync`** (`GET`, §14.10); **`admin/users/:userId/scrub`** (`POST`, the §3 erasure — body `{ reassignTo?: uuid | diff --git a/db/migrations/0052_real_user_monitoring.sql b/db/migrations/0052_real_user_monitoring.sql new file mode 100644 index 0000000..929ffd0 --- /dev/null +++ b/db/migrations/0052_real_user_monitoring.sql @@ -0,0 +1,75 @@ +-- 0052_real_user_monitoring.sql — real user monitoring (INNOBOX_SPEC.md §14.12). +-- +-- rum_views — one row per page view, sent by the browser collector. NO user id and no +-- FK to anything: session_id is a random per-tab id joined to nothing, and +-- route is the matched template, never a concrete path (§14.12 decisions 2 +-- and 3). Mutable working data: the worker deletes rows older than 7 days. +-- rum_daily — (day, route) → views, sessions and the p75 of each measure, plus one +-- row per day with route '*' over all views. Aggregate only, kept forever; +-- the worker recomputes the open 7-day window every hour. +-- rum_client_errors — browser errors deduplicated by fingerprint. Deleted 90 days after +-- last_seen. +-- platform_settings — rum_enabled (default false) and rum_sample_rate (default 100). +-- +-- None of the three is touched by the §10.3 delete cascade or the §3 erasure: nothing in them +-- references a challenge, a solution or a person. + +CREATE TABLE IF NOT EXISTS rum_views ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + received_at timestamptz NOT NULL DEFAULT now(), + day date NOT NULL, + session_id text NOT NULL, + route text NOT NULL, + hard_load boolean NOT NULL, + lcp_ms integer, + inp_ms integer, + nav_ms integer, + ttfb_ms integer, + cls numeric(6,4), + api_ms integer[] NOT NULL DEFAULT '{}', + api_calls integer NOT NULL DEFAULT 0, + api_errors integer NOT NULL DEFAULT 0, + client_errors integer NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS rum_views_day_route_idx ON rum_views (day, route); + +GRANT SELECT, INSERT, DELETE ON rum_views TO innobox_app; -- DELETE: the worker's 7-day trim + +CREATE TABLE IF NOT EXISTS rum_daily ( + day date NOT NULL, + route text NOT NULL, + views integer NOT NULL, + sessions integer NOT NULL, + p75_lcp_ms integer, + p75_inp_ms integer, + p75_cls numeric(6,4), + p75_ttfb_ms integer, + p75_nav_ms integer, + p75_api_ms integer, + api_calls integer NOT NULL, + api_errors integer NOT NULL, + client_errors integer NOT NULL, + PRIMARY KEY (day, route) +); + +GRANT SELECT, INSERT, UPDATE ON rum_daily TO innobox_app; + +CREATE TABLE IF NOT EXISTS rum_client_errors ( + fingerprint text PRIMARY KEY, + message text NOT NULL, + source text NOT NULL, + frame text NOT NULL, + route text NOT NULL, + first_seen timestamptz NOT NULL DEFAULT now(), + last_seen timestamptz NOT NULL DEFAULT now(), + count integer NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS rum_client_errors_last_seen_idx ON rum_client_errors (last_seen); + +GRANT SELECT, INSERT, UPDATE, DELETE ON rum_client_errors TO innobox_app; -- DELETE: the 90-day trim + +-- The switch ships off; the sample rate ships at 100 %. Re-running never flips an admin's choice. +INSERT INTO platform_settings (key, value) VALUES ('rum_enabled', 'false'::jsonb) + ON CONFLICT (key) DO NOTHING; +INSERT INTO platform_settings (key, value) VALUES ('rum_sample_rate', '100'::jsonb) + ON CONFLICT (key) DO NOTHING; diff --git a/packages/shared/package.json b/packages/shared/package.json index 046040f..fedead3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -117,6 +117,14 @@ "./hype": { "types": "./dist/hype.d.ts", "default": "./dist/hype.js" + }, + "./rum": { + "types": "./dist/rum.d.ts", + "default": "./dist/rum.js" + }, + "./rum-sql": { + "types": "./dist/rum-sql.d.ts", + "default": "./dist/rum-sql.js" } }, "scripts": { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7c5d9a7..a4dee28 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -110,6 +110,12 @@ export * from "./gamification-awards.js"; // (client-safe, also at `@innobox/shared/hype`). export * from "./hype.js"; +// §14.12 real user monitoring: the route registry, beacon validation, error fingerprints, the INP +// rule, thresholds and the rollup arithmetic (client-safe, also at `@innobox/shared/rum`), and the +// aggregate SQL web and worker share (also at `@innobox/shared/rum-sql`). +export * from "./rum.js"; +export * from "./rum-sql.js"; + // §24 public submissions: the form's limits and validation, the abuse decisions, retention dates, // the privacy-notice links and the submitter e-mails (client-safe, also at // `@innobox/shared/public-intake`), and the server-only proof-of-work and confirmation token. diff --git a/packages/shared/src/rum-sql.ts b/packages/shared/src/rum-sql.ts new file mode 100644 index 0000000..3f53381 --- /dev/null +++ b/packages/shared/src/rum-sql.ts @@ -0,0 +1,68 @@ +// The real-user-monitoring aggregate SQL (INNOBOX_SPEC.md §14.12), shared by the worker's hourly +// rollup (worker rum/rollup.ts) and the web read path (api/admin/rum/store.ts) — the 7d table, +// today's live rollup rows and today's chart point. One definition, so the stored daily rows and +// the live ones are the same arithmetic. +// +// Every measure is a true 75th percentile (`percentile_cont`, which skips nulls — a soft +// navigation has no LCP and does not drag the percentile down). The API percentile runs over +// every timed call, unnested from the views. Grouping sets add one row with route '*' over all +// routes, so the all-routes figures are real percentiles too, never a blend of per-route ones. +// Also at `@innobox/shared/rum-sql`. + +/** + * The aggregate select over `rum_views v` rows matching `where` (a SQL condition on `v`, with + * its own placeholders). `perDay` groups by (day, route) — the rollup's shape, one row per day — + * otherwise by route alone over the whole matched span (the raw 7d range). Columns: day (perDay + * only), route, views, sessions, p75_lcp_ms, p75_inp_ms, p75_cls, p75_ttfb_ms, p75_nav_ms, + * p75_api_ms, api_calls, api_errors, client_errors. + */ +export function rumAggregateSql(where: string, perDay: boolean): string { + const keys = perDay ? "v.day, " : ""; + const sets = perDay ? "grouping sets ((v.day, v.route), (v.day))" : "grouping sets ((v.route), ())"; + const join = perDay ? "a.day = m.day and a.route = m.route" : "a.route = m.route"; + return ` + select ${perDay ? "m.day, " : ""}m.route, m.views, m.sessions, + m.p75_lcp_ms, m.p75_inp_ms, m.p75_cls, m.p75_ttfb_ms, m.p75_nav_ms, + a.p75_api_ms, m.api_calls, m.api_errors, m.client_errors + from ( + select ${keys}coalesce(v.route, '*') as route, + count(*)::int as views, + count(distinct v.session_id)::int as sessions, + round(percentile_cont(0.75) within group (order by v.lcp_ms))::int as p75_lcp_ms, + round(percentile_cont(0.75) within group (order by v.inp_ms))::int as p75_inp_ms, + round((percentile_cont(0.75) within group (order by v.cls))::numeric, 4) as p75_cls, + round(percentile_cont(0.75) within group (order by v.ttfb_ms))::int as p75_ttfb_ms, + round(percentile_cont(0.75) within group (order by v.nav_ms))::int as p75_nav_ms, + coalesce(sum(v.api_calls), 0)::int as api_calls, + coalesce(sum(v.api_errors), 0)::int as api_errors, + coalesce(sum(v.client_errors), 0)::int as client_errors + from rum_views v + where ${where} + group by ${sets} + ) m + left join ( + select ${keys}coalesce(v.route, '*') as route, + round(percentile_cont(0.75) within group (order by t.ms))::int as p75_api_ms + from rum_views v + cross join lateral unnest(v.api_ms) as t(ms) + where ${where} + group by ${sets} + ) a on ${join}`; +} + +/** The columns `rumAggregateSql` returns, as node-postgres hands them over (numeric → string). */ +export interface RumAggregateRow { + day?: string | Date; + route: string; + views: number; + sessions: number; + p75_lcp_ms: number | null; + p75_inp_ms: number | null; + p75_cls: string | number | null; + p75_ttfb_ms: number | null; + p75_nav_ms: number | null; + p75_api_ms: number | null; + api_calls: number; + api_errors: number; + client_errors: number; +} diff --git a/packages/shared/src/rum.test.ts b/packages/shared/src/rum.test.ts new file mode 100644 index 0000000..33adeb7 --- /dev/null +++ b/packages/shared/src/rum.test.ts @@ -0,0 +1,323 @@ +// §14.12 real user monitoring rules (INNOBOX_SPEC.md): the route registry and matcher, sampling, +// the flush schedule, beacon validation and coercion, error description and fingerprints, the INP +// rule, thresholds, the rollup arithmetic and the chart series. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + RUM_FLUSH_SCHEDULE_S, + RUM_MAX_API_TIMINGS, + RUM_MAX_VIEWS_PER_BEACON, + RUM_OTHER_ROUTE, + addDays, + aggregateDailyRows, + apiErrorPercent, + coerceRumSampleRate, + contiguousSeries, + decideSampled, + describeClientError, + describeRejection, + errorFingerprintInput, + inpFromInteractions, + isRumSampleRate, + nextFlushStep, + normalizeErrorMessage, + parseRumRange, + rumRangeSource, + rumRangeStart, + rumRouteFor, + rumRouteName, + rumVerdict, + validateRumBeacon, + weightedMean, + type RumDailyRow, +} from "./rum.js"; +import { rumAggregateSql } from "./rum-sql.js"; + +const ORIGIN = "https://innobox.example.test"; +const TODAY = "2026-10-21"; + +test("the matcher resolves templates and never keeps a concrete id", () => { + assert.equal(rumRouteFor("/"), "/"); + assert.equal(rumRouteFor("/challenges"), "/challenges"); + assert.equal(rumRouteFor("/challenges/new"), "/challenges/new", "a static segment beats a parameter"); + assert.equal(rumRouteFor("/challenges/412"), "/challenges/[number]"); + assert.equal(rumRouteFor("/challenges/412?tab=solutions#c-9"), "/challenges/[number]"); + assert.equal(rumRouteFor("/profile/3f2b"), "/profile/[userId]"); + assert.equal(rumRouteFor("/admin/monitoring/"), "/admin/monitoring"); + assert.equal(rumRouteFor("/no/such/page"), RUM_OTHER_ROUTE); + assert.equal(rumRouteFor("/submit"), RUM_OTHER_ROUTE, "the public form is not monitored"); + assert.equal(rumRouteName("/challenges/[number]"), "Challenge"); + assert.equal(rumRouteName("*"), "All routes"); + assert.equal(rumRouteName("other"), "Other"); +}); + +test("sampling is one draw against the rate", () => { + assert.equal(decideSampled(100, 0.999), true); + assert.equal(decideSampled(10, 0.05), true); + assert.equal(decideSampled(10, 0.1), false); + assert.equal(decideSampled(1, 0.0099), true); + assert.equal(decideSampled(1, 0.01), false); + // A 1 % rate over a seeded run samples about one session in a hundred. + let seed = 42; + const random = () => ((seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31); + let sampled = 0; + for (let i = 0; i < 20_000; i++) if (decideSampled(1, random())) sampled++; + assert.ok(sampled > 120 && sampled < 280, `sampled ${sampled} of 20000`); +}); + +test("only the fixed sample rates are valid; a malformed stored rate reads as 100", () => { + assert.equal(isRumSampleRate(25), true); + assert.equal(isRumSampleRate(30), false); + assert.equal(isRumSampleRate("50"), false); + assert.equal(coerceRumSampleRate(null), 100); + assert.equal(coerceRumSampleRate(10), 10); +}); + +test("the flush schedule backs off through the primes and resets after a send", () => { + assert.deepEqual([...RUM_FLUSH_SCHEDULE_S], [17, 23, 37, 59, 97, 157]); + let step = 0; + const seen = [RUM_FLUSH_SCHEDULE_S[step]]; + for (let i = 0; i < 7; i++) { + step = nextFlushStep(step, false); + seen.push(RUM_FLUSH_SCHEDULE_S[step]); + } + assert.deepEqual(seen, [17, 23, 37, 59, 97, 157, 157, 157]); + assert.equal(nextFlushStep(4, true), 0); +}); + +test("ranges: 7d reads raw rows, longer ranges the rollup", () => { + assert.equal(parseRumRange("90d"), "90d"); + assert.equal(parseRumRange("1y"), "30d"); + assert.equal(parseRumRange(null), "30d"); + assert.equal(rumRangeSource("7d"), "raw"); + assert.equal(rumRangeSource("30d"), "rollup"); + assert.equal(rumRangeSource("all"), "rollup"); + assert.equal(rumRangeStart("7d", TODAY), "2026-10-15"); + assert.equal(rumRangeStart("all", TODAY), null); + assert.equal(addDays("2026-03-01", -1), "2026-02-28"); +}); + +test("beacon validation refuses only the shape", () => { + assert.equal(validateRumBeacon(null, TODAY).ok, false); + assert.equal(validateRumBeacon([], TODAY).ok, false); + assert.equal(validateRumBeacon({ sid: "abcdefgh-1234" }, TODAY).ok, false, "no views array"); + assert.equal(validateRumBeacon({ sid: "x", views: [] }, TODAY).ok, false, "a malformed session id"); + assert.equal(validateRumBeacon({ sid: "abcdefgh-1234", views: [] }, TODAY).ok, true); +}); + +test("beacon validation coerces, bounds, caps and truncates", () => { + const result = validateRumBeacon( + { + sid: "0b8f0c3e-1a2b-4c5d-8e9f-001122334455", + userId: "should-never-survive", + views: [ + { + route: "/challenges/412", + day: TODAY, + hard: true, + lcp: 2400.6, + inp: -5, + cls: 42, + ttfb: Number.NaN, + nav: 999_999, + api: [...Array.from({ length: 70 }, () => 120), "x"], + calls: 3, + apiErrors: 99, + errors: 2, + path: "/challenges/412", + }, + { route: "/challenges/[number]", day: "2026-10-20", hard: false, lcp: 1000, cls: 0.2, ttfb: 50, inp: 80 }, + { route: "/", day: "2025-01-01" }, + "not a view", + ], + errors: [ + { message: "x".repeat(900), source: "/_next/a.js:1", frame: "f", route: "/admin" }, + { message: " " }, + ], + }, + TODAY, + ); + assert.ok(result.ok); + const { views, errors, sessionId } = result.value; + assert.equal(sessionId, "0b8f0c3e-1a2b-4c5d-8e9f-001122334455"); + assert.equal(views.length, 3); + const [hard, soft, old] = views; + assert.equal(hard!.route, "other", "a concrete path is not a template"); + assert.equal(hard!.lcpMs, 2401); + assert.equal(hard!.inpMs, 0); + assert.equal(hard!.cls, 10); + assert.equal(hard!.ttfbMs, null); + assert.equal(hard!.navMs, 120_000); + assert.equal(hard!.apiMs.length, RUM_MAX_API_TIMINGS); + assert.equal(hard!.apiCalls, RUM_MAX_API_TIMINGS, "calls are at least the timings"); + assert.equal(hard!.apiErrors, RUM_MAX_API_TIMINGS, "errors never exceed calls"); + assert.equal(hard!.clientErrors, 2); + assert.equal((hard as unknown as Record).path, undefined); + assert.equal(soft!.route, "/challenges/[number]"); + assert.equal(soft!.day, "2026-10-20", "a late flush keeps yesterday"); + assert.equal(soft!.lcpMs, null, "no LCP on a soft navigation"); + assert.equal(soft!.cls, null); + assert.equal(soft!.ttfbMs, null); + assert.equal(soft!.inpMs, 80); + assert.equal(old!.day, TODAY, "a day outside the raw window is coerced to today"); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.message.length, 500); + assert.equal(errors[0]!.route, "/admin"); + assert.equal(JSON.stringify(result.value).includes("should-never-survive"), false); + + const many = validateRumBeacon({ sid: "abcdefgh-1234", views: Array.from({ length: 80 }, () => ({ route: "/" })) }, TODAY); + assert.ok(many.ok); + assert.equal(many.value.views.length, RUM_MAX_VIEWS_PER_BEACON); +}); + +test("the fingerprint collapses digits and quoted strings; source and line distinguish", () => { + assert.equal(normalizeErrorMessage(`Cannot read 'title' of item 412`), "Cannot read of item "); + assert.equal( + errorFingerprintInput("Failed at row 12", "/_next/a.js:10"), + errorFingerprintInput("Failed at row 9001", "/_next/a.js:10"), + ); + assert.notEqual(errorFingerprintInput("Boom", "/_next/a.js:10"), errorFingerprintInput("Boom", "/_next/a.js:11")); + assert.notEqual(errorFingerprintInput("Boom", "/_next/a.js:10"), errorFingerprintInput("Boom", "/_next/b.js:10")); +}); + +test("client errors: the app's own are described, extensions and benign noise are dropped", () => { + const own = describeClientError( + { + message: "x is undefined", + filename: `${ORIGIN}/_next/static/chunks/app.js?v=3`, + lineno: 12, + stack: `TypeError: x is undefined\n at render (${ORIGIN}/_next/static/chunks/app.js?v=3:12:5)\n at other`, + }, + ORIGIN, + ); + assert.deepEqual(own, { message: "x is undefined", source: "/_next/static/chunks/app.js:12", frame: "render (/_next/static/chunks/app.js:12:5)" }); + + const firefox = describeClientError({ message: "boom", stack: `render@${ORIGIN}/_next/a.js:3:9\n` }, ORIGIN); + assert.deepEqual(firefox, { message: "boom", source: "", frame: "render@/_next/a.js:3:9" }); + + assert.equal(describeClientError({ message: "e", filename: "chrome-extension://abc/content.js", lineno: 1 }, ORIGIN), null); + assert.equal(describeClientError({ message: "e", stack: "Error: e\n at x (moz-extension://abc/c.js:1:1)" }, ORIGIN), null); + assert.equal(describeClientError({ message: "e", filename: "https://cdn.other.test/x.js", lineno: 1 }, ORIGIN), null); + assert.equal(describeClientError({ message: "Script error." }, ORIGIN), null, "no source and no frame"); + assert.equal( + describeClientError({ message: "ResizeObserver loop completed with undelivered notifications.", filename: `${ORIGIN}/a.js` }, ORIGIN), + null, + ); +}); + +test("frame parsing stays linear on hostile stacks", () => { + // The shapes that made the earlier patterns backtrack: long runs of '@', of ':1', of letters. + for (const hostile of ["@".repeat(50_000), `x@${":1".repeat(25_000)}`, "a".repeat(50_000), `at ${"@".repeat(50_000)}:1`]) { + const started = Date.now(); + describeClientError({ message: "boom", stack: hostile }, ORIGIN); + describeRejection(Object.assign(new Error("boom"), { stack: hostile }), ORIGIN); + assert.ok(Date.now() - started < 200, `took ${Date.now() - started} ms on a ${hostile.length}-char stack`); + } + // And the ordinary shapes still parse. + assert.equal(describeClientError({ message: "e", stack: `f@${ORIGIN}/_next/a.js:3:9` }, ORIGIN)?.frame, "f@/_next/a.js:3:9"); + assert.equal(describeClientError({ message: "e", stack: "user@example.test said hi" }, ORIGIN), null, "an @ that is not a location is no frame"); +}); + +test("rejections: an Error is described normally, anything else records its string form", () => { + assert.deepEqual(describeRejection("quota exceeded", ORIGIN), { message: "quota exceeded", source: "", frame: "(unhandled rejection)" }); + assert.deepEqual(describeRejection(42, ORIGIN), { message: "42", source: "", frame: "(unhandled rejection)" }); + const err = new Error("fetch blew up"); + err.stack = `Error: fetch blew up\n at load (${ORIGIN}/_next/x.js:4:2)`; + assert.deepEqual(describeRejection(err, ORIGIN), { message: "fetch blew up", source: "", frame: "load (/_next/x.js:4:2)" }); + assert.equal(describeRejection({ toString: () => { throw new Error("no"); } }, ORIGIN), null); +}); + +test("INP discards the single worst interaction per 50", () => { + assert.equal(inpFromInteractions([]), null); + assert.equal(inpFromInteractions([40, 300, 120]), 300); + const fifty = [900, ...Array.from({ length: 49 }, (_, i) => 100 + i)]; + assert.equal(inpFromInteractions(fifty), 148, "the 900 ms outlier is discarded at 50 interactions"); + assert.equal(inpFromInteractions(fifty.slice(0, 49)), 900, "but kept below 50"); +}); + +test("verdicts at each threshold boundary", () => { + assert.equal(rumVerdict("lcp", 2500), "good"); + assert.equal(rumVerdict("lcp", 2501), "needs-improvement"); + assert.equal(rumVerdict("lcp", 4001), "poor"); + assert.equal(rumVerdict("inp", 200), "good"); + assert.equal(rumVerdict("inp", 500), "needs-improvement"); + assert.equal(rumVerdict("cls", 0.1), "good"); + assert.equal(rumVerdict("cls", 0.26), "poor"); + assert.equal(rumVerdict("ttfb", 1800), "needs-improvement"); + assert.equal(rumVerdict("nav", 3001), "poor"); + assert.equal(rumVerdict("api", 500), "good"); + assert.equal(rumVerdict("api", null), null); +}); + +const row = (day: string, route: string, views: number, lcp: number | null, apiCalls = 0, api: number | null = null): RumDailyRow => ({ + day, + route, + views, + sessions: views, + p75: { lcp, inp: null, cls: null, ttfb: null, nav: null, api }, + apiCalls, + apiErrors: 1, + clientErrors: 0, +}); + +test("the rollup ranges blend daily p75 by views (API by calls) and sum the counts", () => { + assert.equal(weightedMean([{ value: 1000, weight: 1 }, { value: 2000, weight: 3 }]), 1750); + assert.equal(weightedMean([{ value: null, weight: 5 }]), null); + + const { routes, total } = aggregateDailyRows([ + row("2026-10-20", "/", 10, 1000, 4, 100), + row("2026-10-21", "/", 30, 2000, 1, 900), + row("2026-10-21", "/search", 50, null), + row("2026-10-20", "*", 10, 1000), + row("2026-10-21", "*", 80, 1500), + ]); + assert.deepEqual( + routes.map((r) => [r.route, r.name, r.views]), + [ + ["/search", "Search", 50], + ["/", "Home", 40], + ], + ); + const home = routes.find((r) => r.route === "/")!; + assert.equal(home.p75.lcp, 1750); + assert.equal(home.p75.api, 260, "weighted by calls: (100×4 + 900×1) / 5"); + assert.equal(home.sessions, 40); + assert.equal(home.apiErrors, 2); + assert.equal(routes.find((r) => r.route === "/search")!.p75.lcp, null); + assert.equal(total.route, "*"); + assert.equal(total.views, 90); + assert.equal(total.p75.lcp, 1444, "(1000×10 + 1500×80) / 90"); + assert.equal(apiErrorPercent(1, 3), 33.3); + assert.equal(apiErrorPercent(0, 0), null); +}); + +test("the chart series is contiguous to today; quiet days are zero with no vitals", () => { + assert.deepEqual(contiguousSeries([], TODAY), []); + const series = contiguousSeries( + [ + { day: "2026-10-18", views: 5, p75Lcp: 1200, p75Inp: 90 }, + { day: "2026-10-20", views: 2, p75Lcp: null, p75Inp: 60 }, + ], + TODAY, + ); + assert.deepEqual( + series.map((p) => [p.day, p.views, p.p75Lcp]), + [ + ["2026-10-18", 5, 1200], + ["2026-10-19", 0, null], + ["2026-10-20", 2, null], + ["2026-10-21", 0, null], + ], + ); +}); + +test("the aggregate SQL adds the all-routes row and groups per day only when asked", () => { + const perDay = rumAggregateSql("v.day >= $1", true); + assert.match(perDay, /grouping sets \(\(v\.day, v\.route\), \(v\.day\)\)/); + assert.match(perDay, /coalesce\(v\.route, '\*'\)/); + assert.match(perDay, /unnest\(v\.api_ms\)/); + const range = rumAggregateSql("v.day >= $1", false); + assert.match(range, /grouping sets \(\(v\.route\), \(\)\)/); + assert.doesNotMatch(range, /m\.day/); + assert.doesNotMatch(perDay + range, /user_id/); +}); diff --git a/packages/shared/src/rum.ts b/packages/shared/src/rum.ts new file mode 100644 index 0000000..85056ce --- /dev/null +++ b/packages/shared/src/rum.ts @@ -0,0 +1,592 @@ +// Real user monitoring rules shared by the browser collector, the beacon endpoint, the admin +// read path and the worker's rollup (INNOBOX_SPEC.md §14.12). Client-safe: pure functions and +// constants, no node imports (also at `@innobox/shared/rum`). +// +// Two privacy properties are enforced here rather than trusted to callers: +// • A view names its page by TEMPLATE only. `rumRouteFor` turns a concrete pathname into its +// registry template before anything leaves the browser, and `validateRumBeacon` coerces any +// template the server does not know to "other" — so no number, title or id is ever stored. +// • A beacon carries no person. The validated shape has no field a user id could ride in. +import { calendarDayMs, utcCalendarDay } from "./calendar-day.js"; + +// ── Settings ───────────────────────────────────────────────────────────────────────────── + +export const RUM_ENABLED_KEY = "rum_enabled"; +export const RUM_SAMPLE_RATE_KEY = "rum_sample_rate"; + +/** The sample rates an admin may choose, in percent of sessions. */ +export const RUM_SAMPLE_RATES = [100, 50, 25, 10, 1] as const; +export type RumSampleRate = (typeof RUM_SAMPLE_RATES)[number]; +export const DEFAULT_RUM_SAMPLE_RATE: RumSampleRate = 100; + +export function isRumSampleRate(value: unknown): value is RumSampleRate { + return typeof value === "number" && (RUM_SAMPLE_RATES as readonly number[]).includes(value); +} + +/** A stored rate that is missing or malformed reads as the default. */ +export function coerceRumSampleRate(value: unknown): RumSampleRate { + return isRumSampleRate(value) ? value : DEFAULT_RUM_SAMPLE_RATE; +} + +/** One draw per session: `random` in [0, 1). A 10 % rate samples one session in ten. */ +export function decideSampled(rate: number, random: number): boolean { + return random * 100 < rate; +} + +// ── Flush schedule ─────────────────────────────────────────────────────────────────────── + +/** Seconds to the next flush: 17 while something was sent, then backing off through primes to + * 157 while idle. Primes keep thousands of tabs from flushing in step. */ +export const RUM_FLUSH_SCHEDULE_S = [17, 23, 37, 59, 97, 157] as const; + +/** The schedule step after a tick: back to the start when the tick sent something, else one step + * further, stopping at the last. */ +export function nextFlushStep(step: number, sentSomething: boolean): number { + if (sentSomething) return 0; + return Math.min(step + 1, RUM_FLUSH_SCHEDULE_S.length - 1); +} + +export const RUM_FLUSH_SCHEDULE_TEXT = `beacons every ${RUM_FLUSH_SCHEDULE_S[0]} s while active, backing off to ${RUM_FLUSH_SCHEDULE_S.at(-1)} s when idle`; + +// ── Limits ─────────────────────────────────────────────────────────────────────────────── + +export const RUM_MAX_VIEWS_PER_BEACON = 50; +export const RUM_MAX_ERRORS_PER_BEACON = 50; +export const RUM_MAX_BEACON_BYTES = 64 * 1024; +/** Durations timed per view; further calls are counted but not timed. */ +export const RUM_MAX_API_TIMINGS = 50; +/** Error occurrences described per view; further ones are only counted. */ +export const RUM_MAX_ERRORS_PER_VIEW = 10; +export const RUM_MESSAGE_MAX = 500; +export const RUM_FRAME_MAX = 200; +export const RUM_SOURCE_MAX = 300; +export const RUM_DURATION_MAX_MS = 120_000; +export const RUM_CLS_MAX = 10; +const RUM_COUNT_MAX = 10_000; + +/** Raw views are kept this many days; the 7d range is the only one they cover. */ +export const RUM_RAW_RETENTION_DAYS = 7; +/** A deduplicated error row is deleted this long after it was last seen. */ +export const RUM_ERROR_RETENTION_DAYS = 90; +/** The errors list shows at most this many rows. */ +export const RUM_ERROR_LIST_CAP = 50; +/** Fewer rollup days than this and the chart gives way to the "not enough history" note. */ +export const RUM_MIN_CHART_POINTS = 7; + +// ── Ranges ─────────────────────────────────────────────────────────────────────────────── + +export const RUM_RANGES = ["7d", "30d", "90d", "all"] as const; +export type RumRange = (typeof RUM_RANGES)[number]; +export const DEFAULT_RUM_RANGE: RumRange = "30d"; + +export function parseRumRange(raw: string | null | undefined): RumRange { + return (RUM_RANGES as readonly string[]).includes(raw ?? "") ? (raw as RumRange) : DEFAULT_RUM_RANGE; +} + +/** Days in a range, today included; null for All. */ +export function rumRangeDays(range: RumRange): number | null { + return range === "all" ? null : Number(range.slice(0, -1)); +} + +/** 7d is computed from raw views (a true p75); every longer range reads the daily rollup. */ +export function rumRangeSource(range: RumRange): "raw" | "rollup" { + return range === "7d" ? "raw" : "rollup"; +} + +// ── Days ───────────────────────────────────────────────────────────────────────────────── + +const DAY_MS = 24 * 60 * 60 * 1000; +const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** `day` moved by `delta` whole days (UTC calendar arithmetic, no time zone involved). */ +export function addDays(day: string, delta: number): string { + return utcCalendarDay(calendarDayMs(day) + delta * DAY_MS); +} + +/** The first UTC day a range covers, or null for All. */ +export function rumRangeStart(range: RumRange, today: string): string | null { + const days = rumRangeDays(range); + return days === null ? null : addDays(today, -(days - 1)); +} + +// ── Route registry ─────────────────────────────────────────────────────────────────────── + +/** A view of a page that is not in the registry. */ +export const RUM_OTHER_ROUTE = "other"; + +/** + * Every authenticated page, by its App Router template, with the name the table shows. The + * signed-out Home and the public form never send a beacon, so they are absent; "/" is the + * signed-in Home. A unit test walks app/**\/page.tsx and fails when a page is missing here. + */ +export const RUM_ROUTES: readonly { template: string; name: string }[] = [ + { template: "/", name: "Home" }, + { template: "/challenges", name: "Challenges" }, + { template: "/challenges/new", name: "New challenge" }, + { template: "/challenges/[number]", name: "Challenge" }, + { template: "/search", name: "Search" }, + { template: "/leaderboard", name: "Leaderboard" }, + { template: "/profile", name: "Profile" }, + { template: "/profile/[userId]", name: "User profile" }, + { template: "/quick-start", name: "Quick start" }, + { template: "/whats-new", name: "What's new" }, + { template: "/agents", name: "AI agents" }, + { template: "/admin", name: "Administration" }, + { template: "/admin/triage", name: "Triage queue" }, + { template: "/admin/settings", name: "Platform settings" }, + { template: "/admin/audit", name: "Audit log" }, + { template: "/admin/system-log", name: "System log" }, + { template: "/admin/trash", name: "Trash" }, + { template: "/admin/public-submissions", name: "Public submissions" }, + { template: "/admin/monitoring", name: "Real user monitoring" }, +]; + +/** Pages deliberately outside the registry: unauthenticated surfaces that carry no collector. */ +export const RUM_UNMONITORED_TEMPLATES: readonly string[] = ["/submit", "/submit/confirm"]; + +const TEMPLATE_NAMES = new Map(RUM_ROUTES.map((r) => [r.template, r.name])); + +/** Static templates first, then by fewer parameters, so `/challenges/new` beats + * `/challenges/[number]`. */ +const MATCH_ORDER = [...RUM_ROUTES] + .map((r) => ({ template: r.template, segments: splitPath(r.template) })) + .sort((a, b) => paramCount(a.segments) - paramCount(b.segments)); + +function splitPath(path: string): string[] { + return path.split("/").filter(Boolean); +} + +function paramCount(segments: string[]): number { + return segments.filter(isParam).length; +} + +function isParam(segment: string): boolean { + return segment.startsWith("[") && segment.endsWith("]"); +} + +export function isKnownRumRoute(template: string): boolean { + return template === RUM_OTHER_ROUTE || TEMPLATE_NAMES.has(template); +} + +/** The template a concrete pathname belongs to, or "other". Query string and hash are ignored. */ +export function rumRouteFor(pathname: string): string { + const clean = pathname.split(/[?#]/)[0] ?? ""; + const segments = splitPath(clean); + for (const candidate of MATCH_ORDER) { + if (candidate.segments.length !== segments.length) continue; + if (candidate.segments.every((seg, i) => isParam(seg) || seg === segments[i])) return candidate.template; + } + return RUM_OTHER_ROUTE; +} + +/** The table name for a template; "All routes" for the '*' row and "Other" for the rest. */ +export function rumRouteName(template: string): string { + if (template === "*") return "All routes"; + return TEMPLATE_NAMES.get(template) ?? "Other"; +} + +// ── Beacon validation (server side) ────────────────────────────────────────────────────── + +export interface RumViewInput { + route: string; + day: string; + hardLoad: boolean; + lcpMs: number | null; + inpMs: number | null; + navMs: number | null; + ttfbMs: number | null; + cls: number | null; + apiMs: number[]; + apiCalls: number; + apiErrors: number; + clientErrors: number; +} + +export interface RumErrorInput { + message: string; + source: string; + frame: string; + route: string; +} + +export interface RumBeacon { + sessionId: string; + views: RumViewInput[]; + errors: RumErrorInput[]; +} + +const SESSION_ID_RE = /^[A-Za-z0-9-]{8,64}$/; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function duration(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return Math.min(RUM_DURATION_MAX_MS, Math.max(0, Math.round(value))); +} + +function count(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.min(RUM_COUNT_MAX, Math.max(0, Math.round(value))); +} + +function clsValue(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return Math.round(Math.min(RUM_CLS_MAX, Math.max(0, value)) * 10_000) / 10_000; +} + +function text(value: unknown, max: number): string { + if (typeof value !== "string") return ""; + // One line, no control characters. + return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, max); +} + +function route(value: unknown): string { + return typeof value === "string" && isKnownRumRoute(value) ? value : RUM_OTHER_ROUTE; +} + +/** The view's own UTC day, accepted only inside the raw window (a tab hidden at 23:58 flushes + * after midnight with yesterday's day); anything else is today. */ +function viewDay(value: unknown, today: string): string { + if (typeof value !== "string" || !DAY_RE.test(value)) return today; + if (value > today || value < addDays(today, -RUM_RAW_RETENTION_DAYS)) return today; + return value; +} + +function validateView(raw: Record, today: string): RumViewInput { + const hardLoad = raw.hard === true; + const apiMs = Array.isArray(raw.api) + ? raw.api + .slice(0, RUM_MAX_API_TIMINGS) + .map(duration) + .filter((v): v is number => v !== null) + : []; + const apiCalls = Math.max(count(raw.calls), apiMs.length); + return { + route: route(raw.route), + day: viewDay(raw.day, today), + hardLoad, + // LCP, CLS and TTFB belong to a hard load; a soft navigation has none of its own. + lcpMs: hardLoad ? duration(raw.lcp) : null, + inpMs: duration(raw.inp), + navMs: duration(raw.nav), + ttfbMs: hardLoad ? duration(raw.ttfb) : null, + cls: hardLoad ? clsValue(raw.cls) : null, + apiMs, + apiCalls, + apiErrors: Math.min(count(raw.apiErrors), apiCalls), + clientErrors: count(raw.errors), + }; +} + +function validateError(raw: Record): RumErrorInput | null { + const message = text(raw.message, RUM_MESSAGE_MAX); + if (!message) return null; + return { + message, + source: text(raw.source, RUM_SOURCE_MAX), + frame: text(raw.frame, RUM_FRAME_MAX), + route: route(raw.route), + }; +} + +/** + * Validates and coerces a beacon body. Only the shape is refused (a non-object, no `views` array, + * a malformed session id); every field inside is bounded, truncated or coerced rather than + * rejected, and unknown fields are dropped. `today` is the server's UTC day. + */ +export function validateRumBeacon(raw: unknown, today: string): { ok: true; value: RumBeacon } | { ok: false; error: string } { + if (!isRecord(raw)) return { ok: false, error: "beacon must be a JSON object" }; + if (!Array.isArray(raw.views)) return { ok: false, error: "beacon must carry a views array" }; + if (typeof raw.sid !== "string" || !SESSION_ID_RE.test(raw.sid)) return { ok: false, error: "beacon must carry a session id" }; + const views = raw.views + .slice(0, RUM_MAX_VIEWS_PER_BEACON) + .filter(isRecord) + .map((v) => validateView(v, today)); + const errors = (Array.isArray(raw.errors) ? raw.errors : []) + .slice(0, RUM_MAX_ERRORS_PER_BEACON) + .filter(isRecord) + .map(validateError) + .filter((e): e is RumErrorInput => e !== null); + return { ok: true, value: { sessionId: raw.sid, views, errors } }; +} + +// ── Client errors ──────────────────────────────────────────────────────────────────────── + +/** Digits and quoted strings collapsed, so a message that embeds a number or a name is one error + * rather than thousands. The fingerprint hashes this plus the source file and line. */ +export function normalizeErrorMessage(message: string): string { + return message + .replace(/"[^"]*"|'[^']*'|`[^`]*`/g, "") + .replace(/\d+/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** What the server hashes into a fingerprint. */ +export function errorFingerprintInput(message: string, source: string): string { + return `${normalizeErrorMessage(message)}\n${source}`; +} + +const EXTENSION_SCHEME = /^(?:chrome|moz|safari|safari-web|ms-browser)-extension:\/\//i; +const BENIGN_MESSAGES = [/^ResizeObserver loop/i]; +const URL_IN_TEXT = /\b[a-z][a-z0-9+.-]*:\/\/[^\s()]+/gi; + +/** A URL reduced to what the error list may show: the path only for the app's own scripts. Null + * when it points outside the app — an extension or another origin. */ +function ownPath(url: string, origin: string): string | null { + if (EXTENSION_SCHEME.test(url)) return null; + try { + const parsed = new URL(url); + if (parsed.origin !== origin) return null; + return parsed.pathname; + } catch { + return null; + } +} + +/** A trailing ":line" or ":line:col". No two quantifiers can claim the same character, so it runs + * in linear time on any input. */ +const LINE_COL = /:\d+(?::\d+)?$/; +/** Stack lines read and the length each is cut to before any pattern runs: a stack is + * attacker-influenced text (anyone can throw), and a real top frame is far shorter. */ +const STACK_LINES_READ = 20; +const STACK_LINE_MAX = 1000; + +/** `url:12:5` → [`url`, `:12:5`]; no suffix → [`url`, ``]. */ +function splitLineCol(url: string): [string, string] { + const m = LINE_COL.exec(url); + return m ? [url.slice(0, m.index), m[0]] : [url, ""]; +} + +/** A Firefox/Safari frame, `fn@url:line:col`: an `@` followed by a whitespace-free location that + * ends in a line number. Checked with string operations, not a backtracking pattern. */ +function isAtFrame(line: string): boolean { + const at = line.lastIndexOf("@"); + if (at < 0) return false; + const location = line.slice(at + 1); + return location.length > 0 && !/\s/.test(location) && LINE_COL.test(location) && splitLineCol(location)[0].length > 0; +} + +/** The first stack line (V8 `at …` or Firefox/Safari `fn@url`), its URLs reduced to paths. Null + * when the frame points outside the app. */ +export function topFrame(stack: string | undefined, origin: string): string | null | undefined { + if (!stack) return undefined; + const line = stack + .split("\n", STACK_LINES_READ) + .map((l) => l.slice(0, STACK_LINE_MAX).trim()) + .find((l) => l.startsWith("at ") || isAtFrame(l)); + if (!line) return undefined; + let outside = false; + const rewritten = line.replace(/^at\s+/, "").replace(URL_IN_TEXT, (url) => { + // Keep the trailing ":line:col" that follows the path inside the URL match. + const [bare, lineCol] = splitLineCol(url); + const path = ownPath(bare, origin); + if (path === null) { + outside = true; + return url; + } + return `${path}${lineCol}`; + }); + if (outside) return null; + return rewritten.slice(0, RUM_FRAME_MAX); +} + +export interface RawClientError { + message: string; + /** ErrorEvent.filename — empty for a rejection or a cross-origin script. */ + filename?: string; + lineno?: number; + stack?: string; +} + +/** + * What the collector records for an error, or null to drop it: a browser extension's error, a + * script from another origin, an error with neither a source nor a frame, and the one benign + * ResizeObserver message every app sees. Never anything from the DOM or a form. + */ +export function describeClientError(raw: RawClientError, origin: string): { message: string; source: string; frame: string } | null { + const message = text(raw.message, RUM_MESSAGE_MAX); + if (!message || BENIGN_MESSAGES.some((re) => re.test(message))) return null; + const frame = topFrame(raw.stack, origin); + if (frame === null) return null; + let source = ""; + if (raw.filename) { + const path = ownPath(raw.filename, origin); + if (path === null) return null; + source = raw.lineno ? `${path}:${raw.lineno}` : path; + } + if (!source && !frame) return null; + return { message, source: source.slice(0, RUM_SOURCE_MAX), frame: frame ?? "" }; +} + +/** The frame recorded for a rejection whose reason is not an Error — it has no stack to read. */ +export const RUM_REJECTION_FRAME = "(unhandled rejection)"; + +/** An `unhandledrejection` reason: an Error is described like any error; anything else records its + * `String()` form, truncated, with a fixed frame (it has neither a source nor a stack). */ +export function describeRejection(reason: unknown, origin: string): { message: string; source: string; frame: string } | null { + if (reason instanceof Error) { + return describeClientError({ message: reason.message || reason.name, stack: reason.stack }, origin); + } + let message: string; + try { + message = text(String(reason), RUM_MESSAGE_MAX); + } catch { + return null; // an object whose toString throws + } + if (!message || BENIGN_MESSAGES.some((re) => re.test(message))) return null; + return { message, source: "", frame: RUM_REJECTION_FRAME }; +} + +// ── INP ────────────────────────────────────────────────────────────────────────────────── + +/** + * The INP of a set of interactions (each the longest event duration of one interaction id): the + * worst, after discarding the single worst per 50 interactions. Null with no interaction. + */ +export function inpFromInteractions(durations: readonly number[]): number | null { + if (durations.length === 0) return null; + const sorted = [...durations].sort((a, b) => b - a); + const index = Math.min(sorted.length - 1, Math.floor(sorted.length / 50)); + return Math.round(sorted[index]!); +} + +// ── Thresholds ─────────────────────────────────────────────────────────────────────────── + +export type RumMetric = "lcp" | "inp" | "cls" | "ttfb" | "nav" | "api"; +export type RumVerdict = "good" | "needs-improvement" | "poor"; + +/** [good up to, needs improvement up to]. The Web Vitals thresholds for LCP, INP, CLS and TTFB; + * InnoBox's own bars for navigation and API latency. */ +export const RUM_THRESHOLDS: Record = { + lcp: [2500, 4000], + inp: [200, 500], + cls: [0.1, 0.25], + ttfb: [800, 1800], + nav: [1000, 3000], + api: [500, 1500], +}; + +export function rumVerdict(metric: RumMetric, value: number | null): RumVerdict | null { + if (value === null) return null; + const [good, ni] = RUM_THRESHOLDS[metric]; + if (value <= good) return "good"; + return value <= ni ? "needs-improvement" : "poor"; +} + +// ── Aggregation over daily rows (the rollup ranges) ────────────────────────────────────── + +export interface RumP75 { + lcp: number | null; + inp: number | null; + cls: number | null; + ttfb: number | null; + nav: number | null; + api: number | null; +} + +export interface RumDailyRow { + day: string; + route: string; + views: number; + sessions: number; + p75: RumP75; + apiCalls: number; + apiErrors: number; + clientErrors: number; +} + +export interface RumRouteSummary { + route: string; + name: string; + views: number; + sessions: number; + p75: RumP75; + apiCalls: number; + apiErrors: number; + clientErrors: number; +} + +/** Weighted mean of the non-null values; null when nothing carries weight. */ +export function weightedMean(pairs: readonly { value: number | null; weight: number }[]): number | null { + let sum = 0; + let weight = 0; + for (const { value, weight: w } of pairs) { + if (value === null || w <= 0) continue; + sum += value * w; + weight += w; + } + return weight > 0 ? sum / weight : null; +} + +const roundMs = (v: number | null) => (v === null ? null : Math.round(v)); +const roundCls = (v: number | null) => (v === null ? null : Math.round(v * 10_000) / 10_000); + +/** + * Daily rows → one summary per route. Counts add up; sessions are the sum of daily distinct + * sessions; each p75 is the views-weighted mean of the daily p75 (API latency weighted by calls), + * because a percentile cannot be re-aggregated. The '*' rows become the all-routes total. + */ +export function aggregateDailyRows(rows: readonly RumDailyRow[]): { routes: RumRouteSummary[]; total: RumRouteSummary } { + const byRoute = new Map(); + for (const row of rows) { + const list = byRoute.get(row.route) ?? []; + list.push(row); + byRoute.set(row.route, list); + } + const summarize = (route: string, list: readonly RumDailyRow[]): RumRouteSummary => { + const byViews = (key: keyof RumP75) => list.map((r) => ({ value: r.p75[key], weight: r.views })); + return { + route, + name: rumRouteName(route), + views: list.reduce((n, r) => n + r.views, 0), + sessions: list.reduce((n, r) => n + r.sessions, 0), + p75: { + lcp: roundMs(weightedMean(byViews("lcp"))), + inp: roundMs(weightedMean(byViews("inp"))), + cls: roundCls(weightedMean(byViews("cls"))), + ttfb: roundMs(weightedMean(byViews("ttfb"))), + nav: roundMs(weightedMean(byViews("nav"))), + api: roundMs(weightedMean(list.map((r) => ({ value: r.p75.api, weight: r.apiCalls })))), + }, + apiCalls: list.reduce((n, r) => n + r.apiCalls, 0), + apiErrors: list.reduce((n, r) => n + r.apiErrors, 0), + clientErrors: list.reduce((n, r) => n + r.clientErrors, 0), + }; + }; + const routes = [...byRoute.entries()] + .filter(([route]) => route !== "*") + .map(([route, list]) => summarize(route, list)) + .sort((a, b) => b.views - a.views || a.name.localeCompare(b.name)); + return { routes, total: summarize("*", byRoute.get("*") ?? []) }; +} + +// ── The chart series ───────────────────────────────────────────────────────────────────── + +export interface RumSeriesPoint { + day: string; + views: number; + p75Lcp: number | null; + p75Inp: number | null; +} + +/** One point per UTC day from the first recorded day to `today`: a day without a row is 0 views + * and no vitals (the lines break there). No rows at all → no points. */ +export function contiguousSeries(rows: readonly RumSeriesPoint[], today: string): RumSeriesPoint[] { + if (rows.length === 0) return []; + const byDay = new Map(rows.map((r) => [r.day, r])); + const first = rows.reduce((min, r) => (r.day < min ? r.day : min), rows[0]!.day); + const out: RumSeriesPoint[] = []; + for (let day = first; day <= today; day = addDays(day, 1)) { + out.push(byDay.get(day) ?? { day, views: 0, p75Lcp: null, p75Inp: null }); + } + return out; +} + +/** Percent of calls that failed, one decimal; null with no calls. */ +export function apiErrorPercent(errors: number, calls: number): number | null { + if (calls <= 0) return null; + return Math.round((errors / calls) * 1000) / 10; +} diff --git a/packages/shared/src/version.ts b/packages/shared/src/version.ts index 7009e5f..806613a 100644 --- a/packages/shared/src/version.ts +++ b/packages/shared/src/version.ts @@ -5,4 +5,4 @@ // patch — fixes, styling, copy, small tweaks // minor — new features / behaviors / endpoints / migrations // major — breaking changes (API shapes, required config) -export const APP_VERSION = "1.18.0"; +export const APP_VERSION = "1.19.0"; diff --git a/packages/web/e2e/admin-monitoring.spec.ts b/packages/web/e2e/admin-monitoring.spec.ts new file mode 100644 index 0000000..bc1ca6a --- /dev/null +++ b/packages/web/e2e/admin-monitoring.spec.ts @@ -0,0 +1,102 @@ +// Real user monitoring e2e (INNOBOX_SPEC.md §14.12). Covers the user-facing flow: the +// platform-admin gate (page and API), the console link and breadcrumb, the switch persisting +// across a reload, a beacon accepted and shown in the routes table under its page name, the +// chart (or the young-install note), the range buttons and the client-error list. +// The arithmetic (true p75 vs the rollup blend, the trims) is asserted at the SQL level in +// api/rum/store.dbtest.ts and the worker's rum/rollup.dbtest.ts. +import { test, expect } from "@playwright/test"; +import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; + +test("monitoring: platform-admin only, and the API refuses a member with 403", async ({ browser }) => { + const stamp = uniqueStamp(); + const memberCtx = await browser.newContext(); + await signIn(memberCtx, { name: `E2E Monitoring Member ${stamp}` }); + const page = await memberCtx.newPage(); + await page.goto("/admin/monitoring"); + await expect(page.getByRole("heading", { name: "Real user monitoring is restricted" })).toBeVisible(); + + for (const path of ["/api/admin/rum/summary?range=7d", "/api/admin/rum/series?range=7d", "/api/admin/rum/errors", "/api/admin/rum/settings"]) { + const res = await memberCtx.request.get(path, { headers: { accept: "application/json" } }); + expect(res.status(), path).toBe(403); + } + await memberCtx.close(); +}); + +test("monitoring: an admin turns collection on, and a beacon shows up under its page name", async ({ browser }) => { + const stamp = uniqueStamp(); + const adminCtx = await browser.newContext(); + await signIn(adminCtx, { name: `E2E Monitoring Admin ${stamp}`, admin: true }); + const admin = await adminCtx.newPage(); + + // Reached from the console, with the breadcrumb back to it. + await admin.goto("/admin"); + await admin.getByRole("link", { name: /Real user monitoring/ }).first().click(); + await expect(admin).toHaveURL(/\/admin\/monitoring$/); + await expect(admin.getByRole("heading", { name: "Real user monitoring", level: 1 })).toBeVisible(); + await expect(admin.getByRole("navigation", { name: /breadcrumb/i }).getByRole("link", { name: "Administration" })).toBeVisible(); + + // The switch: turn it on (whatever an earlier run left) and see it persist across a reload. + const toggle = admin.getByRole("switch", { name: "Collect telemetry" }); + await expect(toggle).toBeEnabled(); + if ((await toggle.getAttribute("aria-checked")) !== "true") { + await toggle.click(); + await expect(admin.locator(".toast")).toContainText("Collecting telemetry"); + } + await admin.reload(); + await expect(admin.getByRole("switch", { name: "Collect telemetry" })).toHaveAttribute("aria-checked", "true"); + + try { + // Range presets: 30d by default. + const ranges = admin.getByRole("group", { name: "Range" }); + await expect(ranges.getByRole("button", { name: "30d", exact: true })).toHaveAttribute("aria-pressed", "true"); + + // A beacon from this browser: a challenge page view with an API call and an error. + const status = await admin.evaluate(async () => { + const day = new Date().toISOString().slice(0, 10); + const res = await fetch("/api/rum", { + method: "POST", + body: JSON.stringify({ + sid: crypto.randomUUID(), + views: [{ route: "/challenges/[number]", day, hard: true, lcp: 1800, inp: 120, cls: 0.02, ttfb: 300, nav: 900, api: [140], calls: 1, apiErrors: 0, errors: 1 }], + errors: [{ message: "E2E monitoring probe failed at step 7", source: "/_next/static/chunks/e2e.js:7", frame: "probe (/_next/static/chunks/e2e.js:7:3)", route: "/challenges/[number]" }], + }), + }); + return res.status; + }); + expect(status).toBe(204); + + // The 7d table reads the raw views: the page name, not the template, and the all-routes row first. + await ranges.getByRole("button", { name: "7d", exact: true }).click(); + await admin.getByRole("button", { name: "Refresh" }).click(); + const table = admin.getByTestId("rum-routes"); + await expect(table.locator("tbody tr").first()).toContainText("All routes"); + await expect(table.getByRole("rowheader", { name: "Challenge", exact: true })).toBeVisible(); + await expect(admin.getByText(/true 75th percentile/)).toBeVisible(); + + // The chart, or the honest note on a young install. + const chart = admin.getByTestId("rum-chart").locator("canvas"); + const note = admin.locator(".presence-chart-empty"); + await expect(chart.or(note).first()).toBeVisible(); + + // The error list shows the probe, deduplicated, as text; expanding shows its source. + const errorRow = admin.locator(".rum-error", { hasText: "E2E monitoring probe failed" }).first(); + await expect(errorRow).toBeVisible(); + await errorRow.locator("summary").click(); + await expect(errorRow).toContainText("/_next/static/chunks/e2e.js:7"); + await admin.getByRole("group", { name: "Sort errors" }).getByRole("button", { name: "Count" }).click(); + await expect(admin.getByRole("group", { name: "Sort errors" }).getByRole("button", { name: "Count" })).toHaveAttribute("aria-pressed", "true"); + + // 30d reads the rollup and says so. + await ranges.getByRole("button", { name: "30d", exact: true }).click(); + await expect(admin.getByText(/views-weighted mean of each day's p75/)).toBeVisible(); + } finally { + // Leave the platform as it ships: collection off. + const toggleNow = admin.getByRole("switch", { name: "Collect telemetry" }); + if ((await toggleNow.getAttribute("aria-checked")) === "true") { + await toggleNow.click(); + await expect(toggleNow).toHaveAttribute("aria-checked", "false"); + } + await adminCtx.close(); + } +}); diff --git a/packages/web/package.json b/packages/web/package.json index 20c4375..8ef7a73 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,9 +10,9 @@ "start": "node .next/standalone/server.js", "typecheck": "next typegen && tsc --noEmit", "lint": "eslint .", - "test": "node --import tsx --test src/lib/ui.test.ts src/lib/attribution.test.ts src/lib/spec-refs.test.ts src/lib/date-format-usage.test.ts src/lib/audit.test.ts src/lib/users.test.ts src/lib/routeAccess.test.ts src/lib/presence.test.ts src/lib/presence-touch.test.ts src/lib/pulse.test.ts src/lib/request-memo.test.ts src/lib/charts/presence-chart.test.ts src/lib/charts/survey-trend-chart.test.ts src/app/api/admin/validation.test.ts src/app/api/challenges/validation.test.ts src/app/api/comments/validation.test.ts src/app/api/admin/triage/validation.test.ts src/app/api/admin/settings/validation.test.ts src/app/api/admin/settings/bootstrap-admin.test.ts src/app/api/admin/email/validation.test.ts src/components/breadcrumb-crumbs.test.ts src/components/toggle-switch.test.ts src/lib/http.test.ts src/lib/rate-limit.test.ts src/lib/csrf.test.ts src/lib/security-headers.test.ts src/lib/dev-auth-guard.test.ts src/lib/entra-tenant.test.ts src/lib/system-log.test.ts src/lib/challenges-surface.test.ts src/lib/form-lock.test.ts src/lib/popovers.test.ts src/lib/auth-cookie-sweep.test.ts src/lib/signin-relink.test.ts src/lib/client-ip.test.ts src/lib/csp-mode.test.ts src/lib/csp-report.test.ts src/app/api/users/erasure-validation.test.ts src/app/api/admin/audit/csv.test.ts src/lib/challenges-view.test.ts src/app/api/admin/webhooks/routes.test.ts src/app/api/challenges/featured-rules.test.ts src/lib/auth-pages.test.ts src/lib/photo-type.test.ts src/lib/search-rate-limit.test.ts src/lib/chunked-upload.test.ts src/lib/attachment-control.test.ts src/lib/attachment-intake.test.ts src/lib/challenge-detail.test.ts src/lib/deep-link.test.ts src/app/api/challenges/delete-routes.test.ts src/app/api/follows/routes.test.ts src/lib/clamav.test.ts src/lib/email.test.ts src/lib/avatar-palette.test.ts src/lib/quick-start.test.ts src/lib/directory-card.test.ts src/app/whats-new/upcoming.test.ts src/lib/whats-new.test.ts src/app/api/me/whats-new-seen/routes.test.ts src/app/api/me/achievements-seen/routes.test.ts src/app/api/admin/ai/routes.test.ts src/app/api/admin/ai/validation.test.ts src/app/api/ai/draft/validation.test.ts src/lib/survey-client.test.ts src/app/api/public/handlers.test.ts src/app/api/public-submissions/link/routes.test.ts src/lib/public-intake.test.ts src/lib/pow-solver.test.ts src/lib/mcp/oauth.test.ts src/lib/mcp/consent-page.test.ts src/lib/mcp/protocol.test.ts src/lib/mcp/conformance.test.ts src/lib/session-revocation.test.ts src/app/api/me/sign-out-everywhere/routes.test.ts src/app/api/admin/users/[userId]/scrub/routes.test.ts scripts/e2e-specs.test.mjs", + "test": "node --import tsx --test src/lib/ui.test.ts src/lib/attribution.test.ts src/lib/spec-refs.test.ts src/lib/date-format-usage.test.ts src/lib/audit.test.ts src/lib/users.test.ts src/lib/routeAccess.test.ts src/lib/presence.test.ts src/lib/presence-touch.test.ts src/lib/pulse.test.ts src/lib/request-memo.test.ts src/lib/charts/presence-chart.test.ts src/lib/charts/survey-trend-chart.test.ts src/app/api/admin/validation.test.ts src/app/api/challenges/validation.test.ts src/app/api/comments/validation.test.ts src/app/api/admin/triage/validation.test.ts src/app/api/admin/settings/validation.test.ts src/app/api/admin/settings/bootstrap-admin.test.ts src/app/api/admin/email/validation.test.ts src/components/breadcrumb-crumbs.test.ts src/components/toggle-switch.test.ts src/lib/http.test.ts src/lib/rate-limit.test.ts src/lib/csrf.test.ts src/lib/security-headers.test.ts src/lib/dev-auth-guard.test.ts src/lib/entra-tenant.test.ts src/lib/system-log.test.ts src/lib/challenges-surface.test.ts src/lib/form-lock.test.ts src/lib/popovers.test.ts src/lib/auth-cookie-sweep.test.ts src/lib/signin-relink.test.ts src/lib/client-ip.test.ts src/lib/csp-mode.test.ts src/lib/csp-report.test.ts src/app/api/users/erasure-validation.test.ts src/app/api/admin/audit/csv.test.ts src/lib/challenges-view.test.ts src/app/api/admin/webhooks/routes.test.ts src/app/api/challenges/featured-rules.test.ts src/lib/auth-pages.test.ts src/lib/photo-type.test.ts src/lib/search-rate-limit.test.ts src/lib/chunked-upload.test.ts src/lib/attachment-control.test.ts src/lib/attachment-intake.test.ts src/lib/challenge-detail.test.ts src/lib/deep-link.test.ts src/app/api/challenges/delete-routes.test.ts src/app/api/follows/routes.test.ts src/lib/clamav.test.ts src/lib/email.test.ts src/lib/avatar-palette.test.ts src/lib/quick-start.test.ts src/lib/directory-card.test.ts src/app/whats-new/upcoming.test.ts src/lib/whats-new.test.ts src/app/api/me/whats-new-seen/routes.test.ts src/app/api/me/achievements-seen/routes.test.ts src/app/api/admin/ai/routes.test.ts src/app/api/admin/ai/validation.test.ts src/app/api/ai/draft/validation.test.ts src/lib/survey-client.test.ts src/app/api/public/handlers.test.ts src/app/api/public-submissions/link/routes.test.ts src/lib/public-intake.test.ts src/lib/pow-solver.test.ts src/lib/mcp/oauth.test.ts src/lib/mcp/consent-page.test.ts src/lib/mcp/protocol.test.ts src/lib/mcp/conformance.test.ts src/lib/session-revocation.test.ts src/app/api/me/sign-out-everywhere/routes.test.ts src/app/api/admin/users/[userId]/scrub/routes.test.ts scripts/e2e-specs.test.mjs src/lib/charts/rum-chart.test.ts src/lib/rum-registry.test.ts src/lib/rum-collector.test.ts src/app/api/rum/routes.test.ts", "test:db": "node scripts/test-db-isolated.mjs", - "test:db:run": "node --import tsx --test --test-concurrency=1 src/lib/audit.dbtest.ts src/app/api/admin/store.dbtest.ts src/app/api/admin/settings/bootstrap-admin.dbtest.ts src/app/api/challenges/store.dbtest.ts src/app/api/challenges/delete.dbtest.ts src/app/api/challenges/trash.dbtest.ts src/app/api/comments/store.dbtest.ts src/app/api/admin/triage/store.dbtest.ts src/app/api/admin/presence/store.dbtest.ts src/app/api/users/store.dbtest.ts src/app/api/users/directory-search.dbtest.ts src/app/api/notifications/store.dbtest.ts src/app/api/notifications/inbox-visibility.dbtest.ts src/app/api/notifications/item-columns.dbtest.ts src/app/api/me/pulse/store.dbtest.ts src/lib/role-resolution.dbtest.ts src/app/api/attachments/store.dbtest.ts src/app/api/me/store.dbtest.ts src/app/api/me/whats-new.dbtest.ts src/app/api/profile/store.dbtest.ts src/app/api/admin/system-log/store.dbtest.ts src/app/api/admin/audit/store.dbtest.ts src/app/api/admin/system-banner/store.dbtest.ts src/app/api/challenges/new-markers.dbtest.ts src/app/api/challenges/similar.dbtest.ts src/lib/notify-preferences.dbtest.ts src/lib/notify-digest.dbtest.ts src/lib/notify-coalesce.dbtest.ts src/lib/signin-relink.dbtest.ts src/app/api/users/erasure-reassignment.dbtest.ts src/app/api/admin/identity-sync/store.dbtest.ts src/app/api/admin/audit/verify/verify.dbtest.ts src/app/api/admin/webhooks/store.dbtest.ts src/lib/webhooks.dbtest.ts src/app/api/challenges/featured.dbtest.ts src/app/api/challenges/likes-frozen.dbtest.ts src/app/api/challenges/namespace-archived.dbtest.ts src/lib/notify-events.dbtest.ts src/lib/notify-fanout.dbtest.ts src/lib/notify-inactive.dbtest.ts src/app/api/solutions/store.dbtest.ts src/app/api/search/store.dbtest.ts src/app/api/challenges/visibility-sql.dbtest.ts src/app/api/challenges/people-visibility.dbtest.ts src/app/api/dashboard/store.dbtest.ts src/app/api/leaderboards/store.dbtest.ts src/app/api/admin/ai/store.dbtest.ts src/app/api/ai/draft/store.dbtest.ts src/app/api/ai/triage/store.dbtest.ts src/app/api/ai/similar.dbtest.ts src/app/api/ai/summary/store.dbtest.ts src/app/api/me/survey/store.dbtest.ts src/app/api/admin/survey/store.dbtest.ts src/lib/gamification.dbtest.ts src/app/api/hypes/store.dbtest.ts src/app/api/public/store.dbtest.ts src/app/api/public/visibility-request.dbtest.ts src/lib/mcp/store.dbtest.ts src/lib/mcp/tools.dbtest.ts src/app/api/users/scrub-refusals.dbtest.ts src/app/api/me/sign-out-everywhere/sign-out-everywhere.dbtest.ts", + "test:db:run": "node --import tsx --test --test-concurrency=1 src/lib/audit.dbtest.ts src/app/api/admin/store.dbtest.ts src/app/api/admin/settings/bootstrap-admin.dbtest.ts src/app/api/challenges/store.dbtest.ts src/app/api/challenges/delete.dbtest.ts src/app/api/challenges/trash.dbtest.ts src/app/api/comments/store.dbtest.ts src/app/api/admin/triage/store.dbtest.ts src/app/api/admin/presence/store.dbtest.ts src/app/api/users/store.dbtest.ts src/app/api/users/directory-search.dbtest.ts src/app/api/notifications/store.dbtest.ts src/app/api/notifications/inbox-visibility.dbtest.ts src/app/api/notifications/item-columns.dbtest.ts src/app/api/me/pulse/store.dbtest.ts src/lib/role-resolution.dbtest.ts src/app/api/attachments/store.dbtest.ts src/app/api/me/store.dbtest.ts src/app/api/me/whats-new.dbtest.ts src/app/api/profile/store.dbtest.ts src/app/api/admin/system-log/store.dbtest.ts src/app/api/admin/audit/store.dbtest.ts src/app/api/admin/system-banner/store.dbtest.ts src/app/api/challenges/new-markers.dbtest.ts src/app/api/challenges/similar.dbtest.ts src/lib/notify-preferences.dbtest.ts src/lib/notify-digest.dbtest.ts src/lib/notify-coalesce.dbtest.ts src/lib/signin-relink.dbtest.ts src/app/api/users/erasure-reassignment.dbtest.ts src/app/api/admin/identity-sync/store.dbtest.ts src/app/api/admin/audit/verify/verify.dbtest.ts src/app/api/admin/webhooks/store.dbtest.ts src/lib/webhooks.dbtest.ts src/app/api/challenges/featured.dbtest.ts src/app/api/challenges/likes-frozen.dbtest.ts src/app/api/challenges/namespace-archived.dbtest.ts src/lib/notify-events.dbtest.ts src/lib/notify-fanout.dbtest.ts src/lib/notify-inactive.dbtest.ts src/app/api/solutions/store.dbtest.ts src/app/api/search/store.dbtest.ts src/app/api/challenges/visibility-sql.dbtest.ts src/app/api/challenges/people-visibility.dbtest.ts src/app/api/dashboard/store.dbtest.ts src/app/api/leaderboards/store.dbtest.ts src/app/api/admin/ai/store.dbtest.ts src/app/api/ai/draft/store.dbtest.ts src/app/api/ai/triage/store.dbtest.ts src/app/api/ai/similar.dbtest.ts src/app/api/ai/summary/store.dbtest.ts src/app/api/me/survey/store.dbtest.ts src/app/api/admin/survey/store.dbtest.ts src/lib/gamification.dbtest.ts src/app/api/hypes/store.dbtest.ts src/app/api/public/store.dbtest.ts src/app/api/public/visibility-request.dbtest.ts src/lib/mcp/store.dbtest.ts src/lib/mcp/tools.dbtest.ts src/app/api/users/scrub-refusals.dbtest.ts src/app/api/me/sign-out-everywhere/sign-out-everywhere.dbtest.ts src/app/api/rum/store.dbtest.ts", "e2e": "playwright test -c e2e/playwright.config.ts" }, "dependencies": { @@ -31,7 +31,8 @@ "next-auth": "^4.24.15", "pg": "^8.23.1", "react": "^19.3.0", - "react-dom": "^19.3.0" + "react-dom": "^19.3.0", + "web-vitals": "^5.3.0" }, "devDependencies": { "@modelcontextprotocol/sdk": "^1.32.1", diff --git a/packages/web/src/app/admin/monitoring/page.tsx b/packages/web/src/app/admin/monitoring/page.tsx new file mode 100644 index 0000000..cb836d6 --- /dev/null +++ b/packages/web/src/app/admin/monitoring/page.tsx @@ -0,0 +1,418 @@ +"use client"; +// Platform-admin real user monitoring (INNOBOX_SPEC.md §14.12): how InnoBox feels in people's +// browsers — page views and sessions per page, the Core Web Vitals, the app's own API latency and +// failures, and the JavaScript errors that reached the console. Gated in-page against /api/me; the +// API is the real gate (403 to anyone who is not a platform admin). +// +// A snapshot, like Currently online: no auto-refresh, a Refresh button and an "as of" stamp. +// Nothing here is audited except the switch and the sample rate — the data names nobody. +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + DEFAULT_RUM_RANGE, + RUM_ERROR_LIST_CAP, + RUM_FLUSH_SCHEDULE_TEXT, + RUM_RANGES, + RUM_SAMPLE_RATES, + apiErrorPercent, + rumVerdict, + type RumMetric, + type RumRange, + type RumRouteSummary, + type RumSeriesPoint, +} from "@innobox/shared/rum"; +import { cachedGet, invalidateApi } from "@/lib/ui"; +import { readJson } from "@/lib/api-client"; +import { relativeActive } from "@/lib/presence"; +import { RUM_COLUMNS, formatRumCls, formatRumMs, formatRumPercent, sortRoutes, verdictLabel, verdictPillClass, type RumSortKey } from "@/lib/rum-format"; +import { buildRumOption } from "@/lib/charts/rum-chart"; +import type { ChartEnv } from "@/lib/charts/chart-env"; +import { Breadcrumb, adminCrumbs } from "@/components/Breadcrumb"; +import { useDateFmt } from "@/components/DateFormat"; +import { EChart } from "@/components/EChart"; + +interface MeResponse { + roles: { platformAdmin: boolean }; +} + +interface RumSettings { + enabled: boolean; + sampleRate: number; +} + +interface Summary { + asOf: string; + source: "raw" | "rollup"; + routes: RumRouteSummary[]; + total: RumRouteSummary; +} + +interface Series { + enoughHistory: boolean; + points: RumSeriesPoint[]; +} + +interface ErrorRow { + fingerprint: string; + message: string; + source: string; + frame: string; + route: string; + name: string; + firstSeen: string; + lastSeen: string; + count: number; +} + +type ErrorSort = "last_seen" | "count"; + +export default function AdminMonitoringPage() { + const [gate, setGate] = useState<"loading" | "forbidden" | "ok">("loading"); + + useEffect(() => { + let live = true; + cachedGet("/api/me") + .then((me) => live && setGate(me.roles.platformAdmin ? "ok" : "forbidden")) + .catch(() => live && setGate("forbidden")); + return () => { + live = false; + }; + }, []); + + return ( + <> +
+ {gate === "ok" && } +

Real user monitoring

+

+ How InnoBox performs in people's browsers: page views, load and responsiveness, the app's own requests, and the + script errors people run into. +

+
+ {gate === "loading" &&

Loading…

} + {gate === "forbidden" && ( +
+

Real user monitoring is restricted

+

+ Only platform admins can see this page. +

+
+ )} + {gate === "ok" && } + + ); +} + +function Monitoring() { + const fmt = useDateFmt(); + const [settings, setSettings] = useState(null); + const [savingSettings, setSavingSettings] = useState(false); + const [range, setRange] = useState(DEFAULT_RUM_RANGE); + const [summary, setSummary] = useState(null); + const [series, setSeries] = useState(null); + // `loadedAt` is when the list arrived — the reference for its "3m ago" stamps (never read during render). + const [errors, setErrors] = useState<{ errors: ErrorRow[]; total: number; loadedAt: number } | null>(null); + const [errorSort, setErrorSort] = useState("last_seen"); + const [sort, setSort] = useState<{ key: RumSortKey; desc: boolean }>({ key: "views", desc: true }); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(null); + const [toast, setToast] = useState(null); + + const notify = (message: string) => { + setToast(message); + setTimeout(() => setToast(null), 3000); + }; + + useEffect(() => { + fetch("/api/admin/rum/settings", { headers: { accept: "application/json" } }) + .then(async (res) => (res.ok ? setSettings((await res.json()) as RumSettings) : setProblem("Could not load the monitoring settings."))) + .catch(() => setProblem("Could not load the monitoring settings.")); + }, []); + + const load = useCallback(async (r: RumRange) => { + setBusy(true); + try { + const get = async (url: string): Promise => { + const res = await fetch(url, { headers: { accept: "application/json" } }); + if (!res.ok) throw new Error(String((await readJson(res)).error ?? res.status)); + return (await res.json()) as T; + }; + const [s, c] = await Promise.all([get(`/api/admin/rum/summary?range=${r}`), get(`/api/admin/rum/series?range=${r}`)]); + setSummary(s); + setSeries(c); + setProblem(null); + } catch { + setProblem("Could not load the monitoring data."); + } finally { + setBusy(false); + } + }, []); + + const loadErrors = useCallback(async (r: RumRange, s: ErrorSort) => { + try { + const res = await fetch(`/api/admin/rum/errors?range=${r}&sort=${s}`, { headers: { accept: "application/json" } }); + if (res.ok) setErrors({ ...((await res.json()) as { errors: ErrorRow[]; total: number }), loadedAt: Date.now() }); + } catch { + /* the list keeps its last state; the summary reports the failure */ + } + }, []); + + useEffect(() => { + void load(range); + }, [load, range]); + + useEffect(() => { + void loadErrors(range, errorSort); + }, [loadErrors, range, errorSort]); + + const refresh = () => { + void load(range); + void loadErrors(range, errorSort); + }; + + const saveSettings = async (patch: Partial) => { + if (!settings) return; + const previous = settings; + setSettings({ ...settings, ...patch }); + setSavingSettings(true); + try { + const res = await fetch("/api/admin/rum/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }); + const json = await readJson(res); + if (!res.ok) throw new Error(typeof json.error === "string" ? json.error : "Could not save the monitoring settings."); + setSettings(json as unknown as RumSettings); + // The collector reads these from /api/me on the next page load. + invalidateApi("/api/me"); + notify( + patch.enabled === undefined + ? `Sampling ${String(patch.sampleRate)} % of new sessions.` + : patch.enabled + ? "Collecting telemetry from the next page load." + : "Telemetry collection is off.", + ); + } catch (err) { + setSettings(previous); + notify(err instanceof Error ? err.message : "Could not save the monitoring settings."); + } finally { + setSavingSettings(false); + } + }; + + const buildChart = useCallback((env: ChartEnv) => buildRumOption({ points: series?.points ?? [], style: fmt.style, env }), [series, fmt.style]); + + const rows = useMemo(() => (summary ? sortRoutes(summary.routes, sort.key, sort.desc) : []), [summary, sort]); + const chooseSort = (key: RumSortKey) => + setSort((cur) => (cur.key === key ? { key, desc: !cur.desc } : { key, desc: key !== "name" })); + + const rollup = summary?.source === "rollup"; + + return ( + <> +
+
+
+ Collect telemetry + + +
+ + Sampling decided once per browser tab; {RUM_FLUSH_SCHEDULE_TEXT}. +
+
+
+ {RUM_RANGES.map((r) => ( + + ))} +
+
+ {summary && <>as of {fmt.time(summary.asOf)}} + +
+
+
+ + {problem &&

{problem}

} + +
+
+ Page views · p75 LCP · p75 INP per UTC day, from the daily rollup +
+ {!series &&

Loading…

} + {series && !series.enoughHistory && ( +

Not enough history yet — the chart starts filling from the day collection was switched on.

+ )} + {series && series.enoughHistory && ( + + )} +
+ +
+
+

+ Routes +

+ + {rollup + ? `${range === "all" ? "All" : range} read the daily rollup: vitals are the views-weighted mean of each day's p75 (a percentile can't be re-aggregated), and sessions add up per day.` + : "Last 7 days from raw page views: every vital is a true 75th percentile."} + +
+
+ + + + {RUM_COLUMNS.map((c) => ( + + ))} + + + + {summary && } + {rows.map((row) => ( + + ))} + {summary && rows.length === 0 && ( + + + + )} + +
+ +
+ No page views in this range{settings && !settings.enabled ? " — collection is off." : "."} +
+
+
+ +
+
+

+ Client errors · {errors?.total ?? "…"} +

+
+ {(["last_seen", "count"] as const).map((s) => ( + + ))} +
+
+ {errors && errors.errors.length === 0 &&

No client errors in this range.

} + {errors && errors.errors.length > 0 && ( +
    + {errors.errors.map((e) => ( +
  • +
    + + {e.message} + ×{e.count} + {relativeActive(e.lastSeen, errors.loadedAt)} + +
    +

    {e.message}

    +
    +
    Route
    +
    {e.name}
    +
    Source
    +
    {e.source || "—"}
    +
    Frame
    +
    {e.frame || "—"}
    +
    First seen
    +
    {fmt.dateTime(e.firstSeen)}
    +
    Last seen
    +
    {fmt.dateTime(e.lastSeen)}
    +
    +
    +
    +
  • + ))} +
+ )} + {errors && errors.total > errors.errors.length && ( +

+ Showing {errors.errors.length} of {errors.total} · capped at {RUM_ERROR_LIST_CAP} +

+ )} +
+ + {/* The page says what it records: no covert monitoring. */} +

+ Performance beacons are collected in the browser only while collection is on. They carry no user identity and name pages by type, + never by item. Per-view detail is kept for 7 days and daily totals after that; error details for 90 days after they were last + seen. Only platform admins can see this page. +

+ + {toast &&
{toast}
} + + ); +} + +function MetricCell({ metric, value }: { metric: RumMetric; value: number | null }) { + const verdict = rumVerdict(metric, value); + const shown = metric === "cls" ? formatRumCls(value) : formatRumMs(value); + if (!verdict) return {shown}; + return ( + + + {shown} + + + ); +} + +function RouteRow({ row, total = false }: { row: RumRouteSummary; total?: boolean }) { + return ( + + {row.name} + {row.views.toLocaleString("en")} + {row.sessions.toLocaleString("en")} + + + + + + + {formatRumPercent(apiErrorPercent(row.apiErrors, row.apiCalls))} + {row.clientErrors.toLocaleString("en")} + + ); +} diff --git a/packages/web/src/app/admin/page.tsx b/packages/web/src/app/admin/page.tsx index efb6099..93b55de 100644 --- a/packages/web/src/app/admin/page.tsx +++ b/packages/web/src/app/admin/page.tsx @@ -132,6 +132,12 @@ export default function AdminPage() { )} {gate === "platform_admin" && } + {gate === "platform_admin" && ( + +

Real user monitoring →

+

Page load, responsiveness, request latency and script errors, as people experience them.

+ + )} {gate === "platform_admin" && } {gate === "platform_admin" && ( diff --git a/packages/web/src/app/admin/settings/page.tsx b/packages/web/src/app/admin/settings/page.tsx index 1a92d62..c5f5e10 100644 --- a/packages/web/src/app/admin/settings/page.tsx +++ b/packages/web/src/app/admin/settings/page.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Suspense } from "react"; +import Link from "next/link"; import { cachedGet, invalidateApi } from "@/lib/ui"; import { Breadcrumb, adminCrumbs } from "@/components/Breadcrumb"; import { FeaturedLimitCard } from "@/components/FeaturedLimitCard"; @@ -154,6 +155,13 @@ function SettingsConsole() { + {/* The monitoring switch and sample rate live on the monitoring page itself; this is the pointer. */} +
+

Real user monitoring

+

+ Collection and sampling are set on the Real user monitoring page. +

+
{ + const gate = await requirePlatformAdmin(); + if (!gate.ok) return gate.response; + const params = new URL(req.url).searchParams; + return Response.json(await rumErrors(pool, parseRumRange(params.get("range")), parseRumErrorSort(params.get("sort")))); +} + +export const GET = withSystemLog("/api/admin/rum/errors", handleGET); diff --git a/packages/web/src/app/api/admin/rum/series/route.ts b/packages/web/src/app/api/admin/rum/series/route.ts new file mode 100644 index 0000000..1d8f5c3 --- /dev/null +++ b/packages/web/src/app/api/admin/rum/series/route.ts @@ -0,0 +1,18 @@ +// GET /api/admin/rum/series?range=7d|30d|90d|all (INNOBOX_SPEC.md §14.12): the chart — page views +// and p75 LCP / INP per UTC day, contiguous to today (live). Platform admin only; not audited. +import { requirePlatformAdmin } from "@/lib/auth"; +import { pool } from "@/lib/db"; +import { withSystemLog } from "@/lib/system-log"; +import { parseRumRange } from "@innobox/shared/rum"; +import { rumSeries } from "../store"; + +export const dynamic = "force-dynamic"; + +async function handleGET(req: Request): Promise { + const gate = await requirePlatformAdmin(); + if (!gate.ok) return gate.response; + const range = parseRumRange(new URL(req.url).searchParams.get("range")); + return Response.json(await rumSeries(pool, range)); +} + +export const GET = withSystemLog("/api/admin/rum/series", handleGET); diff --git a/packages/web/src/app/api/admin/rum/settings/route.ts b/packages/web/src/app/api/admin/rum/settings/route.ts new file mode 100644 index 0000000..8b87b88 --- /dev/null +++ b/packages/web/src/app/api/admin/rum/settings/route.ts @@ -0,0 +1,33 @@ +// GET/PATCH /api/admin/rum/settings (INNOBOX_SPEC.md §14.12): the Collect telemetry switch and the +// sample rate, edited on the monitoring page itself. Platform admin only. A change writes one +// `settings.telemetry_changed` audit row with the resulting pair; a no-op patch writes nothing. +import { requirePlatformAdmin } from "@/lib/auth"; +import { pool } from "@/lib/db"; +import { readJsonObject } from "@/lib/http"; +import { rateLimit } from "@/lib/rate-limit"; +import { getRumSettings, setRumSettings } from "@/lib/rum-settings"; +import { withSystemLog } from "@/lib/system-log"; +import { parseRumSettingsPatch } from "./validation"; + +export const dynamic = "force-dynamic"; + +async function handleGET(): Promise { + const gate = await requirePlatformAdmin(); + if (!gate.ok) return gate.response; + return Response.json(await getRumSettings(pool)); +} + +async function handlePATCH(req: Request): Promise { + const gate = await requirePlatformAdmin(); + if (!gate.ok) return gate.response; + const limited = rateLimit(gate.user.id, "mutation"); + if (limited) return limited; + const read = await readJsonObject(req); + if (!read.ok) return read.response; + const patch = parseRumSettingsPatch(read.value); + if (!patch.ok) return Response.json({ error: patch.error }, { status: patch.status }); + return Response.json(await setRumSettings(pool, patch.value, gate.user.id)); +} + +export const GET = withSystemLog("/api/admin/rum/settings", handleGET); +export const PATCH = withSystemLog("/api/admin/rum/settings", handlePATCH); diff --git a/packages/web/src/app/api/admin/rum/settings/validation.ts b/packages/web/src/app/api/admin/rum/settings/validation.ts new file mode 100644 index 0000000..b4ce3b3 --- /dev/null +++ b/packages/web/src/app/api/admin/rum/settings/validation.ts @@ -0,0 +1,25 @@ +// Body validation for PATCH /api/admin/rum/settings (INNOBOX_SPEC.md §14.12). Pure, so the unit +// runner covers it without a session. +import { RUM_SAMPLE_RATES, isRumSampleRate, type RumSampleRate } from "@innobox/shared/rum"; + +export type RumSettingsPatch = { enabled?: boolean; sampleRate?: RumSampleRate }; + +export function parseRumSettingsPatch( + body: Record, +): { ok: true; value: RumSettingsPatch } | { ok: false; status: 400 | 422; error: string } { + const value: RumSettingsPatch = {}; + if (body.enabled !== undefined) { + if (typeof body.enabled !== "boolean") return { ok: false, status: 400, error: "enabled must be true or false" }; + value.enabled = body.enabled; + } + if (body.sampleRate !== undefined) { + if (!isRumSampleRate(body.sampleRate)) { + return { ok: false, status: 422, error: `The sample rate must be one of ${RUM_SAMPLE_RATES.join(", ")} percent.` }; + } + value.sampleRate = body.sampleRate; + } + if (value.enabled === undefined && value.sampleRate === undefined) { + return { ok: false, status: 400, error: "Nothing to change — send enabled or sampleRate." }; + } + return { ok: true, value }; +} diff --git a/packages/web/src/app/api/admin/rum/store.ts b/packages/web/src/app/api/admin/rum/store.ts new file mode 100644 index 0000000..c3fcd57 --- /dev/null +++ b/packages/web/src/app/api/admin/rum/store.ts @@ -0,0 +1,190 @@ +// Read path for the §14.12 monitoring page (INNOBOX_SPEC.md): the routes table, the chart series +// and the client-error list. Platform admin only (the routes gate it). Nothing read here names a +// person — rum_views and rum_daily have no user column — so none of it is audited. +// +// Where the numbers come from: +// • 7d — rum_views directly, the only range the 7-day raw window covers: true p75 per route and +// for all routes, distinct sessions over the whole range. +// • 30d, 90d, All — rum_daily for the closed days plus today's rows computed live from +// rum_views with the very SQL the worker stores (shared rum-sql), then blended by +// aggregateDailyRows: a views-weighted mean of daily p75, because percentiles cannot be +// re-aggregated. The page says so in its caption. +// +// Days are UTC throughout: dates leave SQL as 'YYYY-MM-DD' text (node-postgres would otherwise +// turn a `date` into a local-midnight Date), and "today" is the database's UTC day. +// Relative imports only (no `@/`) so the gated dbtests can load it. +import type { Pool } from "pg"; +import { + RUM_ERROR_LIST_CAP, + RUM_MIN_CHART_POINTS, + aggregateDailyRows, + contiguousSeries, + rumRangeSource, + rumRangeStart, + rumRouteName, + type RumDailyRow, + type RumRange, + type RumRouteSummary, + type RumSeriesPoint, +} from "@innobox/shared/rum"; +import { rumAggregateSql, type RumAggregateRow } from "@innobox/shared/rum-sql"; + +async function dbToday(pool: Pool): Promise { + const { rows } = await pool.query<{ today: string }>(`select to_char((now() at time zone 'utc')::date, 'YYYY-MM-DD') as today`); + return rows[0]!.today; +} + +const num = (v: string | number | null): number | null => (v === null ? null : Number(v)); + +function toSummary(row: RumAggregateRow): RumRouteSummary { + return { + route: row.route, + name: rumRouteName(row.route), + views: row.views, + sessions: row.sessions, + p75: { + lcp: row.p75_lcp_ms, + inp: row.p75_inp_ms, + cls: num(row.p75_cls), + ttfb: row.p75_ttfb_ms, + nav: row.p75_nav_ms, + api: row.p75_api_ms, + }, + apiCalls: row.api_calls, + apiErrors: row.api_errors, + clientErrors: row.client_errors, + }; +} + +function toDaily(row: RumAggregateRow & { day: string }): RumDailyRow { + const s = toSummary(row); + return { day: row.day, route: s.route, views: s.views, sessions: s.sessions, p75: s.p75, apiCalls: s.apiCalls, apiErrors: s.apiErrors, clientErrors: s.clientErrors }; +} + +const EMPTY_TOTAL: RumRouteSummary = { + route: "*", + name: rumRouteName("*"), + views: 0, + sessions: 0, + p75: { lcp: null, inp: null, cls: null, ttfb: null, nav: null, api: null }, + apiCalls: 0, + apiErrors: 0, + clientErrors: 0, +}; + +/** Today's per-route rows plus its '*' row, computed live from the raw views. */ +async function todayLive(pool: Pool, today: string): Promise { + const { rows } = await pool.query( + `select to_char(q.day, 'YYYY-MM-DD') as day, q.route, q.views, q.sessions, q.p75_lcp_ms, q.p75_inp_ms, q.p75_cls, + q.p75_ttfb_ms, q.p75_nav_ms, q.p75_api_ms, q.api_calls, q.api_errors, q.client_errors + from (${rumAggregateSql("v.day = $1::date", true)}) q`, + [today], + ); + return rows.map(toDaily); +} + +export interface RumSummary { + range: RumRange; + asOf: string; + source: "raw" | "rollup"; + routes: RumRouteSummary[]; + total: RumRouteSummary; +} + +export async function rumSummary(pool: Pool, range: RumRange): Promise { + const today = await dbToday(pool); + const start = rumRangeStart(range, today); + const source = rumRangeSource(range); + const asOf = new Date().toISOString(); + + if (source === "raw") { + const { rows } = await pool.query(`select * from (${rumAggregateSql("v.day >= $1::date", false)}) q`, [start ?? today]); + const all = rows.map(toSummary); + const total = all.find((r) => r.route === "*"); + const routes = all.filter((r) => r.route !== "*" && r.views > 0).sort((a, b) => b.views - a.views || a.name.localeCompare(b.name)); + return { range, asOf, source, routes, total: total && total.views > 0 ? total : EMPTY_TOTAL }; + } + + const { rows } = await pool.query( + `select to_char(day, 'YYYY-MM-DD') as day, route, views, sessions, p75_lcp_ms, p75_inp_ms, p75_cls, + p75_ttfb_ms, p75_nav_ms, p75_api_ms, api_calls, api_errors, client_errors + from rum_daily + where day < $1::date and ($2::date is null or day >= $2::date)`, + [today, start], + ); + const daily = [...rows.map(toDaily), ...(await todayLive(pool, today))]; + const { routes, total } = aggregateDailyRows(daily); + return { range, asOf, source, routes, total: total.views > 0 ? total : EMPTY_TOTAL }; +} + +export interface RumSeries { + range: RumRange; + enoughHistory: boolean; + points: RumSeriesPoint[]; +} + +/** One point per UTC day, from the first recorded day to today (live), trimmed to the range. The + * "enough history" test counts the whole series, not the range: a 7d view of a year-old install + * is a chart, not the young-install note. */ +export async function rumSeries(pool: Pool, range: RumRange): Promise { + const today = await dbToday(pool); + const { rows } = await pool.query<{ day: string; views: number; p75_lcp_ms: number | null; p75_inp_ms: number | null }>( + `select to_char(day, 'YYYY-MM-DD') as day, views, p75_lcp_ms, p75_inp_ms + from rum_daily where route = '*' and day < $1::date order by day`, + [today], + ); + const live = (await todayLive(pool, today)).find((r) => r.route === "*" && r.views > 0); + const recorded: RumSeriesPoint[] = rows.map((r) => ({ day: r.day, views: r.views, p75Lcp: r.p75_lcp_ms, p75Inp: r.p75_inp_ms })); + if (live) recorded.push({ day: today, views: live.views, p75Lcp: live.p75.lcp, p75Inp: live.p75.inp }); + const full = contiguousSeries(recorded, today); + const start = rumRangeStart(range, today); + return { range, enoughHistory: full.length >= RUM_MIN_CHART_POINTS, points: start ? full.filter((p) => p.day >= start) : full }; +} + +export interface RumErrorRow { + fingerprint: string; + message: string; + source: string; + frame: string; + route: string; + name: string; + firstSeen: string; + lastSeen: string; + count: number; +} + +export type RumErrorSort = "last_seen" | "count"; + +export function parseRumErrorSort(raw: string | null | undefined): RumErrorSort { + return raw === "count" ? "count" : "last_seen"; +} + +/** The deduplicated errors last seen inside the range, capped at 50, plus how many there are. */ +export async function rumErrors(pool: Pool, range: RumRange, sort: RumErrorSort): Promise<{ errors: RumErrorRow[]; total: number }> { + const today = await dbToday(pool); + const start = rumRangeStart(range, today); + const where = `($1::date is null or last_seen >= ($1::date)::timestamp at time zone 'utc')`; + const order = sort === "count" ? "count desc, last_seen desc" : "last_seen desc, count desc"; + const [list, total] = await Promise.all([ + pool.query<{ fingerprint: string; message: string; source: string; frame: string; route: string; first_seen: Date; last_seen: Date; count: number }>( + `select fingerprint, message, source, frame, route, first_seen, last_seen, count + from rum_client_errors where ${where} order by ${order} limit $2`, + [start, RUM_ERROR_LIST_CAP], + ), + pool.query<{ n: number }>(`select count(*)::int as n from rum_client_errors where ${where}`, [start]), + ]); + return { + errors: list.rows.map((r) => ({ + fingerprint: r.fingerprint, + message: r.message, + source: r.source, + frame: r.frame, + route: r.route, + name: rumRouteName(r.route), + firstSeen: r.first_seen.toISOString(), + lastSeen: r.last_seen.toISOString(), + count: r.count, + })), + total: total.rows[0]?.n ?? 0, + }; +} diff --git a/packages/web/src/app/api/admin/rum/summary/route.ts b/packages/web/src/app/api/admin/rum/summary/route.ts new file mode 100644 index 0000000..fa3cb25 --- /dev/null +++ b/packages/web/src/app/api/admin/rum/summary/route.ts @@ -0,0 +1,19 @@ +// GET /api/admin/rum/summary?range=7d|30d|90d|all (INNOBOX_SPEC.md §14.12): the routes table and +// its all-routes row. Platform admin only. Not audited: the data carries no user ids, so a read +// discloses nothing about anyone. +import { requirePlatformAdmin } from "@/lib/auth"; +import { pool } from "@/lib/db"; +import { withSystemLog } from "@/lib/system-log"; +import { parseRumRange } from "@innobox/shared/rum"; +import { rumSummary } from "../store"; + +export const dynamic = "force-dynamic"; + +async function handleGET(req: Request): Promise { + const gate = await requirePlatformAdmin(); + if (!gate.ok) return gate.response; + const range = parseRumRange(new URL(req.url).searchParams.get("range")); + return Response.json(await rumSummary(pool, range)); +} + +export const GET = withSystemLog("/api/admin/rum/summary", handleGET); diff --git a/packages/web/src/app/api/me/route.ts b/packages/web/src/app/api/me/route.ts index a8e6d6c..4206974 100644 --- a/packages/web/src/app/api/me/route.ts +++ b/packages/web/src/app/api/me/route.ts @@ -18,6 +18,7 @@ import { readBugReports, readOpenSurvey, readSelfSurvey } from "./survey/store"; import { meGamification, newAchievements } from "@/lib/gamification-read"; import { getMcpSettings, hasLiveConnection } from "@/lib/mcp/store"; import { getHypeEnabled } from "@/lib/hype-settings"; +import { getRumSettings } from "@/lib/rum-settings"; import { viewerHypeBudget } from "../hypes/store"; import { readJsonObject } from "@/lib/http"; import { rateLimit } from "@/lib/rate-limit"; @@ -27,7 +28,7 @@ async function handleGET(): Promise { const user = await getSessionUser(); if (!user) return Response.json({ error: "unauthenticated" }, { status: 401 }); - const [dateFormat, aiName, openSurvey, selfSurvey, bugReports, gamification, achievements, mcp, connected, hypeOn] = await Promise.all([ + const [dateFormat, aiName, openSurvey, selfSurvey, bugReports, gamification, achievements, mcp, connected, hypeOn, rum] = await Promise.all([ getDateFormat(pool), getAiDisplayNameSetting(pool), readOpenSurvey(pool, user.id), @@ -38,6 +39,7 @@ async function handleGET(): Promise { getMcpSettings(pool), hasLiveConnection(pool, user.id), getHypeEnabled(pool), + getRumSettings(pool), ]); // §26.11: the hype button's initial budget — null while hype is off. const hype = hypeOn ? { enabled: true, hypesLeft: (await viewerHypeBudget(pool, user.id)).hypesLeft } : null; @@ -80,6 +82,8 @@ async function handleGET(): Promise { // still holds a connection (so they can revoke it). agentsMenu: mcp.enabled || connected, hype, + // §14.12: whether a new tab starts the monitoring collector, and at what sample rate. + rum, }); } diff --git a/packages/web/src/app/api/rum/route.ts b/packages/web/src/app/api/rum/route.ts new file mode 100644 index 0000000..4b9564d --- /dev/null +++ b/packages/web/src/app/api/rum/route.ts @@ -0,0 +1,49 @@ +// POST /api/rum — the §14.12 real-user-monitoring beacon (INNOBOX_SPEC.md). Any signed-in user; +// the body is JSON sent as text/plain (so navigator.sendBeacon needs no preflight) and is parsed +// whatever its declared type. +// +// The session is read to authenticate and to key the rate limit, and for nothing else: the user id +// never reaches the store (§14.12 decision 2), the system log records this route without an actor, +// and the rate-limit warning omits the id. While collection is off every beacon is answered 204 and +// dropped unread. The route is not in the presence allowlist — a flush is not someone using InnoBox. +import { requireUser } from "@/lib/auth"; +import { pool } from "@/lib/db"; +import { readBytesLimited } from "@/lib/http"; +import { rateLimit } from "@/lib/rate-limit"; +import { getRumSettings } from "@/lib/rum-settings"; +import { withSystemLog } from "@/lib/system-log"; +import { RUM_MAX_BEACON_BYTES, validateRumBeacon } from "@innobox/shared/rum"; +import { insertRumBeacon } from "./store"; + +export const dynamic = "force-dynamic"; + +const accepted = () => new Response(null, { status: 204 }); + +async function handlePOST(req: Request): Promise { + const gate = await requireUser(); + if (!gate.ok) return gate.response; + const limited = rateLimit(gate.user.id, "rum", { anonymous: true }); + if (limited) return limited; + + // Switched off: discard without reading the body. A tab opened before the switch flipped keeps + // sending until it reloads, and nothing it sends is kept. + if (!(await getRumSettings(pool)).enabled) return accepted(); + + const bytes = await readBytesLimited(req, RUM_MAX_BEACON_BYTES); + if (!bytes.ok) return bytes.response; + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes.value)); + } catch { + return Response.json({ error: "request body must be valid JSON" }, { status: 400 }); + } + const today = new Date().toISOString().slice(0, 10); + const beacon = validateRumBeacon(parsed, today); + if (!beacon.ok) return Response.json({ error: beacon.error }, { status: 400 }); + + await insertRumBeacon(pool, beacon.value); + return accepted(); +} + +// §14.7 wrapper: failures are recorded (with no actor), but never this route's 429s. +export const POST = withSystemLog("/api/rum", handlePOST, { anonymousActor: true, unrecordedStatuses: [429] }); diff --git a/packages/web/src/app/api/rum/routes.test.ts b/packages/web/src/app/api/rum/routes.test.ts new file mode 100644 index 0000000..2bb29f1 --- /dev/null +++ b/packages/web/src/app/api/rum/routes.test.ts @@ -0,0 +1,86 @@ +// §16 contract guard for the §14.12 monitoring routes (INNOBOX_SPEC.md): the beacon's order of +// checks and its privacy shape (no user id reaches the store, no actor in the system log, no 429 +// rows), the admin endpoints' platform-admin gate and absence of audit on reads, the settings +// patch rules, the `me` field and the rate-limit bucket. Source-level for the handlers, which +// depend on the session layer the unit runner cannot load (the stores are covered by +// store.dbtest.ts). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { requiresOriginCheck } from "../../../lib/csrf"; +import { presenceTouchFor } from "../../../lib/presence"; +import { RATE_LIMITS } from "../../../lib/rate-limit"; +import { parseRumSettingsPatch } from "../admin/rum/settings/validation"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const read = (...parts: string[]) => readFileSync(path.join(here, ...parts), "utf8").replace(/\r\n/g, "\n"); +const beacon = read("route.ts"); +const store = read("store.ts"); + +function handlerBody(source: string, name: string): string { + const m = new RegExp(`async function ${name}\\([^)]*\\)[^{]*\\{([\\s\\S]*?)\\n\\}`).exec(source); + assert.ok(m, name); + return m[1]!; +} + +test("the beacon authenticates, rate-limits, then discards everything while off — before reading the body", () => { + const body = handlerBody(beacon, "handlePOST"); + const order = [ + "await requireUser()", + 'rateLimit(gate.user.id, "rum", { anonymous: true })', + "(await getRumSettings(pool)).enabled", + "readBytesLimited(req, RUM_MAX_BEACON_BYTES)", + "validateRumBeacon(parsed, today)", + "insertRumBeacon(pool, beacon.value)", + ].map((needle) => { + const at = body.indexOf(needle); + assert.ok(at >= 0, needle); + return at; + }); + assert.deepEqual([...order].sort((a, b) => a - b), order, "checks run in this order"); + assert.match(body, /status: 400/); +}); + +test("no user id ever reaches a monitoring row", () => { + assert.doesNotMatch(handlerBody(beacon, "handlePOST"), /insertRumBeacon\([^)]*user/); + assert.doesNotMatch(store, /user_id|userId|actor/i); + assert.ok(beacon.includes(`withSystemLog("/api/rum", handlePOST, { anonymousActor: true, unrecordedStatuses: [429] })`)); + assert.doesNotMatch(beacon, /appendAudit/); +}); + +test("the beacon is Origin-checked, silent for presence, and has its own bucket", () => { + assert.equal(requiresOriginCheck("POST", "/api/rum"), true); + assert.equal(presenceTouchFor("/api/rum", "POST"), null); + assert.deepEqual(RATE_LIMITS.rum, { limit: 20, windowMs: 60_000 }); +}); + +test("the admin endpoints are platform-admin only, and only the settings write is audited", () => { + for (const name of ["summary", "series", "errors", "settings"]) { + const source = read("..", "admin", "rum", name, "route.ts"); + assert.match(source, /requirePlatformAdmin\(\)/, name); + assert.ok(source.includes(`withSystemLog("/api/admin/rum/${name}"`), name); + assert.doesNotMatch(source, /appendAudit/, `${name}: reads are not audited, and the write audits in the settings helper`); + } + const helper = readFileSync(path.join(here, "..", "..", "..", "lib", "rum-settings.ts"), "utf8"); + assert.match(helper, /action: "settings\.telemetry_changed"/); +}); + +test("the settings patch takes the switch and the fixed rates only", () => { + assert.deepEqual(parseRumSettingsPatch({ enabled: true }), { ok: true, value: { enabled: true } }); + assert.deepEqual(parseRumSettingsPatch({ sampleRate: 25 }), { ok: true, value: { sampleRate: 25 } }); + const off = parseRumSettingsPatch({ sampleRate: 30 }); + assert.equal(off.ok, false); + assert.equal(!off.ok && off.status, 422); + assert.equal(!off.ok && /100, 50, 25, 10, 1/.test(off.error), true); + assert.equal(parseRumSettingsPatch({ enabled: "yes" }).ok, false); + const nothing = parseRumSettingsPatch({ unrelated: 1 }); + assert.equal(!nothing.ok && nothing.status, 400); +}); + +test("the me resource carries the collector's start decision", () => { + const me = read("..", "me", "route.ts"); + assert.match(me, /getRumSettings\(pool\)/); + assert.match(me, /\n {4}rum,\n/); +}); diff --git a/packages/web/src/app/api/rum/store.dbtest.ts b/packages/web/src/app/api/rum/store.dbtest.ts new file mode 100644 index 0000000..9573567 --- /dev/null +++ b/packages/web/src/app/api/rum/store.dbtest.ts @@ -0,0 +1,220 @@ +// Live-DB integration test (gated) for §14.12 real user monitoring (INNOBOX_SPEC.md): the beacon +// write path, the switch and its audit row, and the admin read path — the 7d raw table, the 30d +// rollup blend, the chart series and the error list. Self-skips when DATABASE_URL is unset. +// +// rum_daily is shared with the worker's suite in the same throwaway database, and the app role +// cannot delete from it, so every rollup assertion is keyed on this suite's own route names. The +// raw table and the error table are cleared first (the app role may delete from both). +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const url = process.env.DATABASE_URL; +const skip = url ? false : "DATABASE_URL not set — live-DB suite self-skips"; + +async function setup() { + const { Pool } = await import("pg"); + const { randomUUID } = await import("node:crypto"); + const pool = new Pool({ connectionString: url }); + await pool.query(`delete from rum_views`); + await pool.query(`delete from rum_client_errors`); + const { rows } = await pool.query<{ today: string; yesterday: string }>( + `select to_char((now() at time zone 'utc')::date, 'YYYY-MM-DD') as today, + to_char((now() at time zone 'utc')::date - 1, 'YYYY-MM-DD') as yesterday`, + ); + return { pool, randomUUID, today: rows[0]!.today, yesterday: rows[0]!.yesterday }; +} + +const view = (route: string, day: string, extra: Record = {}) => ({ + route, + day, + hardLoad: true, + lcpMs: null as number | null, + inpMs: null as number | null, + navMs: null as number | null, + ttfbMs: null as number | null, + cls: null as number | null, + apiMs: [] as number[], + apiCalls: 0, + apiErrors: 0, + clientErrors: 0, + ...extra, +}); + +test("a beacon is stored with no user anywhere, and its errors are deduplicated", { skip }, async () => { + const { pool, today } = await setup(); + const { insertRumBeacon } = await import("./store"); + try { + const { rows: columns } = await pool.query<{ table_name: string; column_name: string }>( + `select table_name, column_name from information_schema.columns + where table_name in ('rum_views', 'rum_daily', 'rum_client_errors')`, + ); + assert.ok(columns.length > 20); + assert.deepEqual( + columns.filter((c) => /user|actor|email|ip/.test(c.column_name)), + [], + "no column could hold a person", + ); + + const result = await insertRumBeacon(pool, { + sessionId: "dbtest-sess-0001", + views: [view("/challenges/[number]", today, { lcpMs: 1200, apiMs: [80, 120], apiCalls: 3, apiErrors: 1, clientErrors: 2 })], + errors: [ + { message: "Cannot read item 41", source: "/_next/a.js:10", frame: "f", route: "/challenges/[number]" }, + { message: "Cannot read item 42", source: "/_next/a.js:10", frame: "g", route: "/challenges/[number]" }, + { message: "Other failure", source: "/_next/b.js:3", frame: "", route: "/" }, + ], + }); + assert.deepEqual(result, { views: 1, errors: 2 }); + const { rows } = await pool.query(`select session_id, route, lcp_ms, api_ms, api_calls, api_errors, client_errors from rum_views`); + assert.deepEqual(rows, [ + { session_id: "dbtest-sess-0001", route: "/challenges/[number]", lcp_ms: 1200, api_ms: [80, 120], api_calls: 3, api_errors: 1, client_errors: 2 }, + ]); + + // The same error again, a different number: one row, its count and last_seen move. + const before = await pool.query<{ count: number; last_seen: Date }>(`select count, last_seen from rum_client_errors where source = '/_next/a.js:10'`); + assert.equal(before.rows[0]!.count, 2); + await insertRumBeacon(pool, { + sessionId: "dbtest-sess-0001", + views: [], + errors: [{ message: "Cannot read item 9000", source: "/_next/a.js:10", frame: "h", route: "/admin" }], + }); + const after = await pool.query<{ count: number; last_seen: Date; route: string; n: number }>( + `select count, last_seen, route, (select count(*)::int from rum_client_errors) as n from rum_client_errors where source = '/_next/a.js:10'`, + ); + assert.equal(after.rows[0]!.count, 3); + assert.equal(after.rows[0]!.route, "/admin", "the most recent route"); + assert.ok(after.rows[0]!.last_seen >= before.rows[0]!.last_seen); + assert.equal(after.rows[0]!.n, 2); + } finally { + await pool.end(); + } +}); + +test("the switch ships off at 100 %; a change writes one audit row, a no-op writes none", { skip }, async () => { + const { pool, randomUUID } = await setup(); + const { getRumSettings, setRumSettings } = await import("../../../lib/rum-settings"); + try { + const stamp = randomUUID().slice(0, 8); + const { rows } = await pool.query<{ id: string }>( + `insert into users (external_id, user_name, display_name) values ($1, $2, $3) returning id`, + [`dbtest-rum-${stamp}`, `dbtest-rum-${stamp}@example.test`, `Dbtest Rum ${stamp}`], + ); + const admin = rows[0]!.id; + const audits = async () => + ( + await pool.query<{ after: { enabled: boolean; sampleRate: number } }>( + `select after from audit_log where action = 'settings.telemetry_changed' and actor_user_id = $1 order by id`, + [admin], + ) + ).rows.map((r) => r.after); + + // Start from the shipped defaults, whatever an earlier run left behind. + await setRumSettings(pool, { enabled: false, sampleRate: 100 }, admin); + const baseline = (await audits()).length; + assert.deepEqual(await getRumSettings(pool), { enabled: false, sampleRate: 100 }); + + assert.deepEqual(await setRumSettings(pool, { enabled: true }, admin), { enabled: true, sampleRate: 100 }); + assert.deepEqual(await setRumSettings(pool, { sampleRate: 10 }, admin), { enabled: true, sampleRate: 10 }); + await setRumSettings(pool, { sampleRate: 10 }, admin); // no change + assert.deepEqual((await audits()).slice(baseline), [ + { enabled: true, sampleRate: 100 }, + { enabled: true, sampleRate: 10 }, + ]); + assert.deepEqual(await getRumSettings(pool), { enabled: true, sampleRate: 10 }); + + // A malformed stored rate reads as 100, never as an error. + await pool.query(`update platform_settings set value = '"lots"'::jsonb where key = 'rum_sample_rate'`); + assert.equal((await getRumSettings(pool)).sampleRate, 100); + await setRumSettings(pool, { enabled: false, sampleRate: 100 }, admin); + } finally { + await pool.end(); + } +}); + +test("7d is a true p75 from raw views; 30d blends daily rows by views and sums sessions", { skip }, async () => { + const { pool, today, yesterday } = await setup(); + const { insertRumBeacon } = await import("./store"); + const { rumSummary } = await import("../admin/rum/store"); + const ROUTE = "/dbtest-web-summary"; + try { + // Today, raw: four hard loads over two sessions; LCP 100..400 → p75 325. One soft view (no LCP). + await insertRumBeacon(pool, { + sessionId: "dbtest-sess-A", + views: [view(ROUTE, today, { lcpMs: 100, apiMs: [100], apiCalls: 1 }), view(ROUTE, today, { lcpMs: 200, apiMs: [300], apiCalls: 2, apiErrors: 1 })], + errors: [], + }); + await insertRumBeacon(pool, { + sessionId: "dbtest-sess-B", + views: [view(ROUTE, today, { lcpMs: 300 }), view(ROUTE, today, { lcpMs: 400 }), view(ROUTE, today, { hardLoad: false, inpMs: 90 })], + errors: [], + }); + + const raw = await rumSummary(pool, "7d"); + assert.equal(raw.source, "raw"); + const r = raw.routes.find((x) => x.route === ROUTE)!; + assert.equal(r.views, 5); + assert.equal(r.sessions, 2, "distinct sessions over the range"); + assert.equal(r.p75.lcp, 325, "percentile_cont skips the soft view's null"); + assert.equal(r.p75.inp, 90); + assert.equal(r.p75.api, 250, "p75 over every timed call: 100, 300 → 250"); + assert.equal(r.apiCalls, 3); + assert.equal(r.apiErrors, 1); + assert.equal(raw.total.views, 5, "the raw table was cleared, so the all-routes row is ours"); + assert.equal(raw.total.p75.lcp, 325); + + // Yesterday, rolled up: 5 views at p75 LCP 1000, 4 sessions. + await pool.query( + `insert into rum_daily (day, route, views, sessions, p75_lcp_ms, api_calls, api_errors, client_errors) + values ($1::date, $2, 5, 4, 1000, 0, 0, 0) + on conflict (day, route) do update set views = 5, sessions = 4, p75_lcp_ms = 1000, api_calls = 0, api_errors = 0, client_errors = 0, + p75_inp_ms = null, p75_cls = null, p75_ttfb_ms = null, p75_nav_ms = null, p75_api_ms = null`, + [yesterday, ROUTE], + ); + const blended = await rumSummary(pool, "30d"); + assert.equal(blended.source, "rollup"); + const b = blended.routes.find((x) => x.route === ROUTE)!; + assert.equal(b.views, 10); + assert.equal(b.sessions, 6, "daily distinct sessions add up: 2 today + 4 yesterday"); + assert.equal(b.p75.lcp, 663, "views-weighted: (325×5 + 1000×5) / 10 = 662.5"); + assert.equal(b.name, "Other", "a template outside the registry reads as Other"); + } finally { + await pool.end(); + } +}); + +test("the series runs to today with today's point live; errors filter by range and sort", { skip }, async () => { + const { pool, today } = await setup(); + const { insertRumBeacon } = await import("./store"); + const { rumSeries, rumErrors } = await import("../admin/rum/store"); + try { + await insertRumBeacon(pool, { sessionId: "dbtest-sess-C", views: [view("/", today, { lcpMs: 900 }), view("/", today, { lcpMs: 1100 })], errors: [] }); + const all = await rumSeries(pool, "all"); + const last = all.points.at(-1)!; + assert.equal(last.day, today); + assert.equal(last.views, 2, "today comes from the raw views, not a stale rollup row"); + assert.equal(last.p75Lcp, 1050); + assert.equal(all.enoughHistory, all.points.length >= 7); + for (let i = 1; i < all.points.length; i++) { + assert.ok(all.points[i]!.day > all.points[i - 1]!.day, "one point per day, ascending"); + } + const week = await rumSeries(pool, "7d"); + assert.ok(week.points.length <= 7); + assert.equal(week.enoughHistory, all.enoughHistory, "history is judged on the whole series"); + + await pool.query(`delete from rum_client_errors`); + await pool.query( + `insert into rum_client_errors (fingerprint, message, source, frame, route, first_seen, last_seen, count) values + ('fp-often', 'often', '/a.js:1', '', '/', now() - interval '20 days', now() - interval '2 days', 50), + ('fp-recent', 'recent', '/b.js:1', '', '/admin', now() - interval '1 hour', now() - interval '1 hour', 1), + ('fp-old', 'old', '/c.js:1', '', '/', now() - interval '60 days', now() - interval '40 days', 9)`, + ); + const byLastSeen = await rumErrors(pool, "30d", "last_seen"); + assert.deepEqual(byLastSeen.errors.map((e) => e.fingerprint), ["fp-recent", "fp-often"]); + assert.equal(byLastSeen.total, 2); + assert.equal(byLastSeen.errors[0]!.name, "Administration"); + const byCount = await rumErrors(pool, "90d", "count"); + assert.deepEqual(byCount.errors.map((e) => e.fingerprint), ["fp-often", "fp-old", "fp-recent"]); + } finally { + await pool.end(); + } +}); diff --git a/packages/web/src/app/api/rum/store.ts b/packages/web/src/app/api/rum/store.ts new file mode 100644 index 0000000..71df617 --- /dev/null +++ b/packages/web/src/app/api/rum/store.ts @@ -0,0 +1,80 @@ +// Write path for the §14.12 beacon (INNOBOX_SPEC.md): a validated beacon becomes rum_views rows +// and deduplicated rum_client_errors rows. Nothing here takes a person: the signature has no user +// parameter and neither table has a user column, so "the insert does not see the session" is a +// property of the code's shape, not a promise (§14.12 decision 2). +// Relative imports only (no `@/`) so the gated dbtests can load it. +import { createHash } from "node:crypto"; +import type { Pool } from "pg"; +import { errorFingerprintInput, type RumBeacon, type RumErrorInput } from "@innobox/shared/rum"; + +/** SHA-256 of the normalised message plus the source file and line, hex. */ +export function errorFingerprint(message: string, source: string): string { + return createHash("sha256").update(errorFingerprintInput(message, source)).digest("hex"); +} + +interface ErrorBatchRow extends RumErrorInput { + fingerprint: string; + count: number; +} + +/** Collapses a batch's errors by fingerprint, so one upsert never touches a row twice. The most + * recent occurrence's text and route win. */ +export function collapseErrors(errors: readonly RumErrorInput[]): ErrorBatchRow[] { + const byPrint = new Map(); + for (const e of errors) { + const fingerprint = errorFingerprint(e.message, e.source); + const prev = byPrint.get(fingerprint); + byPrint.set(fingerprint, { ...e, fingerprint, count: (prev?.count ?? 0) + 1 }); + } + return [...byPrint.values()]; +} + +export async function insertRumBeacon(pool: Pool, beacon: RumBeacon): Promise<{ views: number; errors: number }> { + let views = 0; + if (beacon.views.length > 0) { + const rows = beacon.views.map((v) => ({ + day: v.day, + route: v.route, + hard_load: v.hardLoad, + lcp_ms: v.lcpMs, + inp_ms: v.inpMs, + nav_ms: v.navMs, + ttfb_ms: v.ttfbMs, + cls: v.cls, + api_ms: v.apiMs, + api_calls: v.apiCalls, + api_errors: v.apiErrors, + client_errors: v.clientErrors, + })); + const res = await pool.query( + `insert into rum_views (day, session_id, route, hard_load, lcp_ms, inp_ms, nav_ms, ttfb_ms, cls, + api_ms, api_calls, api_errors, client_errors) + select x.day, $2, x.route, x.hard_load, x.lcp_ms, x.inp_ms, x.nav_ms, x.ttfb_ms, x.cls, + array(select jsonb_array_elements_text(x.api_ms)::int), + x.api_calls, x.api_errors, x.client_errors + from jsonb_to_recordset($1::jsonb) as x( + day date, route text, hard_load boolean, lcp_ms int, inp_ms int, nav_ms int, ttfb_ms int, + cls numeric, api_ms jsonb, api_calls int, api_errors int, client_errors int)`, + [JSON.stringify(rows), beacon.sessionId], + ); + views = res.rowCount ?? 0; + } + + let errors = 0; + const batch = collapseErrors(beacon.errors); + if (batch.length > 0) { + const res = await pool.query( + `insert into rum_client_errors (fingerprint, message, source, frame, route, count) + select x.fingerprint, x.message, x.source, x.frame, x.route, x.count + from jsonb_to_recordset($1::jsonb) as x(fingerprint text, message text, source text, frame text, route text, count int) + on conflict (fingerprint) do update + set count = rum_client_errors.count + excluded.count, + last_seen = now(), + route = excluded.route, + frame = excluded.frame`, + [JSON.stringify(batch)], + ); + errors = res.rowCount ?? 0; + } + return { views, errors }; +} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 2369047..743ffdb 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1021,6 +1021,68 @@ select.field:disabled { cursor: not-allowed; opacity: 0.6; } .presence-where { max-width: 100%; white-space: normal; } } +/* Real user monitoring (/admin/monitoring, INNOBOX_SPEC.md §14.12). The routes table is the + only wide element: it scrolls sideways inside its card with the route name sticky, so the + page itself never scrolls horizontally. */ +.rum-section { margin-bottom: 18px; } +.rum-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 22px; } +.rum-control-label { font-weight: 600; font-size: 13.5px; } +.rum-sample { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; } +.rum-sample .field { width: auto; padding-top: 6px; padding-bottom: 6px; } +.rum-schedule { font-size: 12.5px; } +.rum-refresh { display: inline-flex; align-items: center; gap: 10px; font-size: 12.5px; } +.rum-section-head { + display: flex; align-items: center; justify-content: space-between; + gap: 8px 14px; flex-wrap: wrap; margin-bottom: 12px; +} +.rum-caption { + font-family: var(--font-body); font-size: 12.5px; letter-spacing: 0; + text-transform: none; color: var(--muted); font-weight: 400; +} +.rum-table-wrap { overflow-x: auto; margin: 0 -4px; } +.rum-table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; } +.rum-table th, .rum-table td { + padding: 8px 10px; border-bottom: 1px solid var(--line); + text-align: right; white-space: nowrap; +} +.rum-table thead th { + font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--faint); font-weight: 500; +} +.rum-table th:first-child, .rum-table td:first-child { + text-align: left; position: sticky; left: 0; background: var(--surface); z-index: 1; +} +.rum-table tbody th { font-weight: 500; } +.rum-table .rum-total th, .rum-table .rum-total td { background: var(--surface-2); font-weight: 600; } +.rum-table .pill { font-size: 12px; text-transform: none; letter-spacing: 0; } +.rum-table .rum-empty { text-align: left; } +.rum-sort { + background: none; border: 0; padding: 0; font: inherit; color: inherit; + letter-spacing: inherit; text-transform: inherit; cursor: pointer; +} +.rum-sort:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; } +.rum-errors { list-style: none; margin: 0; padding: 0; } +.rum-errors > li { border-bottom: 1px solid var(--line); } +.rum-errors > li:last-child { border-bottom: 0; } +.rum-error > summary { + display: flex; align-items: center; gap: 10px; padding: 10px 0; + cursor: pointer; list-style: none; +} +.rum-error > summary::-webkit-details-marker { display: none; } +.rum-error-msg { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: var(--font-mono); font-size: 12.5px; +} +.rum-error-when { font-size: 12.5px; white-space: nowrap; } +.rum-error-body { padding: 0 0 12px; font-size: 13px; } +.rum-error-full { font-family: var(--font-mono); font-size: 12.5px; overflow-wrap: anywhere; margin: 0 0 8px; } +.rum-error-body dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; } +.rum-error-body dt { color: var(--faint); } +.rum-error-body dd { margin: 0; overflow-wrap: anywhere; } +@media (max-width: 720px) { + .rum-controls { align-items: flex-start; flex-direction: column; } +} + /* Expand all / Collapse all control above the cards. */ .admin-bulk { display: flex; gap: 8px; align-items: center; margin-bottom: 22px; } diff --git a/packages/web/src/app/whats-new/changelog.ts b/packages/web/src/app/whats-new/changelog.ts index 4a7ae68..d788324 100644 --- a/packages/web/src/app/whats-new/changelog.ts +++ b/packages/web/src/app/whats-new/changelog.ts @@ -10,6 +10,12 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: "1.19.0", + date: "2026-10-21", + summary: + "New for platform admins: Real user monitoring, under Administration. It shows how InnoBox performs in people's browsers: page views, load and responsiveness, request times and failures, and script errors, page by page. Collection is off until you switch it on. Beacons carry no names and never identify an item.", + }, { version: "1.18.0", date: "2026-10-21", diff --git a/packages/web/src/components/AppShell.tsx b/packages/web/src/components/AppShell.tsx index dbbd88e..5e023e8 100644 --- a/packages/web/src/components/AppShell.tsx +++ b/packages/web/src/components/AppShell.tsx @@ -28,6 +28,7 @@ import { SURVEY_OPEN_EVENT, SURVEY_START_EVENT, announceSurveyChanged, claimFeat import { WHATS_NEW_SEEN_EVENT, WhatsNewNotice } from "./WhatsNewNotice"; import { whatsNewAction } from "../lib/whats-new"; import { AchievementToast } from "./AchievementToast"; +import { RumCollector, type RumConfig } from "./RumCollector"; import type { Achievement } from "@innobox/shared/gamification"; // Shape of the §16 `me` resource (packages/web/src/app/api/me/route.ts) — only the fields @@ -51,6 +52,8 @@ interface MeResponse { /** §25.9: true while agent access is on, or while the user still holds a live connection — * shows the account menu's AI agents item. */ agentsMenu?: boolean; + /** §14.12: whether this tab starts the monitoring collector, and at what sample rate. */ + rum?: RumConfig | null; } /** The survey card the shell has open (§13.10). */ @@ -615,6 +618,8 @@ export function AppShell({ children }: { children: ReactNode }) { {authed && setWhatsNewClosed(true)} suppressed={noticeSuppressed} />} {/* §23.7 achievement toast — one per page load, then the marker advances. */} {authed && } + {/* §14.12 real user monitoring — signed-in pages only; inert unless collection is on. */} + {authed && } ); } diff --git a/packages/web/src/components/RumCollector.tsx b/packages/web/src/components/RumCollector.tsx new file mode 100644 index 0000000..bd30fb1 --- /dev/null +++ b/packages/web/src/components/RumCollector.tsx @@ -0,0 +1,242 @@ +"use client"; +// The §14.12 real-user-monitoring collector (INNOBOX_SPEC.md). Mounted by the authenticated app +// shell and nowhere else — the signed-out Home, the public form and the agent routes never load +// it. It renders nothing and does nothing unless collection is on, this tab's session is sampled +// and the browser has the APIs it needs; it never throws into the page. +// +// What it wires up (the engine, lib/rum-collector.ts, holds the state and builds the batches): +// • Views — a hard load, then every route change (usePathname), each named by its registry +// template before anything is recorded: no concrete path, number or title leaves the browser. +// • Hard-load vitals — LCP, CLS and TTFB through the `web-vitals` library. +// • INP — per view, from `event` timing entries grouped by interaction id. +// • Navigation time — hard load: navigation start → DOMContentLoaded end; soft: the click, +// history push or back/forward that started it → the first paint of the new route. +// • API — a wrapper around window.fetch timing same-origin /api calls, pollers excluded. +// • Errors — `error` and `unhandledrejection`, described by the shared rules (extensions, +// foreign scripts and the ResizeObserver notice dropped; never DOM or form content). +// • Flushes — on the backing-off schedule, and immediately when the page is hidden. +import { useEffect, useRef } from "react"; +import { usePathname } from "next/navigation"; +import { utcCalendarDay } from "@innobox/shared/calendar-day"; +import { describeClientError, describeRejection, rumRouteFor } from "@innobox/shared/rum"; +import { + FlushClock, + RumBuffer, + apiOutcomeOfStatus, + apiOutcomeOfThrow, + isTrackedApiCall, + loadOrStartSession, +} from "@/lib/rum-collector"; + +export interface RumConfig { + enabled: boolean; + sampleRate: number; +} + +const BEACON_URL = "/api/rum"; +/** A click or history push older than this did not start the route change being measured. */ +const NAV_START_MAX_AGE_MS = 10_000; + +function quietly(fn: () => void): void { + try { + fn(); + } catch (err) { + console.debug("rum collector", err); + } +} + +function browserSupported(): boolean { + return ( + typeof window !== "undefined" && + typeof PerformanceObserver === "function" && + typeof navigator.sendBeacon === "function" && + typeof crypto?.randomUUID === "function" + ); +} + +function sessionStore(): Storage | null { + try { + return window.sessionStorage; + } catch { + return null; + } +} + +export function RumCollector({ config }: { config: RumConfig | null | undefined }) { + const pathname = usePathname(); + const bufferRef = useRef(null); + const navStartRef = useRef(null); + const firstViewRef = useRef(true); + + const active = Boolean(config?.enabled); + const rate = config?.sampleRate ?? 100; + + // Start the collector once per page load (and stop it if the shell unmounts). + useEffect(() => { + if (!active || !browserSupported()) return; + let buffer: RumBuffer; + try { + const session = loadOrStartSession(sessionStore(), rate, Math.random, () => crypto.randomUUID()); + if (!session.sampled) return; + buffer = new RumBuffer(session.sid); + } catch { + return; + } + bufferRef.current = buffer; + const origin = window.location.origin; + const cleanups: (() => void)[] = []; + + // Hard-load vitals. reportAllChanges keeps the values current, so whatever the hide sends is + // the latest; the hard-load view waits for that hide (see the engine). + void import("web-vitals") + .then(({ onLCP, onCLS, onTTFB }) => { + if (bufferRef.current !== buffer) return; + onLCP((m) => (buffer.hardMetrics.lcp = m.value), { reportAllChanges: true }); + onCLS((m) => (buffer.hardMetrics.cls = m.value), { reportAllChanges: true }); + onTTFB((m) => (buffer.hardMetrics.ttfb = m.value)); + }) + .catch(() => {}); + + // INP per view: the longest event of each interaction, on the view current when it happened. + quietly(() => { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries() as (PerformanceEntry & { interactionId?: number })[]) { + if (entry.interactionId) buffer.recordInteraction(entry.interactionId, entry.duration); + } + }); + observer.observe({ type: "event", buffered: true, durationThreshold: 40 } as PerformanceObserverInit); + cleanups.push(() => observer.disconnect()); + }); + + // What started a soft navigation: an internal link click, a history push, or back/forward. + const markNavStart = () => (navStartRef.current = performance.now()); + const onClick = (e: MouseEvent) => + quietly(() => { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const link = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null; + if (link && link.origin === origin && link.target !== "_blank") markNavStart(); + }); + document.addEventListener("click", onClick, true); + window.addEventListener("popstate", markNavStart); + const originalPush = history.pushState; + history.pushState = function (...args: Parameters) { + const started = navStartRef.current; + if (started === null || performance.now() - started > NAV_START_MAX_AGE_MS) markNavStart(); + return originalPush.apply(this, args); + }; + cleanups.push(() => { + document.removeEventListener("click", onClick, true); + window.removeEventListener("popstate", markNavStart); + if (history.pushState !== originalPush) history.pushState = originalPush; + }); + + // API calls: same-origin /api work of the page, timed through a fetch wrapper. + const originalFetch = window.fetch; + const wrappedFetch: typeof fetch = async (input, init) => { + let tracked = false; + try { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + tracked = isTrackedApiCall(url, method, origin); + } catch { + tracked = false; + } + if (!tracked) return originalFetch(input, init); + const view = buffer.currentView; + const started = performance.now(); + try { + const res = await originalFetch(input, init); + quietly(() => buffer.recordApi(view, performance.now() - started, apiOutcomeOfStatus(res.status))); + return res; + } catch (err) { + quietly(() => buffer.recordApi(view, performance.now() - started, apiOutcomeOfThrow(err))); + throw err; + } + }; + window.fetch = wrappedFetch; + cleanups.push(() => { + if (window.fetch === wrappedFetch) window.fetch = originalFetch; + }); + + // Errors. + const onError = (e: ErrorEvent) => + quietly(() => { + const desc = describeClientError({ message: e.message, filename: e.filename, lineno: e.lineno, stack: e.error?.stack }, origin); + if (desc) buffer.recordError(desc); + }); + const onRejection = (e: PromiseRejectionEvent) => + quietly(() => { + const desc = describeRejection(e.reason, origin); + if (desc) buffer.recordError(desc); + }); + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); + cleanups.push(() => { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); + }); + + // Flushing. + const clock = new FlushClock(); + let timer: ReturnType | undefined; + const send = (final: boolean): boolean => { + const batch = buffer.takeBatch(final); + if (!batch) return false; + let ok = false; + try { + ok = navigator.sendBeacon(BEACON_URL, batch.body); + } catch { + ok = false; + } + if (!ok && !final) buffer.requeue(batch); + return ok; + }; + const tick = () => { + quietly(() => clock.after(send(false))); + timer = setTimeout(tick, clock.delayMs); + }; + timer = setTimeout(tick, clock.delayMs); + const onHide = () => quietly(() => void send(true)); + const onVisibility = () => { + if (document.visibilityState === "hidden") onHide(); + }; + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("pagehide", onHide); + cleanups.push(() => { + clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("pagehide", onHide); + }); + + return () => { + for (const fn of cleanups) quietly(fn); + if (bufferRef.current === buffer) bufferRef.current = null; + }; + }, [active, rate]); + + // Views: the first one is the hard load, every later pathname a soft navigation. + useEffect(() => { + const buffer = bufferRef.current; + if (!buffer) return; + quietly(() => { + const route = rumRouteFor(pathname ?? "/"); + const day = utcCalendarDay(Date.now()); + if (firstViewRef.current) { + firstViewRef.current = false; + const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; + const navMs = nav && nav.domContentLoadedEventEnd > 0 ? nav.domContentLoadedEventEnd : null; + buffer.startView(route, day, true, navMs); + return; + } + const started = navStartRef.current; + navStartRef.current = null; + const view = buffer.startView(route, day, false); + if (started === null || performance.now() - started > NAV_START_MAX_AGE_MS) return; + // The new route has committed; the frame after the next paint is when the person saw it. + requestAnimationFrame(() => setTimeout(() => (view.navMs = Math.round(performance.now() - started)), 0)); + }); + // `active` re-runs this once the collector has started, so the first view is recorded. + }, [pathname, active]); + + return null; +} diff --git a/packages/web/src/lib/charts/rum-chart.test.ts b/packages/web/src/lib/charts/rum-chart.test.ts new file mode 100644 index 0000000..60c2dc2 --- /dev/null +++ b/packages/web/src/lib/charts/rum-chart.test.ts @@ -0,0 +1,98 @@ +// §14.12 monitoring chart and table formatting (INNOBOX_SPEC.md): the option builder (page-view +// bars plus two lines on a second axis, a null breaks a line, no data-zoom, the aria sentence, the +// escaped tooltip), the millisecond and layout-shift formats, the verdict pills and the table sort. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { RumRouteSummary } from "@innobox/shared/rum"; +import { buildRumOption, rumAriaDescription, rumTooltip } from "./rum-chart"; +import type { ChartEnv } from "./chart-env"; +import { formatRumCls, formatRumMs, formatRumPercent, sortRoutes, verdictLabel, verdictPillClass } from "../rum-format"; + +const env: ChartEnv = { + tokens: { accent: "#0a0", accentSoft: "#efe", anchor: "#003", muted: "#666", faint: "#999", line: "#ddd", ink: "#111", surface: "#fff", fontBody: "sans" }, + coarsePointer: false, + width: 800, +}; + +const points = [ + { day: "2026-10-19", views: 40, p75Lcp: 2100, p75Inp: 90 }, + { day: "2026-10-20", views: 0, p75Lcp: null, p75Inp: null }, + { day: "2026-10-21", views: 12, p75Lcp: 1500, p75Inp: 70 }, +]; + +test("bars for views on the left axis, LCP and INP lines on the right", () => { + const option = buildRumOption({ points, style: "eu", env }) as Record; + const series = option.series as { name: string; type: string; yAxisIndex: number; connectNulls?: boolean; data: [number, number | null][]; lineStyle?: { type: string } }[]; + assert.deepEqual( + series.map((s) => [s.name, s.type, s.yAxisIndex]), + [ + ["Page views", "bar", 0], + ["p75 LCP", "line", 1], + ["p75 INP", "line", 1], + ], + ); + assert.equal(series[1]!.connectNulls, false); + assert.equal(series[1]!.data[1]![1], null, "a quiet day breaks the line"); + assert.equal(series[0]!.data[1]![1], 0, "and is a zero bar"); + assert.notEqual(series[1]!.lineStyle!.type, series[2]!.lineStyle!.type, "told apart by dash, not only colour"); + assert.equal(option.dataZoom, undefined, "the range buttons are the window"); + assert.equal((option.yAxis as unknown[]).length, 2); +}); + +test("the aria sentence states the span, the total and the latest day", () => { + assert.equal( + rumAriaDescription(points, "eu"), + "Page views as bars and p75 LCP and p75 INP as lines per UTC day, 3 days from 19/10/2026 to 21/10/2026, 52 views in all. Latest day: 12 views, p75 LCP 1.50 s, p75 INP 70 ms.", + ); + assert.match(rumAriaDescription([], "us"), /No days recorded yet/); +}); + +test("the tooltip names the UTC day and escapes everything it interpolates", () => { + const ms = Date.UTC(2026, 9, 21); + const html = rumTooltip( + [ + { seriesName: "Page views", marker: "", axisValue: ms, value: [ms, 12] }, + { seriesName: "p75 LCP", marker: "", axisValue: ms, value: [ms, 2390] }, + { seriesName: "x", marker: "", axisValue: ms, value: [ms, null] }, + ], + "us", + ); + assert.match(html, /10\/21\/2026 \(UTC\)/); + assert.match(html, /Page views 12<\/strong>/); + assert.match(html, /p75 LCP 2\.39 s<\/strong>/); + assert.match(html, /<b>x<\/b> —<\/strong>/); +}); + +test("formats: milliseconds, seconds, layout shift and percent", () => { + assert.equal(formatRumMs(null), "—"); + assert.equal(formatRumMs(85), "85 ms"); + assert.equal(formatRumMs(999.6), "1000 ms"); + assert.equal(formatRumMs(2390), "2.39 s"); + assert.equal(formatRumCls(0.11), "0.110"); + assert.equal(formatRumPercent(0.1), "0.1 %"); + assert.equal(formatRumPercent(null), "—"); + assert.equal(verdictPillClass("good"), "pill-ok"); + assert.equal(verdictPillClass("needs-improvement"), "pill-warn"); + assert.equal(verdictPillClass("poor"), "pill-danger"); + assert.equal(verdictLabel("needs-improvement"), "needs improvement"); +}); + +const route = (name: string, views: number, lcp: number | null, apiErrors: number): RumRouteSummary => ({ + route: `/${name}`, + name, + views, + sessions: views, + p75: { lcp, inp: null, cls: null, ttfb: null, nav: null, api: null }, + apiCalls: 10, + apiErrors, + clientErrors: 0, +}); + +test("the table sorts by any column, and a missing value sinks either way", () => { + const rows = [route("Search", 5, 3000, 3), route("Home", 50, null, 0), route("Profile", 20, 1200, 1)]; + assert.deepEqual(sortRoutes(rows, "views", true).map((r) => r.name), ["Home", "Profile", "Search"]); + assert.deepEqual(sortRoutes(rows, "lcp", true).map((r) => r.name), ["Search", "Profile", "Home"]); + assert.deepEqual(sortRoutes(rows, "lcp", false).map((r) => r.name), ["Profile", "Search", "Home"]); + assert.deepEqual(sortRoutes(rows, "name", false).map((r) => r.name), ["Home", "Profile", "Search"]); + assert.deepEqual(sortRoutes(rows, "apiErrors", true).map((r) => r.name), ["Search", "Profile", "Home"]); +}); diff --git a/packages/web/src/lib/charts/rum-chart.ts b/packages/web/src/lib/charts/rum-chart.ts new file mode 100644 index 0000000..570d5a1 --- /dev/null +++ b/packages/web/src/lib/charts/rum-chart.ts @@ -0,0 +1,132 @@ +// The real-user-monitoring chart as a pure ECharts option (INNOBOX_SPEC.md §14.12): page views as +// bars on the left axis and p75 LCP and p75 INP as lines on a right-hand milliseconds axis, per UTC +// day. No data-zoom — the page's range buttons choose the window. Pure: data + resolved tokens in, +// option out; the shared wrapper (components/EChart.tsx) owns the DOM, the theme and `useUTC`. +import type { ComposeOption } from "echarts/core"; +import type { BarSeriesOption, LineSeriesOption } from "echarts/charts"; +import type { AriaComponentOption, GridComponentOption, LegendComponentOption, TooltipComponentOption } from "echarts/components"; +import { calendarDayMs, formatCalendarDay, formatCalendarDayShort, utcCalendarDay } from "@innobox/shared/calendar-day"; +import type { RumSeriesPoint } from "@innobox/shared/rum"; +import { formatRumMs } from "../rum-format"; +import { escapeHtml, type ChartEnv } from "./chart-env"; +import { isolatedPoints } from "./presence-chart"; + +export type RumChartOption = ComposeOption< + BarSeriesOption | LineSeriesOption | GridComponentOption | LegendComponentOption | TooltipComponentOption | AriaComponentOption +>; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The three series. The lines differ by dash as well as colour, so neither relies on colour. */ +export const RUM_SERIES = [ + { key: "views", name: "Page views", kind: "bar" }, + { key: "p75Lcp", name: "p75 LCP", kind: "line", color: "accent", dash: "solid" }, + { key: "p75Inp", name: "p75 INP", kind: "line", color: "anchor", dash: "dashed" }, +] as const; + +interface AxisTooltipItem { + seriesName?: string; + marker?: unknown; + axisValue?: unknown; + value?: unknown; +} + +export function rumTooltip(items: readonly AxisTooltipItem[], style: "eu" | "us"): string { + const first = items[0]; + if (!first) return ""; + const day = utcCalendarDay(Number(first.axisValue)); + const rows = items.map((item) => { + const value = Array.isArray(item.value) ? item.value[1] : null; + const shown = typeof value !== "number" ? "—" : item.seriesName === "Page views" ? String(value) : formatRumMs(value); + const marker = typeof item.marker === "string" ? item.marker : ""; + return `${marker}${escapeHtml(String(item.seriesName ?? ""))} ${escapeHtml(shown)}`; + }); + return [`${escapeHtml(formatCalendarDay(day, style))} (UTC)`, ...rows].join("
"); +} + +/** The screen-reader summary: what the chart shows, its span and where it ends. */ +export function rumAriaDescription(points: readonly RumSeriesPoint[], style: "eu" | "us"): string { + const first = points[0]; + const last = points.at(-1); + if (!first || !last) return "Page views and p75 LCP and INP per UTC day. No days recorded yet."; + const views = points.reduce((n, p) => n + p.views, 0); + return ( + `Page views as bars and p75 LCP and p75 INP as lines per UTC day, ${points.length} days from ` + + `${formatCalendarDay(first.day, style)} to ${formatCalendarDay(last.day, style)}, ${views} views in all. ` + + `Latest day: ${last.views} views, p75 LCP ${formatRumMs(last.p75Lcp)}, p75 INP ${formatRumMs(last.p75Inp)}.` + ); +} + +export function buildRumOption({ points, style, env }: { points: readonly RumSeriesPoint[]; style: "eu" | "us"; env: ChartEnv }): RumChartOption { + const { tokens } = env; + const dayLabel = (ms: number) => formatCalendarDayShort(utcCalendarDay(ms), style); + const first = points[0]; + const last = points.at(-1); + const axisLabel = { color: tokens.faint, fontSize: 11 }; + + return { + aria: { label: { description: rumAriaDescription(points, style) } }, + grid: { left: 8, right: 8, top: 40, bottom: 8, containLabel: true }, + legend: { top: 0, left: 0, itemWidth: 22, textStyle: { color: tokens.muted, fontSize: 12 }, data: RUM_SERIES.map((s) => s.name) }, + tooltip: { + trigger: "axis", + backgroundColor: tokens.surface, + borderColor: tokens.line, + textStyle: { color: tokens.ink, fontSize: 12.5 }, + axisPointer: { type: "shadow", shadowStyle: { color: tokens.accentSoft, opacity: 0.35 } }, + formatter: (params) => rumTooltip((Array.isArray(params) ? params : [params]) as AxisTooltipItem[], style), + }, + xAxis: { + type: "time", + // Half a day of margin on each side, so the first and last bars are not cut in half. + min: first ? calendarDayMs(first.day) - DAY_MS / 2 : undefined, + max: last ? calendarDayMs(last.day) + DAY_MS / 2 : undefined, + minInterval: DAY_MS, + axisLine: { lineStyle: { color: tokens.line } }, + axisTick: { show: false }, + axisLabel: { ...axisLabel, hideOverlap: true, formatter: (value: number) => dayLabel(value) }, + splitLine: { show: false }, + }, + yAxis: [ + { type: "value", name: "views", minInterval: 1, nameTextStyle: axisLabel, axisLabel, splitLine: { lineStyle: { color: tokens.line, type: [3, 4] } } }, + { + type: "value", + name: "ms", + position: "right", + nameTextStyle: axisLabel, + axisLabel: { ...axisLabel, formatter: (value: number) => (value >= 1000 ? `${value / 1000} s` : `${value} ms`) }, + splitLine: { show: false }, + }, + ], + series: RUM_SERIES.map((s) => { + if (s.kind === "bar") { + return { + name: s.name, + type: "bar" as const, + yAxisIndex: 0, + barMaxWidth: 14, + itemStyle: { color: tokens.line, borderRadius: [2, 2, 0, 0] }, + emphasis: { itemStyle: { color: tokens.faint } }, + data: points.map((p) => [calendarDayMs(p.day), p.views]), + }; + } + const values = points.map((p) => p[s.key]); + const lone = isolatedPoints(values); + return { + name: s.name, + type: "line" as const, + yAxisIndex: 1, + // A day with no measure is a break in the line, never a zero and never bridged. + connectNulls: false, + showSymbol: lone.size > 0, + symbol: "circle", + symbolSize: lone.size > 0 ? (_: unknown, params: { dataIndex: number }) => (lone.has(params.dataIndex) ? 6 : 0) : 6, + smooth: false, + lineStyle: { color: tokens[s.color], width: 2, type: s.dash }, + itemStyle: { color: tokens[s.color] }, + emphasis: { focus: "series" as const }, + data: points.map((p, i) => [calendarDayMs(p.day), values[i] ?? null]), + }; + }), + }; +} diff --git a/packages/web/src/lib/rate-limit.ts b/packages/web/src/lib/rate-limit.ts index a2a2500..207d6b6 100644 --- a/packages/web/src/lib/rate-limit.ts +++ b/packages/web/src/lib/rate-limit.ts @@ -15,6 +15,7 @@ export type RateLimitBucket = | "survey" | "bug_report" | "bug_report_day" + | "rum" | "csp-report" | "public-read" | "public-submit" @@ -36,6 +37,9 @@ export const RATE_LIMITS: Record } { + const data = new Map(); + return { data, getItem: (k) => data.get(k) ?? null, setItem: (k, v) => void data.set(k, v) }; +} + +test("the session is drawn once per tab and honoured on reload, whatever the rate does later", () => { + const store = memoryStore(); + let draws = 0; + const random = () => { + draws++; + return 0.5; + }; + const first = loadOrStartSession(store, 100, random, () => "11111111-aaaa"); + assert.deepEqual(first, { sid: "11111111-aaaa", sampled: true }); + assert.ok(store.data.has(RUM_SESSION_KEY)); + // A reload at a 1 % rate keeps the decision and the id; no second draw. + const reload = loadOrStartSession(store, 1, random, () => "22222222-bbbb"); + assert.deepEqual(reload, first); + assert.equal(draws, 1); + // An unsampled session stays unsampled. + const other = memoryStore(); + assert.equal(loadOrStartSession(other, 10, () => 0.5, () => "33333333-cccc").sampled, false); + assert.equal(loadOrStartSession(other, 100, () => 0, () => "x").sampled, false); + // No storage: a page-only session, still valid. + assert.equal(loadOrStartSession(null, 100, () => 0, () => "44444444-dddd").sid, "44444444-dddd"); + // A corrupt entry starts afresh. + const corrupt = memoryStore(); + corrupt.data.set(RUM_SESSION_KEY, "{not json"); + assert.equal(loadOrStartSession(corrupt, 100, () => 0, () => "55555555-eeee").sid, "55555555-eeee"); +}); + +test("only the page's own /api work is tracked — never pollers, the beacon or auth", () => { + assert.equal(isTrackedApiCall("/api/challenges?page=2", "GET", ORIGIN), true); + assert.equal(isTrackedApiCall(`${ORIGIN}/api/comments`, "POST", ORIGIN), true); + assert.equal(isTrackedApiCall("https://elsewhere.test/api/x", "GET", ORIGIN), false); + assert.equal(isTrackedApiCall("/_next/data/x.json", "GET", ORIGIN), false); + assert.equal(isTrackedApiCall("/api/rum", "POST", ORIGIN), false); + assert.equal(isTrackedApiCall("/api/auth/session", "GET", ORIGIN), false); + assert.equal(isTrackedApiCall("/api/me/pulse", "GET", ORIGIN), false, "the shell's 30-second pulse"); + assert.equal(isTrackedApiCall("/api/notifications", "GET", ORIGIN), true, "opening the bell's list is a person"); + assert.equal(isTrackedApiCall("/api/notifications", "PATCH", ORIGIN), true, "marking read is a person"); + assert.equal(isTrackedApiCall("/api/admin/triage/attention", "GET", ORIGIN), false); + assert.equal(isTrackedApiCall("/api/challenges/new-count", "GET", ORIGIN), false); +}); + +test("how a call ends: 5xx, network failure and timeouts are errors; a cancel is nothing", () => { + assert.equal(apiOutcomeOfStatus(200), "ok"); + assert.equal(apiOutcomeOfStatus(422), "ok", "a 4xx is the app saying no"); + assert.equal(apiOutcomeOfStatus(503), "error"); + assert.equal(apiOutcomeOfThrow(new TypeError("Failed to fetch")), "error"); + assert.equal(apiOutcomeOfThrow(new DOMException("timed out", "TimeoutError")), "error"); + assert.equal(apiOutcomeOfThrow(new DOMException("aborted", "AbortError")), "cancelled"); +}); + +test("the hard-load view waits for the hide; soft views go on the next tick", () => { + const buffer = new RumBuffer("sid-12345678"); + buffer.startView("/", DAY, true, 640); + buffer.hardMetrics.lcp = 1800; + buffer.hardMetrics.ttfb = 120; + buffer.startView("/challenges", DAY, false); + buffer.startView("/challenges/[number]", DAY, false); + + const tick = buffer.takeBatch(false)!; + assert.deepEqual( + tick.views.map((v) => v.route), + ["/challenges"], + "the hard load is held and the current view is still open", + ); + assert.equal(buffer.takeBatch(false), null, "nothing left for a regular tick"); + + buffer.hardMetrics.cls = 0.04; // CLS settles later — the hide carries the final value + const hide = buffer.takeBatch(true)!; + const wire = JSON.parse(hide.body) as { sid: string; views: { route: string; hard: boolean; lcp: number | null; cls: number | null; ttfb: number | null; nav: number | null }[] }; + assert.equal(wire.sid, "sid-12345678"); + assert.deepEqual( + wire.views.map((v) => [v.route, v.hard, v.lcp, v.cls, v.ttfb, v.nav]), + [ + ["/", true, 1800, 0.04, 120, 640], + ["/challenges/[number]", false, null, null, null, null], + ], + ); + // The open view went out on the hide; ending it later does not send it twice. + buffer.startView("/search", DAY, false); + const after = buffer.takeBatch(false); + assert.equal(after, null); +}); + +test("calls land on the view they started in, even after it ended; cancels are not counted", () => { + const buffer = new RumBuffer("sid-12345678"); + const first = buffer.startView("/challenges", DAY, false); + buffer.recordApi(first, 120.4, "ok"); + buffer.startView("/search", DAY, false); // the first view ended… + buffer.recordApi(first, 900, "error"); // …and its slow call finished afterwards + buffer.recordApi(first, 50, "cancelled"); + buffer.recordInteraction(7, 80); + buffer.recordInteraction(7, 260); // the same interaction's longest event wins + buffer.recordInteraction(8, 40); + const batch = buffer.takeBatch(true)!; + const views = (JSON.parse(batch.body) as { views: { route: string; api: number[]; calls: number; apiErrors: number; inp: number | null }[] }).views; + assert.deepEqual(views[0], { ...views[0], route: "/challenges", api: [120, 900], calls: 2, apiErrors: 1, inp: null }); + assert.equal(views[1]!.route, "/search"); + assert.equal(views[1]!.inp, 260); +}); + +test("errors are counted always and described up to 10 per view, tagged with the view's route", () => { + const buffer = new RumBuffer("sid-12345678"); + buffer.startView("/admin", DAY, false); + for (let i = 0; i < 14; i++) buffer.recordError({ message: `boom ${i}`, source: "/_next/a.js:1", frame: "" }); + buffer.startView("/", DAY, false); + const batch = buffer.takeBatch(false)!; + assert.equal(batch.errors.length, 10); + assert.ok(batch.errors.every((e) => e.route === "/admin")); + const wire = JSON.parse(batch.body) as { views: { errors: number }[] }; + assert.equal(wire.views[0]!.errors, 14); +}); + +test("a batch holds at most 50 views and stays under 64 KB; the rest wait", () => { + const buffer = new RumBuffer("sid-12345678"); + for (let i = 0; i < 70; i++) { + const view = buffer.startView("/challenges/[number]", DAY, false); + for (let c = 0; c < 50; c++) buffer.recordApi(view, 100000 + c, "ok"); + } + buffer.startView("/", DAY, false); + const first = buffer.takeBatch(false)!; + assert.ok(first.views.length <= RUM_MAX_VIEWS_PER_BEACON); + assert.ok(new TextEncoder().encode(first.body).length <= RUM_MAX_BEACON_BYTES); + let sent = first.views.length; + for (let b = buffer.takeBatch(false); b; b = buffer.takeBatch(false)) sent += b.views.length; + assert.equal(sent, 70, "every completed view goes out, over several beacons"); +}); + +test("a batch that could not be sent goes back to the front of the queue", () => { + const buffer = new RumBuffer("sid-12345678"); + buffer.startView("/challenges", DAY, false); + buffer.recordError({ message: "x", source: "/a.js:1", frame: "" }); + buffer.startView("/search", DAY, false); + const batch = buffer.takeBatch(false)!; + buffer.requeue(batch); + const again = buffer.takeBatch(false)!; + assert.deepEqual( + again.views.map((v) => v.route), + ["/challenges"], + ); + assert.equal(again.errors.length, 1); +}); + +test("the flush clock walks 17 → 157 s while idle and resets after a send", () => { + const clock = new FlushClock(); + const seen: number[] = [clock.delayMs / 1000]; + for (let i = 0; i < 6; i++) { + clock.after(false); + seen.push(clock.delayMs / 1000); + } + assert.deepEqual(seen, [17, 23, 37, 59, 97, 157, 157]); + clock.after(true); + assert.equal(clock.delayMs, 17_000); +}); diff --git a/packages/web/src/lib/rum-collector.ts b/packages/web/src/lib/rum-collector.ts new file mode 100644 index 0000000..ca8de9e --- /dev/null +++ b/packages/web/src/lib/rum-collector.ts @@ -0,0 +1,275 @@ +// The §14.12 collector's engine (INNOBOX_SPEC.md): the per-tab session, the view buffer, the API +// call filter and the batch builder. No DOM access — the browser APIs are wired up by +// components/RumCollector.tsx — so the whole of it runs under the unit runner. +// +// The life of a view: it starts on a hard load or a route change, collects interactions, API +// calls and errors while it is the current view, and ends at the next route change. An ended view +// waits in the queue and is serialized only when a batch is taken, so an API call that resolves +// after its view ended still lands on it. The hard-load view is the exception: it is HELD until +// the page is hidden, because its CLS (and a late LCP) are only final then. +import { + RUM_FLUSH_SCHEDULE_S, + RUM_MAX_API_TIMINGS, + RUM_MAX_BEACON_BYTES, + RUM_MAX_ERRORS_PER_BEACON, + RUM_MAX_ERRORS_PER_VIEW, + RUM_MAX_VIEWS_PER_BEACON, + decideSampled, + inpFromInteractions, + nextFlushStep, +} from "@innobox/shared/rum"; + +// ── Session ────────────────────────────────────────────────────────────────────────────── + +export const RUM_SESSION_KEY = "innobox:rum-session"; + +export interface RumSession { + sid: string; + sampled: boolean; +} + +/** Minimal Storage surface, so tests can pass a Map-backed fake. */ +export interface SessionStore { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +/** + * The tab's session: read from sessionStorage, or — the first time the collector starts in this + * tab — a fresh random id with ONE sampling draw, stored together so a reload, an in-tab + * navigation or a later rate change never re-draws. Without storage (blocked, private mode) the + * session lives for this page only. + */ +export function loadOrStartSession(store: SessionStore | null, rate: number, random: () => number, uuid: () => string): RumSession { + try { + const raw = store?.getItem(RUM_SESSION_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.sid === "string" && typeof parsed.sampled === "boolean") return { sid: parsed.sid, sampled: parsed.sampled }; + } + } catch { + /* unreadable — start a new one */ + } + const session = { sid: uuid(), sampled: decideSampled(rate, random()) }; + try { + store?.setItem(RUM_SESSION_KEY, JSON.stringify(session)); + } catch { + /* quota or blocked — this page keeps the session in memory */ + } + return session; +} + +// ── Which requests are the page's work ─────────────────────────────────────────────────── + +/** The shell's 30-second pulse (§12.2 — the bell count, the badges and the banner in one poll) is + * not the page's work (the §14.5 rule), nor are the two pollers it replaced, should anything still + * call them; the beacon is not, and neither is the auth library's session check. Opening the + * bell's inbox list is a person acting, and counts. */ +export function isTrackedApiCall(url: string, method: string, origin: string): boolean { + let parsed: URL; + try { + parsed = new URL(url, origin); + } catch { + return false; + } + if (parsed.origin !== origin) return false; + const path = parsed.pathname; + if (path !== "/api" && !path.startsWith("/api/")) return false; + if (path === "/api/rum" || path.startsWith("/api/auth/")) return false; + const read = method.toUpperCase() === "GET" || method.toUpperCase() === "HEAD"; + if (read && (path === "/api/me/pulse" || path === "/api/admin/triage/attention" || path === "/api/challenges/new-count")) return false; + return true; +} + +/** How a tracked call ended: a status ≥ 500, a network failure or the app's own timeout is an + * error; a request the browser cancelled because the user moved on is neither a call nor an + * error; everything else (4xx included — the app saying no) is a plain call. */ +export type ApiOutcome = "ok" | "error" | "cancelled"; + +export function apiOutcomeOfStatus(status: number): ApiOutcome { + return status >= 500 ? "error" : "ok"; +} + +export function apiOutcomeOfThrow(err: unknown): ApiOutcome { + const name = err && typeof err === "object" ? (err as { name?: unknown }).name : undefined; + return name === "AbortError" ? "cancelled" : "error"; +} + +// ── Views and the buffer ───────────────────────────────────────────────────────────────── + +export interface ViewState { + route: string; + day: string; + hard: boolean; + navMs: number | null; + interactions: Map; + apiMs: number[]; + calls: number; + apiErrors: number; + errors: number; + described: number; + /** Sent open on a hide; later changes are not sent again. */ + sent: boolean; +} + +export interface HardLoadMetrics { + lcp: number | null; + cls: number | null; + ttfb: number | null; +} + +export interface WireView { + route: string; + day: string; + hard: boolean; + lcp: number | null; + inp: number | null; + cls: number | null; + ttfb: number | null; + nav: number | null; + api: number[]; + calls: number; + apiErrors: number; + errors: number; +} + +export interface WireError { + message: string; + source: string; + frame: string; + route: string; +} + +export interface Batch { + views: ViewState[]; + errors: WireError[]; + body: string; +} + +export class RumBuffer { + private queue: ViewState[] = []; + private held: ViewState | null = null; + private errors: WireError[] = []; + private current: ViewState | null = null; + readonly hardMetrics: HardLoadMetrics = { lcp: null, cls: null, ttfb: null }; + + constructor(private readonly sid: string) {} + + get currentView(): ViewState | null { + return this.current; + } + + startView(route: string, day: string, hard: boolean, navMs: number | null = null): ViewState { + this.endView(); + this.current = { route, day, hard, navMs, interactions: new Map(), apiMs: [], calls: 0, apiErrors: 0, errors: 0, described: 0, sent: false }; + return this.current; + } + + /** Ends the current view: the hard-load view is held for the hide, any other is queued. */ + endView(): void { + const view = this.current; + this.current = null; + if (!view || view.sent) return; + if (view.hard) this.held = view; + else this.queue.push(view); + } + + /** An interaction's longest event duration, kept per interaction id on the current view. */ + recordInteraction(interactionId: number, duration: number): void { + const view = this.current; + if (!view || interactionId <= 0) return; + view.interactions.set(interactionId, Math.max(view.interactions.get(interactionId) ?? 0, duration)); + } + + /** A finished API call, attributed to the view that was current when it STARTED. */ + recordApi(view: ViewState | null, ms: number, outcome: ApiOutcome): void { + if (!view || outcome === "cancelled") return; + view.calls += 1; + if (view.apiMs.length < RUM_MAX_API_TIMINGS) view.apiMs.push(Math.max(0, Math.round(ms))); + if (outcome === "error") view.apiErrors += 1; + } + + /** An error occurrence on the current view: always counted, described only up to 10 per view. */ + recordError(desc: { message: string; source: string; frame: string }): void { + const view = this.current; + if (!view) return; + view.errors += 1; + if (view.described >= RUM_MAX_ERRORS_PER_VIEW) return; + view.described += 1; + this.errors.push({ ...desc, route: view.route }); + } + + serialize(view: ViewState): WireView { + const hard = view.hard; + return { + route: view.route, + day: view.day, + hard, + lcp: hard ? this.hardMetrics.lcp : null, + inp: inpFromInteractions([...view.interactions.values()]), + cls: hard ? this.hardMetrics.cls : null, + ttfb: hard ? this.hardMetrics.ttfb : null, + nav: view.navMs, + api: view.apiMs, + calls: view.calls, + apiErrors: view.apiErrors, + errors: view.errors, + }; + } + + /** + * The next beacon, or null when there is nothing to send. A regular tick sends completed views + * only; the final flush on hide also sends the held hard-load view and the current view (open, + * marked so it is not sent twice). At most 50 views, 50 errors and 64 KB — the rest wait. + */ + takeBatch(final: boolean): Batch | null { + const views = [...this.queue]; + if (final) { + if (this.held) views.push(this.held); + if (this.current && !this.current.sent) views.push(this.current); + } + let pickViews = views.slice(0, RUM_MAX_VIEWS_PER_BEACON); + let pickErrors = this.errors.slice(0, RUM_MAX_ERRORS_PER_BEACON); + if (pickViews.length === 0 && pickErrors.length === 0) return null; + + const encode = () => JSON.stringify({ sid: this.sid, views: pickViews.map((v) => this.serialize(v)), errors: pickErrors }); + let body = encode(); + while (new TextEncoder().encode(body).length > RUM_MAX_BEACON_BYTES && pickViews.length + pickErrors.length > 1) { + if (pickErrors.length >= pickViews.length) pickErrors = pickErrors.slice(0, -1); + else pickViews = pickViews.slice(0, -1); + body = encode(); + } + + const taken = new Set(pickViews); + this.queue = this.queue.filter((v) => !taken.has(v)); + if (this.held && taken.has(this.held)) this.held = null; + if (this.current && taken.has(this.current)) this.current.sent = true; + this.errors = this.errors.slice(pickErrors.length); + return { views: pickViews, errors: pickErrors, body }; + } + + /** Puts a batch that could not be sent back at the front, unless it held the open current view + * (a final flush is not retried). */ + requeue(batch: Batch): void { + const back = batch.views.filter((v) => v !== this.current && !v.hard); + this.queue = [...back, ...this.queue]; + const hard = batch.views.find((v) => v.hard && v !== this.current); + if (hard && !this.held) this.held = hard; + this.errors = [...batch.errors, ...this.errors]; + } +} + +// ── The flush clock ────────────────────────────────────────────────────────────────────── + +/** The schedule walker: `delayMs` for the next tick, and `after(sent)` to advance it. */ +export class FlushClock { + private step = 0; + + get delayMs(): number { + return RUM_FLUSH_SCHEDULE_S[this.step]! * 1000; + } + + after(sentSomething: boolean): void { + this.step = nextFlushStep(this.step, sentSomething); + } +} diff --git a/packages/web/src/lib/rum-format.ts b/packages/web/src/lib/rum-format.ts new file mode 100644 index 0000000..5240e3d --- /dev/null +++ b/packages/web/src/lib/rum-format.ts @@ -0,0 +1,78 @@ +// Display formatting for the §14.12 monitoring page (INNOBOX_SPEC.md): milliseconds, layout +// shift, error rates and the three-step verdict dot. Pure, shared by the table and the chart. +import { apiErrorPercent, type RumMetric, type RumRouteSummary, type RumVerdict } from "@innobox/shared/rum"; + +/** "—" for no value; "85 ms" under a second; "2.39 s" from a second up. */ +export function formatRumMs(value: number | null): string { + if (value === null) return "—"; + if (value >= 1000) return `${(value / 1000).toFixed(2)} s`; + return `${Math.round(value)} ms`; +} + +/** Layout shift is unitless; three decimals is what the thresholds (0.1 / 0.25) need. */ +export function formatRumCls(value: number | null): string { + return value === null ? "—" : value.toFixed(3); +} + +export function formatRumPercent(value: number | null): string { + return value === null ? "—" : `${value.toFixed(1)} %`; +} + +/** The pill class carrying a verdict; the dot sits beside the number, never instead of it. */ +export function verdictPillClass(verdict: RumVerdict): string { + if (verdict === "good") return "pill-ok"; + return verdict === "needs-improvement" ? "pill-warn" : "pill-danger"; +} + +/** The verdict in words, for the cell's accessible name. */ +export function verdictLabel(verdict: RumVerdict): string { + if (verdict === "good") return "good"; + return verdict === "needs-improvement" ? "needs improvement" : "poor"; +} + +// ── The routes table ───────────────────────────────────────────────────────────────────── + +export type RumSortKey = "name" | "views" | "sessions" | RumMetric | "apiErrors" | "clientErrors"; + +export const RUM_COLUMNS: { key: RumSortKey; label: string; metric?: RumMetric }[] = [ + { key: "name", label: "Route" }, + { key: "views", label: "Views" }, + { key: "sessions", label: "Sessions" }, + { key: "lcp", label: "p75 LCP", metric: "lcp" }, + { key: "inp", label: "p75 INP", metric: "inp" }, + { key: "cls", label: "p75 CLS", metric: "cls" }, + { key: "ttfb", label: "p75 TTFB", metric: "ttfb" }, + { key: "nav", label: "p75 Nav", metric: "nav" }, + { key: "api", label: "p75 API", metric: "api" }, + { key: "apiErrors", label: "API errors" }, + { key: "clientErrors", label: "Client errors" }, +]; + +function sortValue(row: RumRouteSummary, key: RumSortKey): number | string | null { + switch (key) { + case "name": + return row.name; + case "views": + case "sessions": + case "clientErrors": + return row[key]; + case "apiErrors": + return apiErrorPercent(row.apiErrors, row.apiCalls); + default: + return row.p75[key]; + } +} + +/** The routes table sorted by a column, as a copy. Nulls ("—") always sink to the bottom, whichever + * the direction. */ +export function sortRoutes(rows: readonly RumRouteSummary[], key: RumSortKey, desc: boolean): RumRouteSummary[] { + return [...rows].sort((a, b) => { + const va = sortValue(a, key); + const vb = sortValue(b, key); + if (va === null && vb === null) return 0; + if (va === null) return 1; + if (vb === null) return -1; + const cmp = typeof va === "string" ? va.localeCompare(String(vb)) : va - (vb as number); + return desc ? -cmp : cmp; + }); +} diff --git a/packages/web/src/lib/rum-registry.test.ts b/packages/web/src/lib/rum-registry.test.ts new file mode 100644 index 0000000..834dbf3 --- /dev/null +++ b/packages/web/src/lib/rum-registry.test.ts @@ -0,0 +1,53 @@ +// §14.12 route registry completeness (INNOBOX_SPEC.md): every authenticated page has a name in the +// registry, so "Other" stays empty in practice, and the registry names no page that does not +// exist. Walks app/**/page.tsx and turns each file path into its App Router template. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { RUM_ROUTES, RUM_UNMONITORED_TEMPLATES, rumRouteFor } from "@innobox/shared/rum"; + +const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "app"); + +function pageTemplates(dir: string, prefix = ""): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + // Route groups "(x)" add no segment; "api" holds route handlers, not pages. + if (prefix === "" && entry.name === "api") continue; + const segment = /^\(.*\)$/.test(entry.name) ? "" : `/${entry.name}`; + out.push(...pageTemplates(path.join(dir, entry.name), prefix + segment)); + } else if (entry.name === "page.tsx") { + out.push(prefix === "" ? "/" : prefix); + } + } + return out; +} + +const pages = pageTemplates(appDir); + +test("every authenticated page is in the registry", () => { + assert.ok(pages.length > 10, "found the app's pages"); + const registered = new Set(RUM_ROUTES.map((r) => r.template)); + const missing = pages.filter((t) => !registered.has(t) && !RUM_UNMONITORED_TEMPLATES.includes(t)); + assert.deepEqual(missing, [], "add these pages to RUM_ROUTES in packages/shared/src/rum.ts"); +}); + +test("the registry names no page that does not exist", () => { + const existing = new Set(pages); + const stale = RUM_ROUTES.map((r) => r.template).filter((t) => !existing.has(t)); + assert.deepEqual(stale, []); +}); + +test("each page's own path resolves to its own template", () => { + for (const template of pages.filter((t) => !RUM_UNMONITORED_TEMPLATES.includes(t))) { + const concrete = template.replace(/\[[^\]]+\]/g, "123"); + assert.equal(rumRouteFor(concrete), template, concrete); + } +}); + +test("names are unique, so no two table rows read alike", () => { + const names = RUM_ROUTES.map((r) => r.name); + assert.equal(new Set(names).size, names.length); +}); diff --git a/packages/web/src/lib/rum-settings.ts b/packages/web/src/lib/rum-settings.ts new file mode 100644 index 0000000..18bcf74 --- /dev/null +++ b/packages/web/src/lib/rum-settings.ts @@ -0,0 +1,58 @@ +// The §14.12 real-user-monitoring switch and sample rate (INNOBOX_SPEC.md): two keys in +// platform_settings, off and 100 % by default (migration 0052). Read by `GET /api/me` (the +// collector's start decision) and by the beacon endpoint (which discards everything while off); +// written only from the monitoring page, audited as `settings.telemetry_changed`. +// Relative imports only (no `@/`) so the gated dbtests can load it. +import type { Pool, PoolClient } from "pg"; +import { RUM_ENABLED_KEY, RUM_SAMPLE_RATE_KEY, coerceRumSampleRate, type RumSampleRate } from "@innobox/shared/rum"; +import { appendAudit } from "./audit"; +import { inTransaction } from "./db"; + +export interface RumSettings { + enabled: boolean; + sampleRate: RumSampleRate; +} + +/** A missing or malformed switch reads as off; a malformed rate as 100. */ +export async function getRumSettings(db: Pool | PoolClient): Promise { + const { rows } = await db.query<{ key: string; value: unknown }>( + `select key, value from platform_settings where key = any($1::text[])`, + [[RUM_ENABLED_KEY, RUM_SAMPLE_RATE_KEY]], + ); + const value = (key: string) => rows.find((r) => r.key === key)?.value; + return { enabled: value(RUM_ENABLED_KEY) === true, sampleRate: coerceRumSampleRate(value(RUM_SAMPLE_RATE_KEY)) }; +} + +/** Applies a validated patch and writes one audit row with the resulting pair, in one transaction. + * A patch that changes nothing writes nothing. */ +export async function setRumSettings( + pool: Pool, + patch: Partial, + actorUserId: string, +): Promise { + return inTransaction(pool, async (client) => { + const before = await getRumSettings(client); + const after: RumSettings = { enabled: patch.enabled ?? before.enabled, sampleRate: patch.sampleRate ?? before.sampleRate }; + if (after.enabled === before.enabled && after.sampleRate === before.sampleRate) return before; + for (const [key, value] of [ + [RUM_ENABLED_KEY, after.enabled], + [RUM_SAMPLE_RATE_KEY, after.sampleRate], + ] as const) { + await client.query( + `insert into platform_settings (key, value, updated_by, updated_at) + values ($1, $2::jsonb, $3, now()) + on conflict (key) do update set value = excluded.value, updated_by = excluded.updated_by, updated_at = now()`, + [key, JSON.stringify(value), actorUserId], + ); + } + await appendAudit(client, { + actorUserId, + action: "settings.telemetry_changed", + targetType: "platform_settings", + targetId: RUM_ENABLED_KEY, + before: { enabled: before.enabled, sampleRate: before.sampleRate }, + after: { enabled: after.enabled, sampleRate: after.sampleRate }, + }); + return after; + }); +} diff --git a/packages/web/src/lib/system-log-core.ts b/packages/web/src/lib/system-log-core.ts index 4ef47a5..ef05c41 100644 --- a/packages/web/src/lib/system-log-core.ts +++ b/packages/web/src/lib/system-log-core.ts @@ -52,6 +52,9 @@ export interface SystemLogOptions { /** Record the row with NO user id and NO actor snapshot — the survey routes (§13.10): a named, * exact-time row next to a date-only response would identify it. The session is not even read. */ anonymousActor?: boolean; + /** Statuses this route never records — the §14.12 beacon's 429: telemetry arriving too fast must + * not turn into system-log noise. */ + unrecordedStatuses?: readonly number[]; } /** What a handler wants recorded for its response instead of the defaults — the §22.5 AI admin @@ -191,7 +194,7 @@ export function createSystemLogWrapper(deps: SystemLogDeps) { const note = notes.get(res); if (note) { void capture(template, req, note.status ?? res.status, note.message, note.errorCode ?? null, startedAt, opts).catch(() => {}); - } else if (shouldRecordSystemEvent(res.status)) { + } else if (shouldRecordSystemEvent(res.status) && !opts.unrecordedStatuses?.includes(res.status)) { void (async () => { const message = await errorMessageOf(res); await capture(template, req, res.status, message, null, startedAt, opts); diff --git a/packages/web/src/lib/system-log.test.ts b/packages/web/src/lib/system-log.test.ts index 1bbe8f4..d449c19 100644 --- a/packages/web/src/lib/system-log.test.ts +++ b/packages/web/src/lib/system-log.test.ts @@ -72,6 +72,22 @@ test("401 and 404 are never recorded; 403/409/413/422/429 and 5xx are", async () assert.equal(first.source, "web"); }); +test("unrecordedStatuses: the monitoring beacon's 429 is never recorded, its 5xx still is (§14.12)", async () => { + const { recorded, withSystemLog } = fakeDeps(); + for (const status of [429, 500]) { + const H = withSystemLog("/api/rum", async (_req: Request) => Response.json({ error: `status ${status}` }, { status }), { + anonymousActor: true, + unrecordedStatuses: [429], + }); + assert.equal((await H(req("POST", "/api/rum"))).status, status, "the response itself is unchanged"); + } + await settle(); + assert.deepEqual( + recorded.map((e) => [e.status, e.userId]), + [[500, null]], + ); +}); + test("the response body is left intact for the client after recording", async () => { const { withSystemLog } = fakeDeps(); const H = withSystemLog("/api/x", async (_req: Request) => Response.json({ error: "forbidden here" }, { status: 403 })); diff --git a/packages/worker/package.json b/packages/worker/package.json index 662af38..4b08fa9 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -11,7 +11,7 @@ "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "tsc -p tsconfig.json && cd dist && node --test", - "test:db": "node --import tsx --test --test-concurrency=1 src/scim/scim.dbtest.ts src/system-log/alert.dbtest.ts src/scim/identity-sync.dbtest.ts src/recon/reconcile.dbtest.ts src/webhooks/deliver.dbtest.ts src/notifications/dispatch.dbtest.ts src/notifications/digest.dbtest.ts src/ai/triage.dbtest.ts src/gamification/backfill.dbtest.ts src/public-intake/retention.dbtest.ts src/scim/agent-grants.dbtest.ts src/trash/purge.dbtest.ts src/presence/rollup.dbtest.ts", + "test:db": "node --import tsx --test --test-concurrency=1 src/scim/scim.dbtest.ts src/system-log/alert.dbtest.ts src/scim/identity-sync.dbtest.ts src/recon/reconcile.dbtest.ts src/webhooks/deliver.dbtest.ts src/notifications/dispatch.dbtest.ts src/notifications/digest.dbtest.ts src/ai/triage.dbtest.ts src/gamification/backfill.dbtest.ts src/public-intake/retention.dbtest.ts src/scim/agent-grants.dbtest.ts src/trash/purge.dbtest.ts src/presence/rollup.dbtest.ts src/rum/rollup.dbtest.ts", "lint": "eslint ." }, "dependencies": { diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 2751004..6e59ce0 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -31,6 +31,7 @@ import { createClamavScanner, createWorkerS3Client, runScanSweep, type ScanDeps, import { runDraftGcSweep } from "./attachments/draft-gc.js"; import { runUploadGcSweep } from "./attachments/upload-gc.js"; import { runPresenceRollupSweep } from "./presence/rollup.js"; +import { runRumSweep } from "./rum/rollup.js"; import { createMetricsHandler, createReadyzHandler, @@ -193,7 +194,8 @@ const leaderElection = startLeaderElection(dbUrl, { }) .catch((err) => console.error(JSON.stringify({ level: "error", msg: "gamification backfill failed", error: String(err) }))); - // §2 hourly housekeeping sweep: §14.5 presence rollup + retention purge, §14.7 system-log + // §2 hourly housekeeping sweep: §14.5 presence rollup + retention purge, §14.12 monitoring + // rollup + trims, §14.7 system-log // trim, §22.3 AI usage prune, §25.13 expired OAuth rows, §11 stale upload sessions (and abandoned draft attachments). Presence rollup is what // keeps per-person presence detail from accumulating past its 3-day floor — skipping it // because a mail or storage integration is unconfigured would silently turn presence into an @@ -208,6 +210,16 @@ const leaderElection = startLeaderElection(dbUrl, { } catch (err) { console.error(JSON.stringify({ level: "error", msg: "presence rollup sweep failed", error: String(err) })); } + // §14.12 real user monitoring: recompute the open 7-day window into the daily rollup, then + // trim raw views past it and error rows unseen for 90 days. + try { + const rum = await runRumSweep(pool); + if (rum.rolled || rum.trimmedViews || rum.trimmedErrors) { + console.log(JSON.stringify({ level: "info", msg: "rum sweep", ...rum })); + } + } catch (err) { + console.error(JSON.stringify({ level: "error", msg: "rum sweep failed", error: String(err) })); + } // §14.7 retention: the system log keeps 90 days. try { const trimmed = await trimSystemEvents(pool); diff --git a/packages/worker/src/rum/rollup.dbtest.ts b/packages/worker/src/rum/rollup.dbtest.ts new file mode 100644 index 0000000..ac9e0fb --- /dev/null +++ b/packages/worker/src/rum/rollup.dbtest.ts @@ -0,0 +1,99 @@ +// Live-DB integration test (gated) for the §14.12 monitoring sweep (INNOBOX_SPEC.md): the rollup +// writes per-route and all-routes rows with true percentiles, is idempotent, repairs an open day +// when late beacons arrive, leaves days outside the window alone, and the trims delete only what +// is past retention. Self-skips when DATABASE_URL is unset. +// +// rum_daily is shared with the web suite and the app role cannot delete from it, so assertions +// are keyed on this suite's own route names; the raw table is cleared first. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const url = process.env.DATABASE_URL; +const skip = url ? false : "DATABASE_URL not set — live-DB suite self-skips"; + +const R1 = "/dbtest-worker-one"; +const R2 = "/dbtest-worker-two"; + +test("rum sweep: rolls the open window per route and for all routes, idempotently, then trims", { skip }, async () => { + const { Pool } = await import("pg"); + const { runRumSweep } = await import("./rollup.js"); + const pool = new Pool({ connectionString: url }); + + const addView = (route: string, daysAgo: number, lcp: number | null, session: string, apiMs: number[] = []) => + pool.query( + `insert into rum_views (day, session_id, route, hard_load, lcp_ms, api_ms, api_calls, api_errors, client_errors) + values ((now() at time zone 'utc')::date - $1::int, $2, $3, $4, $5, $6::int[], $7, 0, 1)`, + [daysAgo, session, route, lcp !== null, lcp, apiMs, apiMs.length], + ); + const daily = async (route: string, daysAgo: number) => + ( + await pool.query<{ views: number; sessions: number; p75_lcp_ms: number | null; p75_api_ms: number | null; client_errors: number }>( + `select views, sessions, p75_lcp_ms, p75_api_ms, client_errors from rum_daily + where route = $1 and day = (now() at time zone 'utc')::date - $2::int`, + [route, daysAgo], + ) + ).rows[0]; + + try { + await pool.query(`delete from rum_views`); + // Yesterday: R1 with LCP 100..400 over two sessions, R2 one soft view; a view 3 days back. + await addView(R1, 1, 100, "s-1", [100, 300]); + await addView(R1, 1, 200, "s-1"); + await addView(R1, 1, 300, "s-2"); + await addView(R1, 1, 400, "s-2"); + await addView(R2, 1, null, "s-3"); + await addView(R1, 3, 1000, "s-4"); + + const first = await runRumSweep(pool); + assert.ok(first.rolled >= 4, "per-route rows and the all-routes rows for two days"); + assert.deepEqual(await daily(R1, 1), { views: 4, sessions: 2, p75_lcp_ms: 325, p75_api_ms: 250, client_errors: 4 }); + assert.deepEqual(await daily(R2, 1), { views: 1, sessions: 1, p75_lcp_ms: null, p75_api_ms: null, client_errors: 1 }); + const star = await daily("*", 1); + assert.equal(star!.views, 5, "the all-routes row counts every route"); + assert.equal(star!.sessions, 3); + assert.equal(star!.p75_lcp_ms, 325, "a real percentile over all views, the soft view's null skipped"); + assert.equal((await daily(R1, 3))!.p75_lcp_ms, 1000); + + // Idempotent: a second sweep over the same raw rows changes nothing. + await runRumSweep(pool); + assert.deepEqual(await daily(R1, 1), { views: 4, sessions: 2, p75_lcp_ms: 325, p75_api_ms: 250, client_errors: 4 }); + + // A late beacon for yesterday (a tab hidden before midnight, flushed after): repaired. + await addView(R1, 1, 5000, "s-5"); + await runRumSweep(pool); + assert.deepEqual(await daily(R1, 1), { views: 5, sessions: 3, p75_lcp_ms: 400, p75_api_ms: 250, client_errors: 5 }); + + // Outside the window: raw views 8 days back are trimmed, and their day is never recomputed. + await pool.query( + `insert into rum_daily (day, route, views, sessions, api_calls, api_errors, client_errors) + values ((now() at time zone 'utc')::date - 8, $1, 77, 7, 0, 0, 0) + on conflict (day, route) do update set views = 77, sessions = 7`, + [R1], + ); + await addView(R1, 8, 1, "s-6"); + const trimmed = await runRumSweep(pool); + assert.equal(trimmed.trimmedViews, 1); + assert.equal((await daily(R1, 8))!.views, 77, "a closed day outside the window is left alone"); + const { rows } = await pool.query<{ n: number }>(`select count(*)::int as n from rum_views where day < (now() at time zone 'utc')::date - 7`); + assert.equal(rows[0]!.n, 0); + const { rows: kept } = await pool.query<{ n: number }>(`select count(*)::int as n from rum_views where route = $1`, [R1]); + assert.equal(kept[0]!.n, 6, "views inside the window are kept"); + + // Errors: deleted 90 days after last_seen, not before. + await pool.query(`delete from rum_client_errors where fingerprint like 'dbtest-worker-%'`); + await pool.query( + `insert into rum_client_errors (fingerprint, message, source, frame, route, first_seen, last_seen, count) values + ('dbtest-worker-stale', 'm', '/a.js:1', '', '/', now() - interval '200 days', now() - interval '91 days', 1), + ('dbtest-worker-fresh', 'm', '/a.js:2', '', '/', now() - interval '200 days', now() - interval '89 days', 1)`, + ); + const swept = await runRumSweep(pool); + assert.ok(swept.trimmedErrors >= 1); + const { rows: left } = await pool.query<{ fingerprint: string }>( + `select fingerprint from rum_client_errors where fingerprint like 'dbtest-worker-%' order by fingerprint`, + ); + assert.deepEqual(left.map((r) => r.fingerprint), ["dbtest-worker-fresh"]); + } finally { + await pool.query(`delete from rum_views where route in ($1, $2)`, [R1, R2]).catch(() => {}); + await pool.end(); + } +}); diff --git a/packages/worker/src/rum/rollup.ts b/packages/worker/src/rum/rollup.ts new file mode 100644 index 0000000..36aac3a --- /dev/null +++ b/packages/worker/src/rum/rollup.ts @@ -0,0 +1,51 @@ +// The §14.12 real-user-monitoring sweep (INNOBOX_SPEC.md), part of the hourly housekeeping sweep. +// Leader-only and pure-DB, so it runs without Entra credentials. +// +// Two steps, in this order, and the order matters: +// 1. ROLLUP — recompute every UTC day still inside the raw window (today and the 7 days before) +// from rum_views into rum_daily, per route plus the '*' all-routes row, with the SQL the web +// read path uses for today's live rows (shared rum-sql). An upsert over the whole window +// rather than "yesterday only": a day whose late beacons arrived after midnight (a tab hidden +// at 23:58 flushes at 00:01 with its view's own day) is repaired by the next sweep, and a +// sweep the worker missed is made good by the following one. +// 2. TRIM — delete raw views older than the window, and error rows not seen for 90 days. +// The window and the retention are the same 7 days and the rollup runs first, so every day gets +// its final recompute from complete raw rows before any of them is deleted. +// +// Days are UTC buckets: `(now() at time zone 'utc')::date`, never `current_date`. +import type { Pool } from "pg"; +import { RUM_ERROR_RETENTION_DAYS, RUM_RAW_RETENTION_DAYS } from "@innobox/shared/rum"; +import { rumAggregateSql } from "@innobox/shared/rum-sql"; + +export interface RumSweepSummary { + /** (day, route) rows written or refreshed in rum_daily. */ + rolled: number; + /** Raw views deleted for age. */ + trimmedViews: number; + /** Deduplicated error rows deleted for age. */ + trimmedErrors: number; +} + +const TODAY = "(now() at time zone 'utc')::date"; + +export async function runRumSweep(pool: Pool): Promise { + const rolled = await pool.query( + `insert into rum_daily (day, route, views, sessions, p75_lcp_ms, p75_inp_ms, p75_cls, p75_ttfb_ms, + p75_nav_ms, p75_api_ms, api_calls, api_errors, client_errors) + ${rumAggregateSql(`v.day >= ${TODAY} - $1::int`, true)} + on conflict (day, route) do update set + views = excluded.views, sessions = excluded.sessions, + p75_lcp_ms = excluded.p75_lcp_ms, p75_inp_ms = excluded.p75_inp_ms, p75_cls = excluded.p75_cls, + p75_ttfb_ms = excluded.p75_ttfb_ms, p75_nav_ms = excluded.p75_nav_ms, p75_api_ms = excluded.p75_api_ms, + api_calls = excluded.api_calls, api_errors = excluded.api_errors, client_errors = excluded.client_errors`, + [RUM_RAW_RETENTION_DAYS], + ); + + // Only after the rollup — never before it. + const views = await pool.query(`delete from rum_views where day < ${TODAY} - $1::int`, [RUM_RAW_RETENTION_DAYS]); + const errors = await pool.query(`delete from rum_client_errors where last_seen < now() - make_interval(days => $1::int)`, [ + RUM_ERROR_RETENTION_DAYS, + ]); + + return { rolled: rolled.rowCount ?? 0, trimmedViews: views.rowCount ?? 0, trimmedErrors: errors.rowCount ?? 0 }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f08e4d1..3f101c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: react-dom: specifier: ^19.3.0 version: 19.3.0(react@19.3.0) + web-vitals: + specifier: ^5.3.0 + version: 5.3.0 devDependencies: '@modelcontextprotocol/sdk': specifier: ^1.32.1 @@ -2858,6 +2861,9 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + web-vitals@5.3.0: + resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4549,7 +4555,7 @@ snapshots: eslint: 9.39.5 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.5))(eslint@9.39.5) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5) eslint-plugin-react: 7.37.5(eslint@9.39.5) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5) @@ -4582,7 +4588,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5) transitivePeerDependencies: - supports-color @@ -4596,7 +4602,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.2.0 @@ -6170,6 +6176,8 @@ snapshots: w3c-keyname@2.2.8: {} + web-vitals@5.3.0: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0