Public: anyone can read this; the crew can change it.
Visitors, users and page speed are a /boards source #2787
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2712-visitors-users-and-page-speed-are-a-boar → main 1 commit fork
3 files
changed +86
−2
README.md +2
| @@ -1640,3 +1640,5 @@ operator. (The three guards of migrations 62, 70 and 81 predate the prefix and | ||
| 1640 | 1640 | are known by name in `src/http/refusal.ts`.) The terminal's toasts follow the |
| 1641 | 1641 | same rule. A guard installed by hand without the prefix answers `refused` |
| 1642 | 1642 | until it is re-created with `forge: ` in front of its message. |
| 1643 | ||
| 1644 | `reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added. |
src/boards/sources.ts +37
| @@ -33,6 +33,16 @@ export type Source = { | ||
| 33 | 33 | docs?: Readonly<Record<string, string>>; |
| 34 | 34 | }; |
| 35 | 35 | |
| 36 | const DAY_AT = "day || 'T00:00:00.000Z'"; | |
| 37 | const NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"; | |
| 38 | ||
| 39 | /** Nearest-rank percentile `p` of a page_vital column, per route per day, as rows of the `reach` source. */ | |
| 40 | const percentile = (name: string, column: string, p: number) => | |
| 41 | `SELECT '${name}' AS metric, route, ${DAY_AT} AS at, ${column} AS value FROM (` + | |
| 42 | `SELECT route, day, ${column}, ROW_NUMBER() OVER (PARTITION BY route, day ORDER BY ${column}) AS rn, ` + | |
| 43 | `COUNT(*) OVER (PARTITION BY route, day) AS n FROM page_vital WHERE ${column} IS NOT NULL) ` + | |
| 44 | `WHERE rn = (n * ${p} + 99) / 100`; | |
| 45 | ||
| 36 | 46 | export const SOURCES: Readonly<Record<string, Source>> = { |
| 37 | 47 | runs: { |
| 38 | 48 | table: "run", |
| @@ -195,6 +205,33 @@ export const SOURCES: Readonly<Record<string, Source>> = { | ||
| 195 | 205 | celsius_max: "°C, the highest 15-s point in the step", |
| 196 | 206 | }, |
| 197 | 207 | }, |
| 208 | // Who visits and how fast it is (#2712): one row per metric (and route, and | |
| 209 | // day), read from the page-view beacon's `page_vital` and from `session`. | |
| 210 | // Admin only. The visitor hash and the user ids are not listed. | |
| 211 | reach: { | |
| 212 | table: "reach", | |
| 213 | owner: "admin", | |
| 214 | select: [ | |
| 215 | `SELECT 'visitors' AS metric, '' AS route, ${DAY_AT} AS at, COUNT(DISTINCT visitor) AS value FROM page_vital GROUP BY day`, | |
| 216 | percentile("lcp_p75", "lcp", 75), | |
| 217 | percentile("inp_p75", "inp", 75), | |
| 218 | percentile("server_p50", "ttfb", 50), | |
| 219 | percentile("server_p95", "ttfb", 95), | |
| 220 | `SELECT 'accounts_active_1d', '', ${NOW}, COUNT(DISTINCT user_id) FROM session WHERE last_seen_at >= strftime('%Y-%m-%dT00:00:00Z', 'now')`, | |
| 221 | `SELECT 'accounts_active_7d', '', ${NOW}, COUNT(DISTINCT user_id) FROM session WHERE last_seen_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')`, | |
| 222 | `SELECT 'accounts_total', '', ${NOW}, COUNT(*) FROM user`, | |
| 223 | ].join(" UNION ALL "), | |
| 224 | time: "at", | |
| 225 | columns: { metric: "text", route: "text", at: "time", value: "number" }, | |
| 226 | docs: { | |
| 227 | metric: | |
| 228 | "visitors (distinct browsers that day), lcp_p75 and inp_p75 (ms), server_p50 and server_p95 " + | |
| 229 | "(ms, time to first byte), accounts_active_1d (signed in today), accounts_active_7d, accounts_total", | |
| 230 | route: "the route pattern, never the path; empty for visitors and accounts", | |
| 231 | at: "the day (UTC) for visitors and the percentiles; now for the account counts, which are not kept per day", | |
| 232 | value: "the count, or the percentile (nearest rank) of that route that day", | |
| 233 | }, | |
| 234 | }, | |
| 198 | 235 | // Items entering each stage from issue to live, one row per entry, read |
| 199 | 236 | // from issue, run and pull as `flow/throughput.ts` reads them: an issue |
| 200 | 237 | // opens, a builder first starts on it (a retry is not a second item), its |
test/boards-query.test.ts +47 −2
| @@ -321,7 +321,7 @@ describe("boards API", () => { | ||
| 321 | 321 | }); |
| 322 | 322 | |
| 323 | 323 | test("the security sources are admin only: a non-admin gets 403, a repo scope gets nothing", async () => { |
| 324 | for (const source of ["signins", "tokens", "adminActions"]) { | |
| 324 | for (const source of ["signins", "tokens", "adminActions", "reach"]) { | |
| 325 | 325 | expect((await q("bob", { source, aggregate: "count" })).status).toBe(403); |
| 326 | 326 | expect((await q("agent", { source, aggregate: "count" })).status).toBe(403); |
| 327 | 327 | expect((await q("admin", { source, aggregate: "count" })).status).toBe(200); |
| @@ -329,8 +329,53 @@ describe("boards API", () => { | ||
| 329 | 329 | } |
| 330 | 330 | }); |
| 331 | 331 | |
| 332 | test("reach: seeded vitals and sessions give the expected p75, p95 and counts, to an admin only", async () => { | |
| 333 | const ins = app.db.prepare( | |
| 334 | "INSERT INTO page_vital (day, at, route, visitor, ttfb, lcp, inp, cls) VALUES (?, 0, ?, ?, ?, ?, ?, 0)", | |
| 335 | ); | |
| 336 | // /x on day 1: lcp 100..400, ttfb 10..40, one null inp; two visitors; /y has one view | |
| 337 | [100, 200, 300, 400].forEach((v, i) => ins.run("2026-10-01", "/x", i < 2 ? "a" : "b", v / 10, v, i === 0 ? null : v)); | |
| 338 | ins.run("2026-10-01", "/y", "c", 5, 50, 60); | |
| 339 | ins.run("2026-10-02", "/x", "a", 7, 70, 80); | |
| 340 | const users = app.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 2").all(); | |
| 341 | const sess = app.db.prepare( | |
| 342 | "INSERT INTO session (token_hash, user_id, expires_at, last_seen_at) VALUES (?, ?, '2999-01-01T00:00:00.000Z', ?)", | |
| 343 | ); | |
| 344 | const now = new Date(); | |
| 345 | sess.run("h1", users[0]!.id, now.toISOString()); | |
| 346 | sess.run("h2", users[1]!.id, new Date(now.getTime() - 3 * 86400_000).toISOString()); | |
| 347 | sess.run("h3", users[1]!.id, new Date(now.getTime() - 30 * 86400_000).toISOString()); | |
| 348 | const total = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM user").get()!.n; | |
| 349 | ||
| 350 | const ask = async (metric: string, extra: object = {}) => { | |
| 351 | const res = await q("admin", { | |
| 352 | source: "reach", aggregate: "max", column: "value", | |
| 353 | filter: [{ column: "metric", op: "eq", value: metric }], ...extra, | |
| 354 | }); | |
| 355 | expect(res.status).toBe(200); | |
| 356 | return ((await res.json()) as { rows: { group: string | null; value: number }[] }).rows; | |
| 357 | }; | |
| 358 | expect(await ask("lcp_p75", { groupBy: "route", filter: [{ column: "metric", op: "eq", value: "lcp_p75" }, { column: "at", op: "lt", value: "2026-10-02" }] })) | |
| 359 | .toEqual([{ group: "/x", value: 300 }, { group: "/y", value: 50 }]); | |
| 360 | expect(await ask("inp_p75", { groupBy: "route", filter: [{ column: "metric", op: "eq", value: "inp_p75" }, { column: "route", op: "eq", value: "/x" }, { column: "at", op: "lt", value: "2026-10-02" }] })) | |
| 361 | .toEqual([{ group: "/x", value: 400 }]); | |
| 362 | expect((await ask("server_p50", { filter: [{ column: "metric", op: "eq", value: "server_p50" }, { column: "route", op: "eq", value: "/x" }, { column: "at", op: "lt", value: "2026-10-02" }] }))[0]!.value).toBe(20); | |
| 363 | expect((await ask("server_p95", { filter: [{ column: "metric", op: "eq", value: "server_p95" }, { column: "route", op: "eq", value: "/x" }, { column: "at", op: "lt", value: "2026-10-02" }] }))[0]!.value).toBe(40); | |
| 364 | expect(await ask("visitors", { groupBy: { bucket: "day" } })).toEqual([ | |
| 365 | { group: "2026-10-01T00:00:00Z", value: 3 }, | |
| 366 | { group: "2026-10-02T00:00:00Z", value: 1 }, | |
| 367 | ]); | |
| 368 | expect((await ask("accounts_active_1d"))[0]!.value).toBe(1); | |
| 369 | expect((await ask("accounts_active_7d"))[0]!.value).toBe(2); | |
| 370 | expect((await ask("accounts_total"))[0]!.value).toBe(total); | |
| 371 | ||
| 372 | const strangerQuery = { source: "reach", aggregate: "count" }; | |
| 373 | expect((await q("bob", strangerQuery)).status).toBe(403); | |
| 374 | expect((await q(null, strangerQuery)).status).toBe(401); | |
| 375 | }); | |
| 376 | ||
| 332 | 377 | test("the security sources list no secret column", () => { |
| 333 | for (const source of ["signins", "tokens", "adminActions"]) { | |
| 378 | for (const source of ["signins", "tokens", "adminActions", "reach"]) { | |
| 334 | 379 | for (const c of Object.keys(SOURCES[source]!.columns)) expect(c).not.toMatch(/hash|token|secret|payload|name/); |
| 335 | 380 | } |
| 336 | 381 | }); |
new above
offline