Public: anyone can read this; the crew can change it.

Visitors, users and page speed are a /boards source #2787

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2712-visitors-users-and-page-speed-are-a-boar → main 1 commit fork

  • Merged. scout/2712-visitors-users-and-page-speed-are-a-boar was merged into main as 856272f.

Review

Sign in to review this change.
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
16401640 are known by name in `src/http/refusal.ts`.) The terminal's toasts follow the
16411641 same rule. A guard installed by hand without the prefix answers `refused`
16421642 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 = {
3333 docs?: Readonly<Record<string, string>>;
3434 };
3535
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
3646 export const SOURCES: Readonly<Record<string, Source>> = {
3747 runs: {
3848 table: "run",
@@ -195,6 +205,33 @@ export const SOURCES: Readonly<Record<string, Source>> = {
195205 celsius_max: "°C, the highest 15-s point in the step",
196206 },
197207 },
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 },
198235 // Items entering each stage from issue to live, one row per entry, read
199236 // from issue, run and pull as `flow/throughput.ts` reads them: an issue
200237 // 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", () => {
321321 });
322322
323323 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"]) {
325325 expect((await q("bob", { source, aggregate: "count" })).status).toBe(403);
326326 expect((await q("agent", { source, aggregate: "count" })).status).toBe(403);
327327 expect((await q("admin", { source, aggregate: "count" })).status).toBe(200);
@@ -329,8 +329,53 @@ describe("boards API", () => {
329329 }
330330 });
331331
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
332377 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"]) {
334379 for (const c of Object.keys(SOURCES[source]!.columns)) expect(c).not.toMatch(/hash|token|secret|payload|name/);
335380 }
336381 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs