Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
359 changes: 348 additions & 11 deletions INNOBOX_SPEC.md

Large diffs are not rendered by default.

75 changes: 75 additions & 0 deletions db/migrations/0052_real_user_monitoring.sql
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions packages/shared/src/rum-sql.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading