Public: anyone can read this; the crew can change it.
Every app made from the template measures its visitors and page speed from its first deploy #2792
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2714-every-app-made-from-the-template-measure → main 8 commits fork 12 threads · 12 unresolved
README.md +4
| @@ -232,6 +232,10 @@ form that mints one and shows its secret once; others see no section. | ||
| 232 | 232 | |
| 233 | 233 | Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, no dependency) that sends, once per page view with `sendBeacon`, the route's path, TTFB, load time, LCP, INP (the worst interaction) and CLS to `POST /_measure`. The route is same-origin only, validates every field, caps the body at 512 bytes and the rate per client, and answers 204. Reports land in `page_vital` (bun:sqlite) keyed by route *pattern*; no IP and no user agent string is stored. A unique visitor is a hash under a salt that rotates daily (yesterday's salt is deleted), rows are kept 90 days. Do-Not-Track and the `forge_measure=off` cookie send nothing, and the server stores nothing for them either. The CSP is unchanged: the script is nonce'd like the shell's. |
| 234 | 234 | |
| 235 | ### Every app's visitors | |
| 236 | ||
| 237 | 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. | |
| 238 | ||
| 235 | 239 | ## Live in the terminal |
| 236 | 240 | |
| 237 | 241 | `ssh` into the forge and you get the whole thing — repositories, code, issues, |
src/actions/apps.ts +12 −1
| @@ -12,6 +12,7 @@ import { mayView } from "../core/sealed.ts"; | ||
| 12 | 12 | import { requireVerified } from "./verify.ts"; |
| 13 | 13 | import { requireAppRoom } from "./app_limit.ts"; |
| 14 | 14 | import type { App } from "../app.ts"; |
| 15 | import { appVitals, type AppVitals } from "../apps/vitals.ts"; | |
| 15 | 16 | import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts"; |
| 16 | 17 | import { grantUrl, originFor } from "../apps/proxy.ts"; |
| 17 | 18 | import { scaffoldFiles } from "../apps/scaffold.ts"; |
| @@ -47,7 +48,7 @@ import { listAllAgents } from "./crew.ts"; | ||
| 47 | 48 | import { plan, planIssueBody, planningAvailable } from "../crew/plan.ts"; |
| 48 | 49 | import type { Issue } from "../core/issues.ts"; |
| 49 | 50 | import { getPull } from "../core/pulls.ts"; |
| 50 | import { canEnterApp, canWrite, getRepo, type Repo } from "../core/repos.ts"; | |
| 51 | import { canAdmin, canEnterApp, canWrite, getRepo, type Repo } from "../core/repos.ts"; | |
| 51 | 52 | import type { Run } from "../core/runs.ts"; |
| 52 | 53 | import { getUserByName, type User } from "../core/users.ts"; |
| 53 | 54 | import { resolveOid } from "../git/merge.ts"; |
| @@ -480,6 +481,16 @@ export function appView( | ||
| 480 | 481 | }); |
| 481 | 482 | } |
| 482 | 483 | |
| 484 | /** | |
| 485 | * An app's visitors and speed, for those who administer its repository: the | |
| 486 | * same gate as the settings card, so every door shows them to the same people. | |
| 487 | */ | |
| 488 | export function appVisitors(app: App, actor: User | undefined, repo: Repo): AppVitals | undefined { | |
| 489 | if (!canAdmin(app.db, repo, actor)) return undefined; | |
| 490 | const record = getAppByRepo(app.db, repo.id); | |
| 491 | return record === undefined ? undefined : appVitals(app.db, record.id); | |
| 492 | } | |
| 493 | ||
| 483 | 494 | export type EnvError = "not_found" | "no_runtime" | "no_such_env"; |
| 484 | 495 | |
| 485 | 496 | /** The URL to open an environment at — via a grant for a private app. */ |
src/apps/measure.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * `POST /__forge/measure` on an app's own host: the door the template's | |
| 3 | * beacon posts to. The forge answers it, so the app holds no secret and | |
| 4 | * the report is filed against the app whose host it arrived at, never one | |
| 5 | * named in the body. Same-origin only, opted-out visitors stored nothing, | |
| 6 | * rate-limited per client and per app, a body of a few hundred bytes at | |
| 7 | * most, every field validated (`web/measure.ts`). Always an empty 204 or a | |
| 8 | * short refusal. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 13 | import { readBounded } from "../http/limits.ts"; | |
| 14 | import { clientAddress, clientBucket } from "../http/peer.ts"; | |
| 15 | import { optedOut, parseReport, REPORT_MAX } from "../web/measure.ts"; | |
| 16 | import type { AppTarget } from "./proxy.ts"; | |
| 17 | import { recordAppVital } from "./vitals.ts"; | |
| 18 | ||
| 19 | const PER_CLIENT = 30; | |
| 20 | const PER_APP = 600; | |
| 21 | ||
| 22 | const limiter = (perMinute: number) => | |
| 23 | new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true }); | |
| 24 | ||
| 25 | const limits = new WeakMap<App, { client: RateLimiter; apps: Map<string, RateLimiter> }>(); | |
| 26 | ||
| 27 | const refuse = (status: number, headers?: Record<string, string>): Response => | |
| 28 | new Response(null, { status, headers: { "cache-control": "no-store", ...headers } }); | |
| 29 | ||
| 30 | /** The page's own origin: the request's Origin names the host it was sent to. */ | |
| 31 | function sameOrigin(request: Request): boolean { | |
| 32 | const origin = request.headers.get("origin"); | |
| 33 | const host = request.headers.get("host"); | |
| 34 | if (origin === null || host === null) return false; | |
| 35 | try { | |
| 36 | return new URL(origin).host === host; | |
| 37 | } catch { | |
| 38 | return false; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export async function appMeasure( | |
| 43 | app: App, | |
| 44 | request: Request, | |
| 45 | target: AppTarget, | |
| 46 | ): Promise<Response> { | |
| 47 | if (request.method !== "POST") return refuse(405, { allow: "POST" }); | |
| 48 | if (!sameOrigin(request)) return refuse(403); | |
| 49 | if (optedOut(request)) return refuse(204); | |
| 50 | ||
| 51 | let pair = limits.get(app); | |
| 52 | if (pair === undefined) { | |
| 53 | pair = { client: limiter(PER_CLIENT), apps: new Map() }; | |
| 54 | limits.set(app, pair); | |
| 55 | } | |
| 56 | let forApp = pair.apps.get(target.app.id); | |
| 57 | if (forApp === undefined) { | |
| 58 | forApp = limiter(PER_APP); | |
| 59 | pair.apps.set(target.app.id, forApp); | |
| 60 | } | |
| 61 | for (const verdict of [ | |
| 62 | pair.client.take(`${target.app.id}:${clientBucket(request)}`, "ro"), | |
| 63 | forApp.take("app", "ro"), | |
| 64 | ]) { | |
| 65 | if (!verdict.allowed) | |
| 66 | return refuse(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) }); | |
| 67 | } | |
| 68 | ||
| 69 | // Previews are drafts and the card counts production only: store nothing. | |
| 70 | if (target.env !== "production") return refuse(204); | |
| 71 | ||
| 72 | const bytes = await readBounded(request, REPORT_MAX); | |
| 73 | if (bytes === "too_large") return refuse(413); | |
| 74 | const report = parseReport(new TextDecoder().decode(bytes)); | |
| 75 | if (report === "malformed") return refuse(400); | |
| 76 | recordAppVital( | |
| 77 | app.db, | |
| 78 | target.app.id, | |
| 79 | report, | |
| 80 | clientAddress(request), | |
| 81 | request.headers.get("user-agent") ?? "", | |
| 82 | ); | |
| 83 | return refuse(204); | |
| 84 | } |
src/apps/proxy.ts +11
| @@ -35,6 +35,7 @@ import { loadKey } from "../kernel/secret.ts"; | ||
| 35 | 35 | import { previewEnv, type EnvName } from "./data.ts"; |
| 36 | 36 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 37 | 37 | import { crewNow, tellLink } from "./crew.ts"; |
| 38 | import { appMeasure } from "./measure.ts"; | |
| 38 | 39 | import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts"; |
| 39 | 40 | |
| 40 | 41 | export interface AppTarget { |
| @@ -588,6 +589,16 @@ export async function proxyApp( | ||
| 588 | 589 | }); |
| 589 | 590 | } |
| 590 | 591 | } |
| 592 | // The beacon's report: filed against this app, never waking it. A private | |
| 593 | // app (or a sealed pull's preview) admits only the people it admits, so | |
| 594 | // it keeps its visitors to itself. | |
| 595 | if (url.pathname === "/__forge/measure" && !target.nested) { | |
| 596 | if (target.app.access === "private" || sealedPull(runtime, target) !== undefined) { | |
| 597 | const admitted = admit(runtime, request, target); | |
| 598 | if (!admitted.ok) return admitted.error; | |
| 599 | } | |
| 600 | return appMeasure(runtime.app, request, target); | |
| 601 | } | |
| 591 | 602 | // An environment asleep wakes for the request that wants it, and the |
| 592 | 603 | // request waits the second that takes rather than seeing a closed door. |
| 593 | 604 | if (runtime.envStatus(target.app, target.env)?.state === "asleep") |
src/apps/scaffold.ts +10
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | |
| 15 | 15 | import { readFileSync } from "node:fs"; |
| 16 | 16 | import type { FileWrite } from "../git/write.ts"; |
| 17 | import { beaconJs } from "../web/beacon.ts"; | |
| 17 | 18 | |
| 18 | 19 | /** |
| 19 | 20 | * The evaluator an app decides with is the forge's own, carried as text: |
| @@ -25,6 +26,12 @@ const CAN_TS = readFileSync( | ||
| 25 | 26 | "utf8", |
| 26 | 27 | ); |
| 27 | 28 | |
| 29 | /** | |
| 30 | * The forge's own page-view beacon, posting to the door the forge proxy | |
| 31 | * answers on the app's host: the app holds no secret and keeps no report. | |
| 32 | */ | |
| 33 | const BEACON_JS = beaconJs.replace("/_measure", "/__forge/measure") + "\n"; | |
| 34 | ||
| 28 | 35 | export interface ScaffoldInput { |
| 29 | 36 | /** The repository name; also the title on the page. */ |
| 30 | 37 | readonly name: string; |
| @@ -69,6 +76,7 @@ export function scaffoldFiles(input: ScaffoldInput): FileWrite[] { | ||
| 69 | 76 | { path: "scripts/contract-emit.ts", content: CONTRACT_EMIT_TS }, |
| 70 | 77 | { path: "static/app.css", content: APP_CSS }, |
| 71 | 78 | { path: "static/live.js", content: LIVE_JS }, |
| 79 | { path: "static/beacon.js", content: BEACON_JS }, | |
| 72 | 80 | { path: "src/who.test.ts", content: WHO_TEST }, |
| 73 | 81 | { path: "src/signin.test.ts", content: SIGNIN_TEST }, |
| 74 | 82 | { path: "src/db.test.ts", content: DB_TEST }, |
| @@ -167,6 +175,7 @@ decide nothing themselves. \`AGENTS.md\` says how to add to it. | ||
| 167 | 175 | | \`src/tty.ts\` | the terminal, over the same actions | |
| 168 | 176 | | \`src/env.ts\` | what the forge told this process | |
| 169 | 177 | | \`static/app.css\`, \`static/live.js\` | the look in the tokens' words, and the list that stays current | |
| 178 | | \`static/beacon.js\` | reports each page's load and INP to the forge, which counts visitors: no secret, no address kept | | |
| 170 | 179 | |
outdated
Two table rows for static/beacon.js, with different wording and misaligned columns. Delete one; every app made from the template would ship this duplicate in its README. |
||
| 171 | 180 | ## What every app stands on |
| 172 | 181 | |
| @@ -1702,6 +1711,7 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 1702 | 1711 | <meta name="twitter:description" content="\${escape(description)}"> |
| 1703 | 1712 | <link rel="stylesheet" href="\${tokensHref}"> |
| 1704 | 1713 | <link rel="stylesheet" href="\${asset("app.css")}"> |
| 1714 | <script defer src="\${asset("beacon.js")}"></script> | |
| 1705 | 1715 | <script type="module" src="\${asset("live.js")}"></script> |
outdated
A classic script without defer blocks the first paint. Use |
||
| 1706 | 1716 | <body> |
| 1707 | 1717 | <div class="page"> |
src/apps/vitals.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * What an app's visitors report about it, kept against that app. The | |
| 3 | * report is the forge's own (`web/measure.ts`): same validation, same daily | |
| 4 | * salt, no address stored. Only the two numbers the app's page shows are | |
| 5 | * kept: load and INP, for 90 days. A day keeps at most DAY_MAX rows per app. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { sha256 } from "../kernel/hash.ts"; | |
| 10 | import { dayOf, DAY_MAX, saltFor, type Report } from "../web/measure.ts"; | |
| 11 | ||
| 12 | const KEEP_MS = 90 * 24 * 60 * 60 * 1000; | |
| 13 | ||
| 14 | export function recordAppVital( | |
| 15 | db: Database, | |
| 16 | appId: string, | |
| 17 | report: Report, | |
| 18 | client: string, | |
| 19 | agent: string, | |
| 20 | at: number = Date.now(), | |
| 21 | dayMax: number = DAY_MAX, | |
| 22 | ): boolean { | |
| 23 | const day = dayOf(at); | |
| 24 | db.run("DELETE FROM app_vital WHERE at < ?", [at - KEEP_MS]); | |
| 25 | const held = db | |
| 26 | .query<{ n: number }, [string, string]>( | |
outdated
This DELETE runs on every report and has no index on |
||
| 27 | "SELECT COUNT(*) AS n FROM app_vital WHERE app_id = ? AND day = ?", | |
| 28 | ) | |
| 29 | .get(appId, day); | |
| 30 | if ((held?.n ?? 0) >= dayMax) return false; | |
| 31 | const visitor = sha256(`${saltFor(db, at)}\0${appId}\0${client}\0${agent}`).slice(0, 32); | |
| 32 | db.run( | |
outdated
High: nothing ever deletes from |
||
| 33 | "INSERT INTO app_vital (app_id, day, at, visitor, load, inp) VALUES (?, ?, ?, ?, ?, ?)", | |
| 34 | [appId, day, at, visitor, report.load, report.inp], | |
| 35 | ); | |
| 36 | return true; | |
| 37 | } | |
| 38 | ||
| 39 | export interface AppVitals { | |
| 40 | /** Distinct visitors today. */ | |
| 41 | readonly today: number; | |
| 42 | /** The sum of each of the last 7 days' distinct visitors (a visitor cannot be followed across days). */ | |
| 43 | readonly week: number; | |
| 44 | /** 75th percentile page load, ms, over 7 days; undefined with no data. */ | |
| 45 | readonly loadP75: number | undefined; | |
| 46 | readonly inpP75: number | undefined; | |
| 47 | } | |
| 48 | ||
| 49 | const DAY = 24 * 60 * 60 * 1000; | |
| 50 | ||
| 51 | function p75(db: Database, appId: string, column: "load" | "inp", since: string): number | undefined { | |
| 52 | const n = | |
| 53 | db | |
| 54 | .query<{ n: number }, [string, string]>( | |
| 55 | `SELECT COUNT(${column}) AS n FROM app_vital WHERE app_id = ? AND day >= ?`, | |
| 56 | ) | |
| 57 | .get(appId, since)?.n ?? 0; | |
| 58 | if (n === 0) return undefined; | |
| 59 | return db | |
| 60 | .query<{ v: number }, [string, string, number]>( | |
| 61 | `SELECT ${column} AS v FROM app_vital WHERE app_id = ? AND day >= ? AND ${column} IS NOT NULL ORDER BY ${column} LIMIT 1 OFFSET ?`, | |
| 62 | ) | |
| 63 | .get(appId, since, Math.ceil(0.75 * n) - 1)?.v; | |
| 64 | } | |
| 65 | ||
| 66 | /** Production's visitors and speed (a preview's report is never stored). */ | |
| 67 | export function appVitals(db: Database, appId: string, at: number = Date.now()): AppVitals { | |
| 68 | const since = dayOf(at - 6 * DAY); | |
| 69 | const count = (from: string): number => | |
outdated
Previews are stored but never read, and the card counts production only. Either skip storing non-production rows or show them; as it stands they are dead data. Also add a test that a pull-N row is excluded from appVitals. |
||
| 70 | db | |
| 71 | .query<{ n: number }, [string, string]>( | |
| 72 | "SELECT COUNT(*) AS n FROM (SELECT DISTINCT day, visitor FROM app_vital WHERE app_id = ? AND day >= ?)", | |
| 73 | ) | |
| 74 | .get(appId, from)?.n ?? 0; | |
| 75 | return { | |
| 76 | today: count(dayOf(at)), | |
| 77 | week: count(since), | |
| 78 | loadP75: p75(db, appId, "load", since), | |
| 79 | inpP75: p75(db, appId, "inp", since), | |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 83 | /** A duration as a person reads it: "420 ms", "1.3 s", or a dash with no data. */ | |
| 84 | export const speedText = (v: number | undefined): string => | |
| 85 | v === undefined ? "–" : v < 1000 ? `${Math.round(v)} ms` : `${(v / 1000).toFixed(1)} s`; | |
| 86 | ||
| 87 | /** The one line the terminal shows. */ | |
| 88 | export const visitorsLine = (v: AppVitals): string => | |
| 89 | `visitors ${v.today} today, ${v.week} in 7 days · p75 load ${speedText(v.loadP75)} · p75 INP ${speedText(v.inpP75)}`; |
src/core/apps.ts +1
| @@ -219,5 +219,6 @@ export function updateApp( | ||
| 219 | 219 | } |
| 220 | 220 | |
| 221 | 221 | export function deleteApp(db: Database, appId: string): boolean { |
| 222 | db.run("DELETE FROM app_vital WHERE app_id = ?1", [appId]); | |
| 222 | 223 | return db.query("DELETE FROM app WHERE id = ?1").run(appId).changes > 0; |
| 223 | 224 | } |
src/core/migrations/20261019160000-app-vitals.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | // Every app measures its own visitors and page speed from its first deploy | |
| 2 | // (#2714). The same report the forge's own pages send (`page_vital`), kept | |
| 3 | // apart and filed under the app it came through, so the forge's numbers | |
| 4 | // and an app's never mix. `visitor` is hashed under the day's rotating salt | |
| 5 | // and the app's id; no address is kept. A row is dropped after 90 days (as `page_vital`'s are) | |
outdated
This header says what is kept but not for how long. |
||
| 6 | // and with its app. Production only: a preview's report is answered and dropped. | |
| 7 | export default ` | |
| 8 | CREATE TABLE app_vital ( | |
| 9 | id INTEGER PRIMARY KEY, | |
| 10 | app_id TEXT NOT NULL, | |
| 11 | day TEXT NOT NULL, | |
| 12 | at INTEGER NOT NULL, | |
| 13 | visitor TEXT NOT NULL, | |
| 14 | load REAL, | |
| 15 | inp REAL | |
| 16 | ); | |
| 17 | CREATE INDEX app_vital_app_day ON app_vital (app_id, day); | |
| 18 | CREATE INDEX app_vital_at ON app_vital (at); | |
outdated
Add |
||
| 19 | `; |
src/http/api.ts +6 −3
| @@ -246,6 +246,7 @@ import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; | ||
| 246 | 246 | import { listBranches } from "../git/branches.ts"; |
| 247 | 247 | import { |
| 248 | 248 | appView, |
| 249 | appVisitors, | |
| 249 | 250 | deleteAppEnvVar, |
| 250 | 251 | disableApp, |
| 251 | 252 | enableApp, |
| @@ -4936,9 +4937,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4936 | 4937 | if (!repo) return notFound(); |
| 4937 | 4938 | const view = appView(ctx.app, ctx.auth.user, repo); |
| 4938 | 4939 | if (!view.ok) return notFound(); |
| 4939 | return json( | |
| 4940 | appJson(ctx, view.value.app, repo, appsRuntime(ctx.app)), | |
| 4941 | ); | |
| 4940 | const vitals = appVisitors(ctx.app, ctx.auth.user, repo); | |
| 4941 | return json({ | |
| 4942 | ...appJson(ctx, view.value.app, repo, appsRuntime(ctx.app)), | |
| 4943 | ...(vitals === undefined ? {} : { vitals }), | |
| 4944 | }); | |
| 4942 | 4945 | }); |
| 4943 | 4946 | |
| 4944 | 4947 | router.add("POST", "/api/v1/repos/:owner/:name/app", async (ctx) => { |
src/tui/screens/deploys.ts +8 −1
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | * joined to `deploy_pull` in one grouped read for `carries #3 #5`. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appVisitors } from "../../actions/apps.ts"; | |
| 11 | import { visitorsLine } from "../../apps/vitals.ts"; | |
| 10 | 12 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 11 | 13 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| 12 | 14 | import type { Repo } from "../../core/repos.ts"; |
| @@ -37,6 +39,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 37 | 39 | const list = new RunList(f); |
| 38 | 40 | let carries = new Map<string, number[]>(); |
| 39 | 41 | let loaded = false; |
| 42 | let visitors = ""; | |
| 40 | 43 | |
| 41 | 44 | const read = (): void => { |
| 42 | 45 | const runs = readDeploys(f, repo); |
| @@ -45,6 +48,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 45 | 48 | f.db, |
| 46 | 49 | runs.map((r) => r.id), |
| 47 | 50 | ); |
| 51 | const seen = appVisitors(f.app, f.user, repo); | |
| 52 | visitors = seen === undefined ? "" : visitorsLine(seen); | |
| 48 | 53 | loaded = true; |
| 49 | 54 | ctx.invalidate(); |
| 50 | 55 | }; |
| @@ -61,7 +66,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 61 | 66 | load: async () => read(), |
| 62 | 67 | render(area, s) { |
| 63 | 68 | const c = ctx.theme.c; |
| 64 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 69 | const [head, body] = split(area, "v", [len(visitors === "" ? 1 : 2), fill(1)]); | |
| 65 | 70 | s.text( |
| 66 | 71 | head!.x, |
| 67 | 72 | head!.y, |
| @@ -69,6 +74,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 69 | 74 | style(c.mutedFg, c.bg), |
| 70 | 75 | head!.x + head!.w, |
| 71 | 76 | ); |
| 77 | if (visitors !== "") | |
| 78 | s.text(head!.x, head!.y + 1, truncate(visitors, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 72 | 79 | if (list.runs.length === 0) { |
| 73 | 80 | emptyState(s, body!, c, { |
| 74 | 81 | title: loaded ? "No deploys" : "Reading…", |
src/web/measure.ts +2 −2
| @@ -87,10 +87,10 @@ export function optedOut(request: Request): boolean { | ||
| 87 | 87 | return cookie.split(";").some((pair) => pair.trim() === "forge_measure=off"); |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10); | |
| 90 | export const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10); | |
| 91 | 91 | |
| 92 | 92 | /** Today's salt, made on first use; every other day's is deleted. */ |
| 93 | function saltFor(db: Database, at: number): string { | |
| 93 | export function saltFor(db: Database, at: number): string { | |
| 94 | 94 | const day = dayOf(at); |
| 95 | 95 | const found = db |
| 96 | 96 | .query<{ salt: string }, [string]>("SELECT salt FROM vital_salt WHERE day = ?") |
src/web/pages/settings.ts +20
| @@ -51,6 +51,7 @@ import { | ||
| 51 | 51 | updateRepoSettings, |
| 52 | 52 | } from "../../actions/repos.ts"; |
| 53 | 53 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 54 | import { appVitals, speedText as ms, type AppVitals } from "../../apps/vitals.ts"; | |
| 54 | 55 | import type { PreviewData } from "../../apps/data.ts"; |
| 55 | 56 | import { |
| 56 | 57 | getAppByRepo, |
| @@ -571,6 +572,7 @@ function appSettingsView( | ||
| 571 | 572 | const domains = listAppDomains(ctx.app, ctx.auth.user, repo); |
| 572 | 573 | return html`${form} |
| 573 | 574 | ${when(listed?.ok === true, () => html`<div class="mt">${appMembersView(base, values.access, named)}</div>`)} |
| 575 | <div class="mt">${appVisitorsView(appVitals(ctx.app.db, record.id))}</div> | |
The card is web-only. Show the same four numbers in the terminal's app view too, or state in the README why this is read-only on the page. The README also says nothing about where visitors and speed are seen. |
||
| 574 | 576 | <div class="mt"> |
| 575 | 577 | ${appEnvView(base, listAppEnvNames(ctx.app.db, record.id))} |
| 576 | 578 | </div> |
| @@ -609,6 +611,24 @@ const SCOPE_WORDS: Record<AppEnvScope, string> = { | ||
| 609 | 611 | preview: "previews", |
| 610 | 612 | }; |
| 611 | 613 | |
| 614 | /** Production's visitors and the speed they saw, from the app's own beacon. */ | |
| 615 | function appVisitorsView(v: AppVitals): Html { | |
| 616 | return card( | |
| 617 | html`<p class="muted small mb"> | |
| 618 | Counted by the beacon the template ships, through the forge: no | |
| 619 | address is kept, and a visitor is the same only within a day, so the | |
| 620 | 7 day figure adds the days up. | |
| 621 | </p> | |
| 622 | <dl class="stats"> | |
| 623 | <div><dt>Visitors today</dt><dd>${v.today}</dd></div> | |
| 624 | <div><dt>Visitors, 7 days</dt><dd>${v.week}</dd></div> | |
| 625 | <div><dt>p75 load</dt><dd>${ms(v.loadP75)}</dd></div> | |
| 626 | <div><dt>p75 INP</dt><dd>${ms(v.inpP75)}</dd></div> | |
| 627 | </dl>`, | |
| 628 | { head: html`${icon("rocket")}<h2>Visitors and speed</h2>` }, | |
| 629 | ); | |
| 630 | } | |
| 631 | ||
| 612 | 632 | function appEnvView(base: string, names: AppEnvName[]): Html { |
| 613 | 633 | const rows = names.map((v) => |
| 614 | 634 | row({ |
test/apps-measure.test.ts added +247
| @@ -0,0 +1,247 @@ | ||
| 1 | /** | |
| 2 | * Every app measures its visitors and page speed from its first deploy | |
| 3 | * (#2714): the template ships the forge's beacon, the forge answers | |
| 4 | * `/__forge/measure` on the app's host, and a report is stored against that | |
| 5 | * app and no other. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 15 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 16 | import { appVisitors } from "../src/actions/apps.ts"; | |
| 17 | import { createToken } from "../src/core/tokens.ts"; | |
| 18 | import { getRepo } from "../src/core/repos.ts"; | |
| 19 | import { appVitals, recordAppVital } from "../src/apps/vitals.ts"; | |
| 20 | import { deleteApp, getAppByRepo } from "../src/core/apps.ts"; | |
| 21 | import { createUser } from "../src/core/users.ts"; | |
| 22 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | const DOMAIN = "apps.test"; | |
| 28 | ||
| 29 | test("the template links the beacon and the beacon posts to the forge's door", () => { | |
| 30 | const files = new Map(scaffoldFiles({ name: "n", description: "d" }).map((f) => [f.path, f.content])); | |
| 31 | expect(files.get("static/beacon.js")).toContain('"/__forge/measure"'); | |
| 32 | expect(files.get("static/beacon.js")).not.toContain('"/_measure"'); | |
| 33 | expect(files.get("src/page.ts")).toContain('asset("beacon.js")'); | |
| 34 | }); | |
| 35 | ||
| 36 | describe("a report through the proxy", () => { | |
| 37 | let dataDir: string; | |
| 38 | let app: App; | |
| 39 | let servers: Servers; | |
| 40 | let runtime: AppsRuntime; | |
| 41 | const ids: Record<string, string> = {}; | |
| 42 | ||
| 43 | beforeAll(async () => { | |
| 44 | dataDir = await mkdtemp(join(tmpdir(), "forge-app-measure-")); | |
| 45 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 46 | servers = await serve(app); | |
| 47 | runtime = new AppsRuntime(app, { | |
| 48 | domain: DOMAIN, | |
| 49 | scheme: "http", | |
| 50 | publicPort: servers.port, | |
| 51 | forgeUrl: servers.url, | |
| 52 | concurrency: 1, | |
| 53 | }); | |
| 54 | await runtime.start(); | |
| 55 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 56 | username: "alice", | |
| 57 | email: "[email protected]", | |
| 58 | password: "a-good-passphrase", | |
| 59 | adminIfFirst: true, | |
| 60 | }); | |
| 61 | if (!a.ok) throw new Error(a.error); | |
| 62 | for (const name of ["notes", "diary", "busy", "wide", "secret"]) { | |
| 63 | const made = await newApp(app, a.value, { | |
| 64 | name, | |
| 65 | description: "A place for words", | |
| 66 | visibility: name === "secret" ? "private" : "public", | |
| 67 | }); | |
| 68 | if (!made.ok) throw new Error(String(made.error)); | |
| 69 | ids[name] = getAppByRepo(app.db, made.value.repo.id)!.id; | |
| 70 | } | |
| 71 | }, 120_000); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | await runtime.stop(); | |
| 75 | servers.stop(); | |
| 76 | app.close(); | |
| 77 | await rm(dataDir, { recursive: true, force: true }); | |
| 78 | }); | |
| 79 | ||
| 80 | const report = (slug: string, body: unknown, headers: Record<string, string> = {}) => | |
| 81 | fetch(`${servers.url}/__forge/measure`, { | |
| 82 | method: "POST", | |
| 83 | body: typeof body === "string" ? body : JSON.stringify(body), | |
| 84 | headers: { | |
| 85 | host: `${slug}.${DOMAIN}`, | |
| 86 | origin: `http://${slug}.${DOMAIN}`, | |
| 87 | "user-agent": "test", | |
| 88 | ...headers, | |
| 89 | }, | |
| 90 | }); | |
| 91 | const good = { path: "/", ttfb: 40, load: 800, lcp: 600, inp: 90, cls: 0 }; | |
| 92 | ||
| 93 | test("is stored against that app and no other, and the app's page counts it", async () => { | |
| 94 | expect((await report("notes", good)).status).toBe(204); | |
| 95 | const rows = app.db | |
| 96 | .query<{ app_id: string }, []>("SELECT app_id FROM app_vital") | |
| 97 | .all(); | |
| 98 | expect(rows.map((r) => r.app_id)).toEqual([ids["notes"]!]); | |
| 99 | expect(appVitals(app.db, ids["notes"]!)).toMatchObject({ | |
| 100 | today: 1, | |
| 101 | week: 1, | |
| 102 | loadP75: 800, | |
| 103 | inpP75: 90, | |
| 104 | }); | |
| 105 | expect(appVitals(app.db, ids["diary"]!)).toMatchObject({ today: 0, week: 0 }); | |
| 106 | // The body cannot name another app. | |
| 107 | expect((await report("notes", { ...good, app: ids["diary"] })).status).toBe(400); | |
| 108 | }); | |
| 109 | ||
| 110 | test("keeps no address and refuses a foreign origin, a bad body and an opted-out visitor", async () => { | |
| 111 | expect((await report("notes", good, { origin: "http://evil.test" })).status).toBe(403); | |
| 112 | expect((await report("notes", "nope")).status).toBe(400); | |
| 113 | expect((await report("notes", { ...good, load: -1 })).status).toBe(400); | |
| 114 | const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n; | |
| 115 | expect((await report("notes", good, { dnt: "1" })).status).toBe(204); | |
| 116 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n).toBe(before); | |
| 117 | const columns = app.db.query<{ name: string }, []>("PRAGMA table_info(app_vital)").all(); | |
outdated
Missing tests. (a) A private app or sealed preview refuses an anonymous report. (b) The 31st report from one client returns 429. (c) recordAppVital stops at dayMax. (d) An oversized body returns 413. (e) The p75 of several rows is right, and a preview row is not counted. Each is behaviour this change adds and none is covered. |
||
| 118 | expect(columns.map((c) => c.name).join()).not.toMatch(/ip|addr|agent/); | |
| 119 | }); | |
outdated
|
||
| 120 | ||
| 121 | test("a private app refuses an anonymous report and stores nothing", async () => { | |
| 122 | const res = await report("secret", good); | |
| 123 | expect(res.status).toBe(404); | |
| 124 | expect( | |
| 125 | app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM app_vital WHERE app_id = ?").get(ids["secret"]!)!.n, | |
| 126 | ).toBe(0); | |
| 127 | }); | |
| 128 | ||
| 129 | test("the 31st report from one client is a 429", async () => { | |
| 130 | const statuses: number[] = []; | |
| 131 | for (let i = 0; i < 31; i++) statuses.push((await report("busy", good)).status); | |
| 132 | expect(statuses.slice(0, 30).every((s) => s === 204)).toBe(true); | |
| 133 | expect(statuses[30]).toBe(429); | |
| 134 | }); | |
| 135 | ||
| 136 | test("the 601st report to one app in a minute is a 429, whoever sends it", async () => { | |
| 137 | let last = 0; | |
| 138 | for (let i = 0; i < 601; i++) | |
| 139 | last = (await report("wide", good, { "x-forwarded-for": `10.${i >> 8}.${i & 255}.1` })).status; | |
| 140 | expect(last).toBe(429); | |
| 141 | }, 60_000); | |
| 142 | ||
| 143 | test("the numbers are the admin's alone, on the API as on the page", async () => { | |
| 144 | const owner = (await import("../src/core/users.ts")).getUserByName(app.db, "alice")!; | |
| 145 | const repo = getRepo(app.db, "alice", "notes")!; | |
| 146 | expect(appVisitors(app, undefined, repo)).toBeUndefined(); | |
| 147 | const bob = await createUser(app.db, { origin: "seed", trusted: true, | |
| 148 | username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 149 | if (!bob.ok) throw new Error(bob.error); | |
| 150 | expect(appVisitors(app, bob.value, repo)).toBeUndefined(); | |
| 151 | expect(appVisitors(app, owner, repo)).toMatchObject({ today: 1 }); | |
| 152 | const get = (token?: string) => | |
| 153 | fetch(`${servers.url}/api/v1/repos/alice/notes/app`, { | |
| 154 | headers: token ? { authorization: `Bearer ${token}` } : {}, | |
| 155 | }); | |
| 156 | const anon = await get(); | |
| 157 | expect(anon.status).toBe(200); | |
| 158 | expect(await anon.json()).not.toHaveProperty("vitals"); | |
| 159 | const asBob = await get(createToken(app.db, bob.value.id, "r", "ro").token); | |
| 160 | expect(await asBob.json()).not.toHaveProperty("vitals"); | |
| 161 | const asAlice = await get(createToken(app.db, owner.id, "r", "ro").token); | |
| 162 | expect(await asAlice.json()).toMatchObject({ vitals: { today: 1 } }); | |
| 163 | }); | |
| 164 | ||
| 165 | test("a sealed pull's preview refuses an anonymous report and stores nothing", async () => { | |
| 166 | const repo = getRepo(app.db, "alice", "diary")!; | |
| 167 | const alice = (await import("../src/core/users.ts")).getUserByName(app.db, "alice")!; | |
| 168 | app.db | |
| 169 | .query( | |
| 170 | `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, sealed) | |
| 171 | VALUES ('sealed1', ?1, 99, 'pull', 't', '', ?2, 'open', 1)`, | |
| 172 | ) | |
| 173 | .run(repo.id, alice.id); | |
| 174 | app.db | |
| 175 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('sealed1', 'main', 'x', 'a', 'b')") | |
| 176 | .run(); | |
| 177 | const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n; | |
| 178 | expect((await report("diary--99", good)).status).toBe(404); | |
| 179 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n).toBe(before); | |
| 180 | }); | |
| 181 | ||
| 182 | test("a body over the cap is a 413", async () => { | |
| 183 | const res = await report("diary", JSON.stringify({ ...good, path: "/" + "a".repeat(600) })); | |
| 184 | expect(res.status).toBe(413); | |
| 185 | }); | |
| 186 | ||
| 187 | test("a preview's report is accepted but not stored", async () => { | |
| 188 | const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n; | |
| 189 | expect((await report("diary--7", good)).status).toBe(204); | |
| 190 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n).toBe(before); | |
| 191 | }); | |
| 192 | }); | |
| 193 | ||
| 194 | describe("what is kept", () => { | |
| 195 | const rows = (db: App["db"]) => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n; | |
| 196 | let dataDir: string; | |
| 197 | let app: App; | |
| 198 | ||
| 199 | beforeAll(async () => { | |
| 200 | dataDir = await mkdtemp(join(tmpdir(), "forge-app-vitals-")); | |
| 201 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 202 | }); | |
| 203 | afterAll(async () => { | |
| 204 | app.close(); | |
| 205 | await rm(dataDir, { recursive: true, force: true }); | |
| 206 | }); | |
| 207 | ||
| 208 | const rep = (load: number) => ({ path: "/", ttfb: null, load, lcp: null, inp: null, cls: null }) as never; | |
| 209 | ||
| 210 | test("a day stops at dayMax rows per app", () => { | |
| 211 | const at = Date.UTC(2026, 0, 5, 12); | |
| 212 | expect(recordAppVital(app.db, "a1", rep(1), "c", "u", at, 2)).toBe(true); | |
| 213 | expect(recordAppVital(app.db, "a1", rep(1), "d", "u", at, 2)).toBe(true); | |
| 214 | expect(recordAppVital(app.db, "a1", rep(1), "e", "u", at, 2)).toBe(false); | |
| 215 | expect(recordAppVital(app.db, "a2", rep(1), "e", "u", at, 2)).toBe(true); | |
| 216 | expect(rows(app.db)).toBe(3); | |
| 217 | }); | |
| 218 | ||
| 219 | test("p75 is right over several rows", () => { | |
| 220 | const at = Date.UTC(2026, 0, 6, 12); | |
| 221 | for (const [i, load] of [100, 200, 300, 400].entries()) | |
| 222 | recordAppVital(app.db, "p", rep(load), `c${i}`, "u", at); | |
| 223 | const v = appVitals(app.db, "p", at); | |
| 224 | expect(v.loadP75).toBe(300); | |
| 225 | expect(v.today).toBe(4); | |
| 226 | expect(v.week).toBe(4); | |
| 227 | }); | |
| 228 | ||
| 229 | test("the prune and the per-app reads use an index", () => { | |
| 230 | const plan = (sql: string) => | |
| 231 | app.db.query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`).all().map((r) => r.detail).join("\n"); | |
| 232 | expect(plan("DELETE FROM app_vital WHERE at < 1")).toContain("app_vital_at"); | |
| 233 | expect(plan("SELECT COUNT(*) FROM app_vital WHERE app_id = 'a' AND day = 'd'")).toContain("app_vital_app_day"); | |
| 234 | }); | |
| 235 | ||
| 236 | test("a 91-day-old row is gone after the next record, and deleting the app removes its rows", () => { | |
| 237 | const now = Date.UTC(2026, 5, 1, 12); | |
| 238 | recordAppVital(app.db, "old", rep(1), "c", "u", now - 91 * 86_400_000); | |
| 239 | recordAppVital(app.db, "old", rep(2), "d", "u", now - 89 * 86_400_000); | |
| 240 | recordAppVital(app.db, "old", rep(3), "e", "u", now); | |
| 241 | const n = (id: string) => | |
| 242 | app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM app_vital WHERE app_id = ?").get(id)!.n; | |
| 243 | expect(n("old")).toBe(2); | |
| 244 | deleteApp(app.db, "old"); | |
| 245 | expect(n("old")).toBe(0); | |
| 246 | }); | |
| 247 | }); |
test/tui-deploys.test.ts +20
| @@ -7,6 +7,8 @@ import { join } from "node:path"; | ||
| 7 | 7 | import { createRepo } from "../src/actions/repos.ts"; |
| 8 | 8 | import { signUp } from "../src/actions/users.ts"; |
| 9 | 9 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 10 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 11 | import { recordAppVital } from "../src/apps/vitals.ts"; | |
| 10 | 12 | import { createRun } from "../src/core/runs.ts"; |
| 11 | 13 | import type { Repo } from "../src/core/repos.ts"; |
| 12 | 14 | import type { User } from "../src/core/users.ts"; |
| @@ -121,3 +123,21 @@ test("a non-reader sees nothing of a private repo's deploys", async () => { | ||
| 121 | 123 | } |
| 122 | 124 | expect(await screenText(alice, r.value)).toContain("#77"); |
| 123 | 125 | }); |
| 126 | ||
| 127 | test("an admin sees the app's visitors and speed above the deploys, and a reader does not", async () => { | |
| 128 | const r = await createRepo(forge, alice, { name: "shop", visibility: "public" }); | |
| 129 | if (!r.ok) throw new Error("repo failed"); | |
| 130 | const made = createAppRecord(forge.db, { repoId: r.value.id, slug: "shop", access: "public" }); | |
| 131 | if (!made.ok) throw new Error(made.error); | |
| 132 | const record = made.value; | |
| 133 | const at = Date.now(); | |
| 134 | recordAppVital(forge.db, record.id, { path: "/", ttfb: null, load: 420, lcp: null, inp: 80, cls: 0 }, "c", "u", at); | |
| 135 | expect(await screenText(alice, r.value)).toContain("visitors 1 today, 1 in 7 days · p75 load 420 ms · p75 INP 80 ms"); | |
| 136 | const c = await signUp(forge, { | |
| 137 | username: "carol", | |
| 138 | email: "[email protected]", | |
| 139 | password: "a-long-passphrase", | |
| 140 | }, { slot: fixtureSlot(forge) }); | |
| 141 | if (!c.ok) throw new Error("signup failed"); | |
| 142 | expect(await screenText(c.value, r.value)).not.toContain("visitors"); | |
| 143 | }); |
"The terminal has no screen for them: it reads the same JSON through the API." Both doors means
src/tui. Add the numbers to the app's terminal screen (deploys.tsis the natural place), then change this sentence to say how to reach them over ssh.