Public: anyone can read this; the crew can change it.
Boards can chart each app's p75 load and INP per day from an app_vital source #3214
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3201-boards-can-chart-each-app-s-p75-load-and → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +91
−0
README.md +2
| @@ -247,6 +247,8 @@ Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, | ||
| 247 | 247 | |
| 248 | 248 | An app made from the template ships the same beacon as `static/beacon.js`, posting to `POST /__forge/measure` on the app's own host. The forge answers that door itself (`src/apps/measure.ts`), before the app is woken: same-origin only, 30 reports a minute per client and 600 per app (429), a body over the cap is a 413, and a private app or a sealed pull's preview refuses anyone its gate refuses. The app is the host's, never the body's. Production reports are kept in `app_vital` (`src/apps/vitals.ts`, load and INP, a visitor hashed under the day's salt, no address, kept 90 days and removed with the app, at most 20,000 rows per app a day); previews store nothing. Only Do-Not-Track opts a visitor out on an app's host: the `forge_measure=off` cookie is set on the forge's host and never reaches an app's. Read the numbers (visitors today and over 7 days, p75 load and INP) on the app's settings page under **Visitors and speed**, or as `vitals` in an admin's `GET /api/v1/repos/:owner/:name/app`. Both doors show them only to people who administer the repository (`appVisitors` in `src/actions/apps.ts`), so a reader or an anonymous caller sees no `vitals`. In the terminal they are the second line of the app's **Deploys** screen over ssh. |
| 249 | 249 | |
| 250 | The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker. | |
| 251 | ||
| 250 | 252 | ## Live in the terminal |
| 251 | 253 | |
| 252 | 254 | `ssh` into the forge and you get the whole thing — repositories, code, issues, |
src/boards/sources.ts +27
| @@ -252,6 +252,33 @@ export const SOURCES: Readonly<Record<string, Source>> = { | ||
| 252 | 252 | value: "the count, or the percentile (nearest rank) of that route that day", |
| 253 | 253 | }, |
| 254 | 254 | }, |
| 255 | // What each app's visitors report (`apps/vitals.ts`): one row per app per | |
| 256 | // day, filed under the app's repo, so a scope of the repos someone | |
| 257 | // administers (as the app's settings card decides) is the only gate. The | |
| 258 | // visitor hash and the slug are not listed. | |
| 259 | app_vital: { | |
| 260 | table: "app_vital_day", | |
| 261 | owner: "repo_id", | |
| 262 | select: | |
| 263 | "SELECT a.repo_id AS repo_id, v.app_id AS app, v.day || 'T00:00:00.000Z' AS day, " + | |
| 264 | "MAX(CASE WHEN v.load IS NOT NULL AND v.rl = (v.nl * 75 + 99) / 100 THEN v.load END) AS load_p75, " + | |
| 265 | "MAX(CASE WHEN v.inp IS NOT NULL AND v.ri = (v.ni * 75 + 99) / 100 THEN v.inp END) AS inp_p75, " + | |
| 266 | "COUNT(*) AS reports FROM (SELECT app_id, day, load, inp, " + | |
| 267 | "ROW_NUMBER() OVER (PARTITION BY app_id, day ORDER BY load IS NULL, load) AS rl, " + | |
| 268 | "COUNT(load) OVER (PARTITION BY app_id, day) AS nl, " + | |
| 269 | "ROW_NUMBER() OVER (PARTITION BY app_id, day ORDER BY inp IS NULL, inp) AS ri, " + | |
| 270 | "COUNT(inp) OVER (PARTITION BY app_id, day) AS ni FROM app_vital) v " + | |
| 271 | "JOIN app a ON a.id = v.app_id GROUP BY v.app_id, v.day", | |
| 272 | time: "day", | |
| 273 | columns: { repo_id: "text", app: "text", day: "time", load_p75: "number", inp_p75: "number", reports: "number" }, | |
| 274 | docs: { | |
| 275 | app: "the app's id", | |
| 276 | day: "the day (UTC)", | |
| 277 | load_p75: "page load, ms, nearest-rank 75th percentile of that day's reports; null with none", | |
| 278 | inp_p75: "interaction to next paint, ms, same rank; null with none", | |
| 279 | reports: "reports kept that day (production only)", | |
| 280 | }, | |
| 281 | }, | |
| 255 | 282 | // Forges made by the one-click path (#2914): one row per spawn event |
| 256 | 283 | // (`actions/spawn.ts`, `spawn/live.ts`). The forge's name and the failure's |
| 257 | 284 | // words are not listed; the step is one of a fixed few. |
test/boards-app-vital.test.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * The `app_vital` source: each app's p75 load and INP per day, nearest rank, | |
| 3 | * visible only through the repos a scope names. | |
| 4 | */ | |
| 5 | import { beforeAll, expect, test } from "bun:test"; | |
| 6 | import type { Database } from "bun:sqlite"; | |
| 7 | import { runQuery, type Scope } from "../src/boards/query.ts"; | |
| 8 | import { migrations } from "../src/core/schema.ts"; | |
| 9 | import { openDb } from "../src/kernel/db.ts"; | |
| 10 | ||
| 11 | let db: Database; | |
| 12 | const q = (scope: Scope, extra: Record<string, unknown> = {}) => | |
| 13 | runQuery(db, { source: "app_vital", aggregate: "max", column: "load_p75", groupBy: "day", series: "app", ...extra }, scope); | |
| 14 | ||
| 15 | beforeAll(() => { | |
| 16 | db = openDb(":memory:", migrations); | |
| 17 | db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u1','u','u@x','x'), ('u2','w','w@x','x')"); | |
| 18 | db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1','u1','one'), ('r2','u2','two')"); | |
| 19 | db.run("INSERT INTO app (id, repo_id, slug, access) VALUES ('a1','r1','one','public'), ('a2','r2','two','public')"); | |
| 20 | const put = db.prepare("INSERT INTO app_vital (app_id, day, at, visitor, load, inp) VALUES (?, ?, 0, ?, ?, ?)"); | |
| 21 | // a1, day 1: loads 100..400 -> rank ceil(.75*4)=3 -> 300; inp 10,20 -> rank 2 -> 20. | |
| 22 | [100, 200, 300, 400].forEach((l, i) => put.run("a1", "2026-10-01", `v${i}`, l, i < 2 ? (i + 1) * 10 : null)); | |
| 23 | // a1, day 2: one report, no inp. | |
| 24 | put.run("a1", "2026-10-02", "v0", 50, null); | |
| 25 | // a2, day 1: five loads -> rank ceil(3.75)=4 -> 40; inp 5. | |
| 26 | [50, 10, 20, 30, 40].forEach((l, i) => put.run("a2", "2026-10-01", `w${i}`, l, i === 0 ? 5 : null)); | |
| 27 | }); | |
| 28 | ||
| 29 | test("each app's p75 per day is the nearest rank", () => { | |
| 30 | const rows = runQuery(db, { source: "app_vital", aggregate: "max", column: "load_p75", groupBy: "day", series: "app" }, { repoIds: "all" }); | |
| 31 | expect(rows).toEqual({ | |
| 32 | ok: true, | |
| 33 | rows: [ | |
| 34 | { group: "2026-10-01T00:00:00.000Z", value: 300, series: "a1" }, | |
| 35 | { group: "2026-10-01T00:00:00.000Z", value: 40, series: "a2" }, | |
| 36 | { group: "2026-10-02T00:00:00.000Z", value: 50, series: "a1" }, | |
| 37 | ], | |
| 38 | }); | |
| 39 | const inp = q({ repoIds: "all" }, { column: "inp_p75" }); | |
| 40 | expect(inp).toEqual({ | |
| 41 | ok: true, | |
| 42 | rows: [ | |
| 43 | { group: "2026-10-01T00:00:00.000Z", value: 20, series: "a1" }, | |
| 44 | { group: "2026-10-01T00:00:00.000Z", value: 5, series: "a2" }, | |
| 45 | { group: "2026-10-02T00:00:00.000Z", value: null, series: "a1" }, | |
| 46 | ], | |
| 47 | }); | |
| 48 | const n = q({ repoIds: "all" }, { aggregate: "sum", column: "reports" }); | |
| 49 | expect(n.ok && n.rows.map((r) => r.value)).toEqual([4, 5, 1]); | |
| 50 | }); | |
| 51 | ||
| 52 | test("a scope sees only its own repos' apps, and a stranger none", () => { | |
| 53 | const mine = q({ repoIds: ["r2"] }); | |
| 54 | expect(mine).toEqual({ ok: true, rows: [{ group: "2026-10-01T00:00:00.000Z", value: 40, series: "a2" }] }); | |
| 55 | expect(q({ repoIds: [] })).toEqual({ ok: true, rows: [] }); | |
| 56 | expect(q({ repoIds: ["r9"] })).toEqual({ ok: true, rows: [] }); | |
| 57 | }); | |
| 58 | ||
| 59 | test("from and to bound the days", () => { | |
| 60 | const r = q({ repoIds: "all" }, { from: "2026-10-02T00:00:00Z" }); | |
| 61 | expect(r.ok && r.rows.length).toBe(1); | |
| 62 | }); |
new above
offline
The inner SELECT ranks all of app_vital before the scope and from/to filters see it. Push the day bound down, or cap the window scan, so a one-app scope does not pay for the whole table.