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

See where people come from and where they go: referrers, campaigns and a traffic path #9529

Merged opened this in answer to

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

Merged

scout/forge:scout/9427-see-where-people-come-from-and-where-the → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/9427-see-where-people-come-from-and-where-the was merged into main as c2eefd3.

Review

Sign in to review this change.
29 files changed +1764 −6
AGENTS.md +1 −1
@@ -14,4 +14,4 @@ A new read of a table that grows (`event`, `issue`, `run`, `comment`) names the
1414
1515 A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`.
1616
17 State shared between modules through process memory, not the database, is named here. `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue.
17 State shared between modules through process memory, not the database, is named here. `src/perf/views_report.ts`: `readVisits` holds each scope and window's visits for 15 seconds (dropped by `flushViews`), so a page and its probe share one read; `src/perf/views_store.ts`: the view buffer, flushed every 10 seconds. The traffic sources view's terminal door is `src/tui/screens/trafficview.ts` (palette entry for admins, per-app command for a repository's admins). `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue.
docs/design/audit.md +1
@@ -137,6 +137,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
137137 - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen.
138138 - `/settings/security` — `pages/security.ts` · the Shield and the evidence an admin needs to act with no AI · `/settings/traffic`, `/settings/backups` · none seen.
139139 - `/settings/traffic` — `pages/traffic.ts` · who used the answering time in the last 15 minutes · `/settings/usage`, `/settings/telemetry` · none seen.
140 - `/settings/traffic/sources` — `pages/sources.ts` · where people come from and where they go: sources, campaigns, landing pages, exits, a path to a goal, GitHub traffic; each number links to its probe · `/settings/traffic`, `/settings/traffic/scrapes` · none seen.
140141 - `/settings/traffic/scrapes` — `pages/scrapes.ts` · what was read by repository and path, which networks and addresses did it, a bounded capture, the abuse export · `/settings/traffic`, `/settings/security` · none seen.
141142
142143 ## Operator views — why each is a page (#2696)
docs/features/traffic-sources.md added +60
@@ -0,0 +1,60 @@
1 # Where people come from and where they go
2
3 One view for the forge (`/settings/traffic/sources`, admin) and one per app
4 (`/:owner/:name/settings/app/traffic`, whoever administers the repository).
5
6 **What is recorded.** The page-view beacon (`src/web/beacon.ts`, on every forge
7 page and in every app template) also sends `document.referrer`, the query string
8 and the host of the last external link clicked. The server keeps only the
9 referrer's *host*, `utm_source`/`utm_medium`/`utm_campaign` (lowercase, a safe
10 alphabet, 64 characters), and a clicked host that is in the named set
11 (`github.com`, `espodev.com`; `FORGE_OUTBOUND_LINKS=host,host` replaces it).
12 No cookie, no address, no third party; the visitor is the existing daily-salted
13 hash. Forge pages are filed by route pattern, never by a path that could name a
14 private repository.
15
16 **How it is counted.** A visit is one visitor's views in one scope on one day.
17 Source, medium and campaign come from its first view (`utm_source` wins over the
18 referring site; a referrer on the same host is navigation, not a source), the
19 landing page is its first path, the exit its last. Scrapers (crawler, headless
20 browser, HTTP library or no user agent) are stored flagged and shown apart.
21
22 **Cheap under load.** A beacon request pushes one row onto an in-memory buffer
23 (bounded at 5,000) and answers; a timer writes the buffer every 10 s in one
24 transaction (`src/perf/views_store.ts`). A day keeps at most 50,000 rows per
25 scope; rows are kept 90 days. The view reads one indexed range.
26
27 **Filtering and the path.** Clicking a source, medium, campaign, landing page or
28 exit narrows every panel. "A path to a goal" takes steps such as
29 `/, /explore, out:github.com`, in order, and shows visits and drop-off at each.
30
31 **Probe.** Every number links to `GET /api/v1/admin/traffic/probe`
32 (`scope=forge|<app id>`, `window`, `who`, `number`, or `dimension`+`key`, or
33 `steps`), which counts it again in SQL by a different route and says whether the
34 two agree.
35
36 **GitHub.** With `FORGE_GITHUB_TOKEN` (push access to read traffic) and
37 `FORGE_GITHUB_REPOS=owner/name,…` the forge pulls referrers, views and clones
38 once a day into `github_traffic`; GitHub keeps only 14 days. api.github.com is in
39 `forge.json` egress.
40
41 **Cannot see.** Visitors with scripts off or Do-Not-Track, hidden referrers,
42 GitHub's own pages (hence the pull), apps whose `static/beacon.js` predates these
43 fields (new apps get the new one; copy it from the template to update an old one),
44 and an outbound click when the browser leaves before it can send.
45
46 **Forged clicks.** An outbound click (`out`) is reported by the visitor's browser,
47 so anyone can post `out=github.com` and inflate the outbound panel or an `out:`
48 goal step. Counting clicks without server-side redirects cannot avoid that; the
49 page says so. Treat outbound figures as a hint.
50
51 **Caps.** A visitor leaves at most 300 views per scope per day (app paths come
52 from the client), so one client cannot fill the 50,000-row day with invented
53 paths. A read builds its visits once per scope and window and holds them for 15
54 seconds, dropped when views are written, so a page and its probe calls share it.
55
56 **Terminal.** Palette "Traffic sources" (`src/tui/screens/trafficview.ts`) is the
57 forge's view for an admin; with a repository open, "<owner>/<name> — Traffic
58 sources" is that app's view for those who administer it. A guest is offered
59 neither. `w` window, `s` people/scrapers, Enter narrows, `c` clears, `t` types
60 the path steps. It does not call the probe; use the web page for that.
forge.json +2 −1
@@ -1,6 +1,7 @@
11 {
22 "egress": [
3 "api.anthropic.com"
3 "api.anthropic.com",
4 "api.github.com"
45 ],
56 "app": {
67 "looks": ["/", "/front", "/explore", "/board", "/notes", "/feed", "/review", "/new", "/audit", "/releases", "/apps", "/repos", "/map", "/system", "/talk", "/flow", "/resilience", "/settings", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"],
src/apps/measure.ts +9
@@ -14,6 +14,8 @@ import { readBounded } from "../http/limits.ts";
1414 import { clientAddress, clientBucket } from "../http/peer.ts";
1515 import { optedOut, parseReport, REPORT_MAX } from "../web/measure.ts";
1616 import type { AppTarget } from "./proxy.ts";
17 import { originOf } from "../perf/sources.ts";
18 import { noteView } from "../perf/views_store.ts";
1719 import { recordAppVital } from "./vitals.ts";
1820
1921 const PER_CLIENT = 30;
@@ -80,5 +82,12 @@ export async function appMeasure(
8082 clientAddress(request),
8183 request.headers.get("user-agent") ?? "",
8284 );
85 noteView(app.db, {
86 scope: target.app.id,
87 path: report.path.split(/[?#]/)[0] ?? "/",
88 origin: originOf(report.ref, report.q, request.headers.get("host") ?? "", report.out),

The path is client-supplied here. Normalise it, or cap the distinct paths per visitor, so one client cannot fill the 50,000-row day cap.

89 client: clientAddress(request),
90 agent: request.headers.get("user-agent") ?? "",
91 });
8392 return refuse(204);
8493 }
src/contract/openapi/routes/getApiV1AdminTrafficProbe.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/traffic/probe",
6 summary:
7 "Read the traffic view's numbers (where people come from) against their source (a person admin's token, any scope); query: scope (forge, or an app id), window (7d, 30d, 90d), who (people, scrapers), number (one id), dimension (source, medium, campaign, landing, exit) with key, steps (comma-separated path steps ending in a goal); answers { as_of, scope, window, numbers: [{ id, label, value, counts, inputs, recomputed, agrees }] }: recomputed counts the stored page views again in SQL",
8 auth: true,
9 refusals: { not_found: "the token is not a person who administers the forge, or the scope names no app" },
10 };
src/contract/openapi/routes/index.ts +1
@@ -48,6 +48,7 @@ export { route as getApiV1AdminSettings } from "./getApiV1AdminSettings.ts";
4848 export { route as getApiV1AdminShield } from "./getApiV1AdminShield.ts";
4949 export { route as getApiV1AdminSubscriptions } from "./getApiV1AdminSubscriptions.ts";
5050 export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts";
51 export { route as getApiV1AdminTrafficProbe } from "./getApiV1AdminTrafficProbe.ts";
5152 export { route as getApiV1Agents } from "./getApiV1Agents.ts";
5253 export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts";
5354 export { route as getApiV1Apps } from "./getApiV1Apps.ts";
src/core/migration-pins/20261108150000-traffic-views.pin added +1
@@ -0,0 +1,1 @@
1 aae41113f6b4ebac5a90d65f98a50a58189d9c009948997fd407646d93e29db4
src/core/migrations/20261108150000-traffic-views.ts added +38
@@ -0,0 +1,38 @@
1 // Where people come from and where they go (#9427, `perf/views_store.ts`): one
2 // row per page view the beacon reports, for the forge (scope `forge`) or an app
3 // (scope = its id). `visitor` is the daily-salted hash; no address, cookie or
4 // user agent is kept. Read by scope and time, so the index leads with both.
5 // `github_traffic` is GitHub's own repository traffic, which it keeps 14 days,
6 // pulled daily; `github_pull` is when each repository was last pulled.
7 // New tables only. Additive.
8 export default `
9 CREATE TABLE traffic_view (
10 id INTEGER PRIMARY KEY,
11 scope TEXT NOT NULL,
12 day TEXT NOT NULL,
13 at INTEGER NOT NULL,
14 visitor TEXT NOT NULL,
15 path TEXT NOT NULL,
16 ref_host TEXT NOT NULL DEFAULT '',
17 source TEXT NOT NULL DEFAULT '',
18 medium TEXT NOT NULL DEFAULT '',
19 campaign TEXT NOT NULL DEFAULT '',
20 out_host TEXT NOT NULL DEFAULT '',
21 scraper INTEGER NOT NULL DEFAULT 0
22 );
23 CREATE INDEX traffic_view_scope_at ON traffic_view (scope, at);
24 CREATE TABLE github_traffic (
25 repo TEXT NOT NULL,
26 day TEXT NOT NULL,
27 kind TEXT NOT NULL CHECK (kind IN ('views', 'clones', 'referrers')),
28 key TEXT NOT NULL,
29 count INTEGER NOT NULL,
30 uniques INTEGER NOT NULL,
31 PRIMARY KEY (repo, day, kind, key)
32 ) WITHOUT ROWID;
33 CREATE TABLE github_pull (
34 repo TEXT PRIMARY KEY,
35 pulled_at INTEGER NOT NULL,
36 error TEXT
37 ) WITHOUT ROWID;
38 `;
src/http/api/admin.ts +20
@@ -7,6 +7,8 @@
77
88
99 import { crawlReport } from "../../perf/crawlers.ts";
10 import { getAppById } from "../../core/apps.ts";
11 import { probeViews } from "../../perf/views_probe.ts";
1012 import { kpiProbe } from "../../web/studio/kpi_probe.ts";
1113 import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts";
1214 import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts";
@@ -147,6 +149,24 @@ export function adminRoutes(router: Router<Handler>): void {
147149 return json(kpiProbe(ctx.app));
148150 });
149151
152 // The traffic view's numbers, each counted again another way (`perf/views_probe.ts`).
153 // `scope` is `forge` or an app's id; `number`, `dimension`+`key` and `steps` pick what to show.
154 router.add("GET", "/api/v1/admin/traffic/probe", (ctx) => {
155 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
156 const denied = accountAdmin(ctx, "ro");
157 if (denied) return denied;
158 const q = ctx.url.searchParams;
159 const scope = q.get("scope") ?? "forge";
160 if (scope !== "forge" && getAppById(ctx.app.db, scope) === undefined) return notFound();
161 const probed = probeViews(ctx.app.db, scope, q.get("window"), q.get("who") === "scrapers" ? "scrapers" : "people", Date.now(), {
162 dimension: q.get("dimension"),
163 key: q.get("key"),
164 steps: q.get("steps"),
165 });
166 const only = q.get("number");
167 return json(only === null ? probed : { ...probed, numbers: probed.numbers.filter((n) => n.id === only) });
168 });
169
150170 // The forge's capacity (`core/capacity.ts`): read and changed live by an
151171 // admin, never by an agent; anyone else is told the door is not there.
152172 router.add("GET", "/api/v1/admin/capacity", (ctx) => {
src/http/measure.ts +10
@@ -15,6 +15,8 @@ import { optedOut, parseReport, recordVital, REPORT_MAX } from "../web/measure.t
1515 import type { Ctx } from "./context.ts";
1616 import { readBounded } from "./limits.ts";
1717 import { clientAddress, clientBucket } from "./peer.ts";
18 import { originOf } from "../perf/sources.ts";
19 import { noteView } from "../perf/views_store.ts";
1820
1921 const PER_CLIENT = 30;
2022 const PER_FORGE = 1200;
@@ -63,5 +65,13 @@ export async function measureSubmit(ctx: Ctx, routeOf: RouteOf): Promise<Respons
6365 clientAddress(request),
6466 request.headers.get("user-agent") ?? "",
6567 );
68 noteView(app.db, {
69 scope: "forge",
70 // The route pattern, never the path: a path can name a private repository.
71 path: route,
72 origin: originOf(report.ref, report.q, ctx.url.host, report.out),
73 client: clientAddress(request),
74 agent: request.headers.get("user-agent") ?? "",
75 });
6676 return refuse(204);
6777 }
src/http/server.ts +9
@@ -461,6 +461,9 @@ import {
461461 import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts";
462462 import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts";
463463 import { createTrafficWriter } from "../perf/traffic_store.ts";
464 import { startGithubPull } from "../perf/github_traffic.ts";
465 import { appSourcesPage } from "../web/pages/sources.ts";
466 import { createViewWriter } from "../perf/views_store.ts";
464467 import { createReadWriter, readOf } from "../perf/reads.ts";
465468 import { parseServerTiming } from "../perf/record.ts";
466469 import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts";
@@ -1370,6 +1373,7 @@ export function buildRouter(): Router<Handler> {
13701373 proposalTokenRevoke,
13711374 );
13721375 router.add("GET", "/:owner/:name/settings/app", appSettingsPage);
1376 router.add("GET", "/:owner/:name/settings/app/traffic", appSourcesPage);
13731377 router.add("GET", "/:owner/:name/settings/machines", machinesSettingsPage);
13741378 router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate);
13751379 router.add("POST", "/:owner/:name/settings/app/disable", appDisable);
@@ -2250,6 +2254,9 @@ export async function serve(app: App): Promise<Servers> {
22502254 const timings = new Map<Database, RouteTiming>();
22512255 // The minute ledger's history: one write a minute, never on a request.
22522256 const trafficWriter = createTrafficWriter(app.db, perfRecord.ledger, app.log);
2257 // Page views are buffered by the beacon doors and written here; GitHub's traffic is pulled daily when a token is set.
2258 const viewWriter = createViewWriter(app.db, app.log);
2259 const githubPull = startGithubPull(app.db, app.log);
22532260 const readWriter = createReadWriter(app.db, readLedger, app.log);
22542261 const counted = (
22552262 owner: App,
@@ -2553,6 +2560,8 @@ export async function serve(app: App): Promise<Servers> {
25532560 }
25542561 timings.clear();
25552562 trafficWriter.stop();
2563 viewWriter.stop();
2564 githubPull?.stop();
25562565 readWriter.stop();
25572566 // A child's hooks stop answering before its database closes.
25582567 for (const key of [...new Set([...childHttps.keys(), ...childInternals.keys()])]) letGo(key);
src/http/settings_routes.ts +2
@@ -20,6 +20,7 @@ import { usagePage } from "../web/pages/usage.ts";
2020 import { spendPage } from "../web/pages/spend-tab.ts";
2121 import { securityPage, securityShieldSet } from "../web/pages/security.ts";
2222 import { trafficPage } from "../web/pages/traffic.ts";
23 import { sourcesPage } from "../web/pages/sources.ts";
2324 import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts";
2425 import {
2526 accountAct,
@@ -190,6 +191,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
190191 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
191192 router.add("GET", "/settings/spend", spendPage);
192193 router.add("GET", "/settings/traffic", trafficPage);
194 router.add("GET", "/settings/traffic/sources", sourcesPage);
193195 router.add("GET", "/settings/traffic/scrapes", scrapesPage);
194196 router.add("GET", "/settings/traffic/scrapes/export", scrapesExport);
195197 router.add("POST", "/settings/traffic/scrapes/capture", captureStart);
src/perf/github_traffic.ts added +200
@@ -0,0 +1,200 @@
1 /**
2 * GitHub's own repository traffic (referrers, views, clones), which GitHub keeps
3 * for 14 days, pulled once a day into the same store (`github_traffic`) so it
4 * outlives them. Off unless `FORGE_GITHUB_TOKEN` (a token that may read the
5 * repositories' traffic: push access) and `FORGE_GITHUB_REPOS`
6 * (`owner/name,owner/name`) are set. The token is read from the environment,
7 * sent only to api.github.com, and never stored or printed. Every answer is
8 * validated before it is written; a failure is recorded against the repository
9 * and the next day tries again.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { quietly } from "../core/write_clock.ts";
14
15 const API = "https://api.github.com";
16 const REPO = /^(?!\.+\/)[A-Za-z0-9_.-]{1,100}\/(?!\.+$)[A-Za-z0-9_.-]{1,100}$/;
17 const PULL_EVERY_MS = 23 * 3_600_000;
18 const CHECK_MS = 3_600_000;
19
20 export type Fetch = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<Response>;
21
22 export interface GithubConfig {
23 readonly token: string;
24 readonly repos: readonly string[];
25 }
26
27 export function githubConfig(env: Record<string, string | undefined> = process.env): GithubConfig | null {
28 const token = env["FORGE_GITHUB_TOKEN"]?.trim() ?? "";
29 const repos = (env["FORGE_GITHUB_REPOS"] ?? "").split(",").map((r) => r.trim()).filter((r) => REPO.test(r));
30 return token === "" || repos.length === 0 ? null : { token, repos };
31 }
32
33 interface Entry {
34 readonly day: string;
35 readonly kind: "views" | "clones" | "referrers";
36 readonly key: string;
37 readonly count: number;
38 readonly uniques: number;
39 }
40
41 const whole = (n: unknown): n is number => typeof n === "number" && Number.isInteger(n) && n >= 0 && n < 1e9;
42 const arrayOf = (o: unknown, key: string): unknown[] | null => {
43 const v = typeof o === "object" && o !== null ? (o as Record<string, unknown>)[key] : undefined;
44 return Array.isArray(v) ? v : null;
45 };
46
47 /** GitHub's three answers as rows, or why not. Referrers are a 14-day total: filed under the day of the pull. */
48 export function entriesOf(
49 kind: Entry["kind"],
50 answer: unknown,
51 today: string,
52 ): Entry[] | "malformed" {
53 const list = kind === "referrers" ? (Array.isArray(answer) ? answer : null) : arrayOf(answer, kind);
54 if (list === null) return "malformed";
55 const out: Entry[] = [];
56 for (const item of list.slice(0, 200)) {
57 const r = item as Record<string, unknown>;
58 if (typeof item !== "object" || item === null || !whole(r["count"]) || !whole(r["uniques"])) return "malformed";
59 if (kind === "referrers") {
60 const key = r["referrer"];
61 if (typeof key !== "string" || key.length === 0 || key.length > 100 || !/^[A-Za-z0-9._ -]+$/.test(key)) return "malformed";
62 out.push({ day: today, kind, key, count: r["count"], uniques: r["uniques"] });
63 } else {
64 const stamp = r["timestamp"];
65 if (typeof stamp !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(stamp)) return "malformed";
66 out.push({ day: stamp.slice(0, 10), kind, key: "", count: r["count"], uniques: r["uniques"] });
67 }
68 }
69 return out;
70 }
71
72 /** Pull every configured repository; returns how many were stored. Never throws. */
73 export async function pullGithub(
74 db: Database,
75 config: GithubConfig,
76 fetcher: Fetch = (url, init) => fetch(url, init),
77 now: number = Date.now(),
78 ): Promise<number> {
79 const today = new Date(now).toISOString().slice(0, 10);
80 let stored = 0;
81 for (const repo of config.repos) {
82 let error: string | null = null;
83 const rows: Entry[] = [];
84 for (const [kind, path] of [["referrers", "popular/referrers"], ["views", "views"], ["clones", "clones"]] as const) {
85 try {
86 const signal = AbortSignal.timeout(15_000);
87 const res = await fetcher(`${API}/repos/${repo}/traffic/${path}`, {
88 headers: {
89 authorization: `Bearer ${config.token}`,
90 accept: "application/vnd.github+json",
91 "x-github-api-version": "2022-11-28",
92 "user-agent": "forge-traffic",
93 },
94 signal,
95 });
96 if (!res.ok) {
97 error = `${kind}: GitHub answered ${res.status}`;
98 break;
99 }
100 const entries = entriesOf(kind, await res.json(), today);
101 if (entries === "malformed") {
102 error = `${kind}: unexpected answer`;
103 break;
104 }
105 rows.push(...entries);
106 } catch (e) {
107 error = `${kind}: ${e instanceof Error ? e.name : "failed"}`;
108 break;
109 }
110 }
111 quietly(db, () =>
112 db.transaction(() => {
113 if (error === null) {
114 const put = db.query(
115 `INSERT INTO github_traffic (repo, day, kind, key, count, uniques) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
116 ON CONFLICT (repo, day, kind, key) DO UPDATE SET count = excluded.count, uniques = excluded.uniques`,
117 );
118 for (const r of rows) put.run(repo, r.day, r.kind, r.key, r.count, r.uniques);
119 stored += 1;
120 }
121 db.query(
122 `INSERT INTO github_pull (repo, pulled_at, error) VALUES (?1, ?2, ?3)
123 ON CONFLICT (repo) DO UPDATE SET pulled_at = excluded.pulled_at, error = excluded.error`,
124 ).run(repo, now, error);
125 })(),
126 );
127 }
128 return stored;
129 }
130
131 /** Whether any repository has not been pulled in the last day. */
132 export function pullDue(db: Database, config: GithubConfig, now: number): boolean {
133 const last = new Map(
134 db.query<{ repo: string; pulled_at: number }, []>("SELECT repo, pulled_at FROM github_pull").all().map((r) => [r.repo, r.pulled_at]),
135 );
136 return config.repos.some((r) => now - (last.get(r) ?? 0) >= PULL_EVERY_MS);
137 }
138
139 /** The daily pull, started by the server; null when no token or repository is set. */
140 export function startGithubPull(
141 db: Database,
142 log: { warn(message: string, fields?: Record<string, unknown>): void },
143 env: Record<string, string | undefined> = process.env,
144 fetcher?: Fetch,
145 ): { stop(): void } | null {
146 const config = githubConfig(env);
147 if (config === null) return null;
148 const run = (): void => {
149 if (!pullDue(db, config, Date.now())) return;
150 pullGithub(db, config, fetcher).catch((error) => log.warn("could not pull GitHub traffic", { error: String(error) }));
151 };
152 const first = setTimeout(run, 30_000);
153 const tick = setInterval(run, CHECK_MS);
154 first.unref();
155 tick.unref();
156 return {
157 stop() {
158 clearTimeout(first);
159 clearInterval(tick);
160 },
161 };
162 }
163
164 export interface GithubRepoSummary {
165 readonly repo: string;
166 readonly views: number;
167 readonly viewers: number;
168 readonly clones: number;
169 readonly cloners: number;
170 readonly referrers: readonly { key: string; count: number; uniques: number }[];
171 readonly pulledAt: number | null;
172 readonly error: string | null;
173 }
174
175 /** Each pulled repository's totals since `sinceDay`, and its latest referrers. */
176 export function githubSummary(db: Database, sinceDay: string): GithubRepoSummary[] {
177 const repos = db.query<{ repo: string; pulled_at: number; error: string | null }, []>("SELECT repo, pulled_at, error FROM github_pull ORDER BY repo").all();
178 return repos.map((p) => {
179 const sum = (kind: string): { count: number; uniques: number } =>
180 db
181 .query<{ count: number; uniques: number }, [string, string, string]>(
182 "SELECT COALESCE(SUM(count), 0) AS count, COALESCE(SUM(uniques), 0) AS uniques FROM github_traffic WHERE repo = ?1 AND kind = ?2 AND day >= ?3",
183 )
184 .get(p.repo, kind, sinceDay) ?? { count: 0, uniques: 0 };
185 const latest = db
186 .query<{ day: string }, [string]>("SELECT MAX(day) AS day FROM github_traffic WHERE repo = ?1 AND kind = 'referrers'")
187 .get(p.repo)?.day;
188 const referrers =
189 latest === undefined || latest === null
190 ? []
191 : db
192 .query<{ key: string; count: number; uniques: number }, [string, string]>(
193 "SELECT key, count, uniques FROM github_traffic WHERE repo = ?1 AND kind = 'referrers' AND day = ?2 ORDER BY count DESC, key LIMIT 10",
194 )
195 .all(p.repo, latest);
196 const v = sum("views");
197 const c = sum("clones");
198 return { repo: p.repo, views: v.count, viewers: v.uniques, clones: c.count, cloners: c.uniques, referrers, pulledAt: p.pulled_at, error: p.error };
199 });
200 }
src/perf/sources.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * What a page view says about where it came from, reduced to what may be kept:
3 * the referrer's host (never its path or query), the three `utm_` fields, and
4 * the host of an outbound link, kept only when it is in the named set. Plain
5 * strings in, plain data out; every value is bounded and restricted to a
6 * safe alphabet, so what is stored is also safe to print.
7 */
8
9 /** The links whose clicks are counted. A host matches itself and its subdomains. */
10 export const OUTBOUND_DEFAULT = ["github.com", "espodev.com"] as const;
11
12 /** The named set: the default, or `FORGE_OUTBOUND_LINKS` (comma-separated hosts) when it is set. */
13 export function outboundNames(env: Record<string, string | undefined> = process.env): readonly string[] {
14 const given = (env["FORGE_OUTBOUND_LINKS"] ?? "")
15 .split(",")
16 .map((h) => h.trim().toLowerCase())
17 .filter((h) => HOST.test(h));
18 return given.length > 0 ? given : OUTBOUND_DEFAULT;
19 }
20
21 const HOST = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
22 const TAG = /^[a-z0-9][a-z0-9._ -]{0,63}$/;
23 const HEADLESS = /headless|puppeteer|playwright|phantomjs|selenium|python-requests|curl\/|wget|go-http|okhttp|java\/|libwww|httpclient|axios|node-fetch|undici/i;
24 const CRAWLER = /bot\b|bot[/ ;)-]|crawl|spider|slurp|scrap|archiver|reflection|facebookexternalhit|bingpreview|ia_archiver/i;
25
26 /** A User-Agent that is a program, not a person: a crawler, a headless browser, an HTTP library, or none. */
27 export const isScraperAgent = (userAgent: string): boolean =>
28 userAgent.length === 0 || CRAWLER.test(userAgent.slice(0, 300)) || HEADLESS.test(userAgent.slice(0, 300));
29
30 /** A host as it may be kept, or "": lowercase, no `www.`, letters, digits, dots and hyphens. */
31 export function hostOf(url: string): string {
32 let parsed: URL;
33 try {
34 parsed = new URL(url);
35 } catch {
36 return "";
37 }
38 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
39 const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
40 return HOST.test(host) ? host : "";
41 }
42
43 const tag = (value: string | null): string => {
44 const v = (value ?? "").trim().toLowerCase();
45 return TAG.test(v) ? v : "";
46 };
47
48 /** The named link a clicked host belongs to, or "". */
49 export function outboundOf(host: string, names: readonly string[] = outboundNames()): string {
50 const h = host.toLowerCase().replace(/^www\./, "");
51 if (!HOST.test(h)) return "";
52 return names.find((n) => h === n || h.endsWith(`.${n}`)) ?? "";
53 }
54
55 export interface Origin {
56 readonly refHost: string;
57 readonly source: string;
58 readonly medium: string;
59 readonly campaign: string;
60 readonly out: string;
61 }
62
63 /**
64 * `referrer` is document.referrer, `search` is location.search, `own` the host
65 * the page was served from (a referrer from the same host is navigation inside
66 * the site, not a source), `clicked` the host of the link the visitor left by.
67 */
68 export function originOf(
69 referrer: string | undefined,
70 search: string | undefined,
71 own: string,
72 clicked: string | undefined,
73 names: readonly string[] = outboundNames(),
74 ): Origin {
75 const params = new URLSearchParams(search ?? "");
76 const ref = referrer ? hostOf(referrer) : "";
77 const external = ref !== "" && ref !== own.toLowerCase().replace(/^www\./, "").replace(/:\d+$/, "");
78 const utm = tag(params.get("utm_source"));
79 return {
80 refHost: external ? ref : "",
81 source: utm !== "" ? utm : external ? ref : "",
82 medium: tag(params.get("utm_medium")),
83 campaign: tag(params.get("utm_campaign")),
84 out: clicked ? outboundOf(clicked, names) : "",
85 };
86 }
src/perf/views_probe.ts added +126
@@ -0,0 +1,126 @@
1 /**
2 * The traffic view's numbers against their source, in the shape of the home
3 * probe (`web/studio/kpi_probe.ts`): what the page shows, one sentence of what
4 * it counts, its raw inputs, and the same figure recomputed by a different
5 * route (SQL over the stored views, where the page builds visits in memory).
6 * `agrees` false and staying false is a claim to look at.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import {
11 DIMENSIONS,
12 DIRECT,
13 funnel,
14 matches,
15 parseSteps,
16 readVisits,
17 sinceOf,
18 windowOfViews,
19 type Dimension,
20 type Filter,
21 type ViewWindow,
22 } from "./views_report.ts";
23
24 export interface ProbedView {
25 readonly id: string;
26 readonly label: string;
27 readonly value: number;
28 readonly counts: string;
29 readonly inputs: Readonly<Record<string, unknown>>;
30 readonly recomputed: number;
31 readonly agrees: boolean;
32 }
33
34 export const COUNTS = {
35 visits: "Distinct visitor hashes per day in this scope: one visit is one visitor's views on one day.",
36 page_views: "Page views the beacon reported; a visitor with scripts off reports none.",
37 outbound: "Page views that carried a click on a link in the named set, one per view.",
38 scrapers: "Visits whose user agent is a crawler, a headless browser, an HTTP library or empty, counted apart from people.",
39 cell: "Visits whose first view (source, medium, campaign, landing) or last view (exit) has this value.",
40 funnel: "Visits that did this step after every step before it, other pages allowed between.",
41 } as const;
42
43 const made = (id: string, label: string, counts: string, value: number, recomputed: number, inputs: Record<string, unknown>): ProbedView => ({
44 id, label, value, counts, inputs, recomputed, agrees: value === recomputed,
45 });
46
47 const COLUMN: Record<Dimension, { column: string; rank: "rn" | "rl" }> = {
48 source: { column: "source", rank: "rn" },
49 medium: { column: "medium", rank: "rn" },
50 campaign: { column: "campaign", rank: "rn" },
51 landing: { column: "path", rank: "rn" },
52 exit: { column: "path", rank: "rl" },
53 };
54
55 export function probeViews(
56 db: Database,
57 scope: string,
58 windowRaw: string | null,
59 who: "people" | "scrapers",
60 now: number,
61 ask: { dimension?: string | null; key?: string | null; steps?: string | null } = {},
62 ): { as_of: string; scope: string; window: ViewWindow; numbers: ProbedView[] } {
63 const window = windowOfViews(windowRaw);
64 const since = sinceOf(now, window);
65 const flag = who === "scrapers" ? 1 : 0;
66 const visits = readVisits(db, scope, since);
67 const mine = visits.filter((v) => matches(v, { who }));
68 const one = (sql: string, ...args: (string | number)[]): number =>
69 db.query<{ n: number }, (string | number)[]>(sql).get(...args)?.n ?? 0;
70 const numbers: ProbedView[] = [
71 made("visits", "Visits", COUNTS.visits, mine.length,
72 one("SELECT COUNT(*) AS n FROM (SELECT DISTINCT day, visitor FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = ?3)", scope, since, flag),
73 { scope, window, who }),
74 made("page_views", "Page views", COUNTS.page_views, mine.reduce((n, v) => n + v.pages, 0),
75 one("SELECT COUNT(*) AS n FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = ?3", scope, since, flag),
76 { scope, window, who }),
77 made("outbound", "Outbound clicks", COUNTS.outbound, mine.reduce((n, v) => n + v.outs.length, 0),
78 one("SELECT COUNT(*) AS n FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = ?3 AND out_host != ''", scope, since, flag),
79 { scope, window, who }),
80 made("scrapers", "Scraper visits", COUNTS.scrapers, visits.filter((v) => v.scraper).length,
81 one("SELECT COUNT(*) AS n FROM (SELECT DISTINCT day, visitor FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 1)", scope, since),
82 { scope, window }),
83 ];
84 const dimension = DIMENSIONS.find((d) => d === ask.dimension);
85 if (dimension !== undefined && typeof ask.key === "string") {
86 const key = ask.key === DIRECT && dimension === "source" ? "" : ask.key;
87 const filter: Filter = { who, [dimension]: key };
88 const { column, rank } = COLUMN[dimension];
89 numbers.push(
90 made("cell", `${dimension} ${ask.key}`, COUNTS.cell, visits.filter((v) => matches(v, filter)).length,
91 one(
92 `WITH h AS (SELECT ${column} AS c,
93 ROW_NUMBER() OVER (PARTITION BY day, visitor ORDER BY at, id) AS rn,
94 ROW_NUMBER() OVER (PARTITION BY day, visitor ORDER BY at DESC, id DESC) AS rl
95 FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = ?3)
96 SELECT COUNT(*) AS n FROM h WHERE ${rank} = 1 AND c = ?4`,
97 scope, since, flag, key,
98 ),
99 { scope, window, who, dimension, key }),
100 );
101 }
102 const steps = parseSteps(ask.steps ?? null);
103 if (steps.length > 0) {
104 const rows = funnel(db, scope, window, { who }, steps, now);
105 // The second route: for each visitor-day, walk the stored views in order.
106 const reached = new Map<string, number>();
107 const stored = db
108 .query<{ day: string; visitor: string; path: string; out_host: string }, [string, number, number]>(
109 "SELECT day, visitor, path, out_host FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = ?3 ORDER BY at, id",
110 )
111 .all(scope, since, flag);
112 const at = (e: string, s: string): boolean =>
113 s === "/" ? e === "/" : s.startsWith("out:") ? e === s : e === s || e.startsWith(s.endsWith("/") ? s : `${s}/`);
114 for (const r of stored) {
115 const k = `${r.day}\0${r.visitor}`;
116 let n = reached.get(k) ?? 0;
117 for (const e of r.out_host === "" ? [r.path] : [r.path, `out:${r.out_host}`]) if (n < steps.length && at(e, steps[n]!)) n += 1;
118 reached.set(k, n);
119 }
120 rows.forEach((row, i) => {
121 const again = i === 0 ? reached.size : [...reached.values()].filter((n) => n >= i).length;
122 numbers.push(made(`funnel_${i}`, row.step, COUNTS.funnel, row.visits, again, { scope, window, who, steps }));
123 });
124 }
125 return { as_of: new Date(now).toISOString(), scope, window, numbers };
126 }
src/perf/views_report.ts added +233
@@ -0,0 +1,233 @@
1 /**
2 * The traffic view's figures (#9427): visits made from the stored page views,
3 * narrowed by a filter, summarised into the panels, and a path from a source
4 * through named steps to a goal. Plain data in, plain data out.
5 *
6 * A visit is one visitor's views in one scope on one day: the visitor hash is
7 * salted daily, so a visitor cannot be followed past midnight, and a return in
8 * the same day is the same visit. Its source, medium and campaign are those of
9 * its first view; its landing page is the first path and its exit the last.
10 * Scrapers (`sources.ts` `isScraperAgent`) are counted apart and never in the
11 * people's figures.
12 */
13
14 import type { Database } from "bun:sqlite";
15
16 export const DAY_MS = 86_400_000;
17 export const WINDOWS = { "7d": 7, "30d": 30, "90d": 90 } as const;
18 export type ViewWindow = keyof typeof WINDOWS;
19 export const windowOfViews = (raw: string | null): ViewWindow => (raw === "30d" || raw === "90d" ? raw : "7d");
20 /** The most views one read looks at. */
21 export const READ_MAX = 200_000;
22
23 export const DIMENSIONS = ["source", "medium", "campaign", "landing", "exit"] as const;
24 export type Dimension = (typeof DIMENSIONS)[number];
25
26 export interface Filter {
27 readonly who: "people" | "scrapers";
28 readonly source?: string;
29 readonly medium?: string;
30 readonly campaign?: string;
31 readonly landing?: string;
32 readonly exit?: string;
33 }
34
35 export interface Visit {
36 readonly day: string;
37 readonly visitor: string;
38 readonly source: string;
39 readonly medium: string;
40 readonly campaign: string;
41 readonly landing: string;
42 readonly exit: string;
43 readonly pages: number;
44 /** Paths in order, each followed by `out:<host>` when that view carried a click. */
45 readonly events: readonly string[];
46 readonly outs: readonly string[];
47 readonly scraper: boolean;
48 }
49
50 interface ViewRow {
51 day: string;
52 visitor: string;
53 path: string;
54 source: string;
55 medium: string;
56 campaign: string;
57 out_host: string;
58 scraper: number;
59 }
60
61 export const sinceOf = (now: number, window: ViewWindow): number => now - WINDOWS[window] * DAY_MS;
62
63 const HELD_MS = 15_000;
64 const held = new WeakMap<Database, Map<string, { at: number; visits: Visit[] }>>();
65
outdated

This loads up to 200,000 rows into memory on every render and probe call. Aggregate in SQL, or cache the result briefly.

66 /** Forget the visits held for a short while; the store calls it after it writes. */
67 export const forgetVisits = (db: Database): void => void held.delete(db);
68
69 /**
70 * The visits of a scope since a time. A read builds up to `READ_MAX` rows, so
71 * the result is held for `HELD_MS` per scope and minute: a page and its probe
72 * calls share one read, and a write (`forgetVisits`) drops what is held.
73 */
74 export function readVisits(db: Database, scope: string, since: number): Visit[] {
75 const key = `${scope}\0${Math.floor(since / 60_000)}`;
76 let mine = held.get(db);
77 if (mine === undefined) held.set(db, (mine = new Map()));
78 const now = Date.now();
79 const hit = mine.get(key);
80 if (hit !== undefined && now - hit.at < HELD_MS) return hit.visits;
81 if (mine.size >= 64) mine.clear();
82 const visits = buildVisits(db, scope, since);
83 mine.set(key, { at: now, visits });
84 return visits;
85 }
86
87 function buildVisits(db: Database, scope: string, since: number): Visit[] {
88 const rows = db
89 .query<ViewRow, [string, number, number]>(
90 `SELECT day, visitor, path, source, medium, campaign, out_host, scraper
91 FROM traffic_view WHERE scope = ?1 AND at >= ?2 ORDER BY at, id LIMIT ?3`,
92 )
93 .all(scope, since, READ_MAX);
94 const made = new Map<string, { first: ViewRow; last: ViewRow; events: string[]; outs: string[]; pages: number }>();
95 for (const r of rows) {
96 const key = `${r.day}\0${r.visitor}\0${r.scraper}`;
97 let v = made.get(key);
98 if (v === undefined) {
99 v = { first: r, last: r, events: [], outs: [], pages: 0 };
100 made.set(key, v);
101 }
102 v.last = r;
103 v.pages += 1;
104 v.events.push(r.path);
105 if (r.out_host !== "") {
106 v.events.push(`out:${r.out_host}`);
107 v.outs.push(r.out_host);
108 }
109 }
110 return [...made.values()].map((v) => ({
111 day: v.first.day,
112 visitor: v.first.visitor,
113 source: v.first.source,
114 medium: v.first.medium,
115 campaign: v.first.campaign,
116 landing: v.first.path,
117 exit: v.last.path,
118 pages: v.pages,
119 events: v.events,
120 outs: v.outs,
121 scraper: v.first.scraper === 1,
122 }));
123 }
124
125 export function matches(v: Visit, f: Filter): boolean {
126 return (
127 v.scraper === (f.who === "scrapers") &&
128 (f.source === undefined || v.source === f.source) &&
129 (f.medium === undefined || v.medium === f.medium) &&
130 (f.campaign === undefined || v.campaign === f.campaign) &&
131 (f.landing === undefined || v.landing === f.landing) &&
132 (f.exit === undefined || v.exit === f.exit)
133 );
134 }
135
136 export interface Counted {
137 readonly key: string;
138 readonly visits: number;
139 }
140
141 function tally(visits: readonly Visit[], of: (v: Visit) => string, limit = 10): Counted[] {
142 const by = new Map<string, number>();
143 for (const v of visits) by.set(of(v), (by.get(of(v)) ?? 0) + 1);
144 return [...by]
145 .map(([key, n]) => ({ key, visits: n }))
146 .sort((a, b) => b.visits - a.visits || a.key.localeCompare(b.key))
147 .slice(0, limit);
148 }
149
150 export interface SourcesReport {
151 readonly visits: number;
152 readonly pageViews: number;
153 readonly pagesPerVisit: number;
154 /** The same window's scrapers, whatever the filter. */
155 readonly scrapers: number;
156 readonly people: number;
157 readonly days: readonly Counted[];
158 readonly sources: readonly Counted[];
159 readonly mediums: readonly Counted[];
160 readonly campaigns: readonly Counted[];
161 readonly landings: readonly Counted[];
162 readonly exits: readonly Counted[];
163 readonly outbound: readonly Counted[];
164 }
165
166 export const DIRECT = "(direct)";
167
168 export function sourcesReport(db: Database, scope: string, window: ViewWindow, filter: Filter, now: number): SourcesReport {
169 const all = readVisits(db, scope, sinceOf(now, window));
170 const mine = all.filter((v) => matches(v, filter));
171 const pageViews = mine.reduce((n, v) => n + v.pages, 0);
172 const days = Array.from({ length: WINDOWS[window] }, (_, i) =>
173 new Date(sinceOf(now, window) + (i + 1) * DAY_MS).toISOString().slice(0, 10),
174 );
175 const perDay = new Map(tally(mine, (v) => v.day, 1000).map((c) => [c.key, c.visits]));
176 const outs = new Map<string, number>();
177 for (const v of mine) for (const o of v.outs) outs.set(o, (outs.get(o) ?? 0) + 1);
178 return {
179 visits: mine.length,
180 pageViews,
181 pagesPerVisit: mine.length === 0 ? 0 : Math.round((pageViews / mine.length) * 10) / 10,
182 scrapers: all.filter((v) => v.scraper).length,
183 people: all.filter((v) => !v.scraper).length,
184 days: days.map((key) => ({ key, visits: perDay.get(key) ?? 0 })),
185 sources: tally(mine, (v) => v.source || DIRECT),
186 mediums: tally(mine.filter((v) => v.medium !== ""), (v) => v.medium),
187 campaigns: tally(mine.filter((v) => v.campaign !== ""), (v) => v.campaign),
188 landings: tally(mine, (v) => v.landing),
189 exits: tally(mine, (v) => v.exit),
190 outbound: [...outs].map(([key, visits]) => ({ key, visits })).sort((a, b) => b.visits - a.visits || a.key.localeCompare(b.key)),
191 };
192 }
193
194 export interface FunnelStep {
195 readonly step: string;
196 readonly visits: number;
197 /** Visits lost since the step before, and their share of it. */
198 readonly lost: number;
199 readonly lostShare: number;
200 }
201
202 export const STEPS_MAX = 6;
203 const STEP = /^(?:out:[a-z0-9.-]{1,253}|\/[A-Za-z0-9/_:.~%+-]{0,199})$/;
204
205 /** The steps a person typed: a path (a prefix, `/` meaning the front page only) or `out:<host>`. Invalid ones are dropped. */
206 export function parseSteps(raw: string | null): string[] {
207 return (raw ?? "")
208 .split(",")
209 .map((s) => s.trim())
210 .filter((s) => STEP.test(s))
211 .slice(0, STEPS_MAX);
212 }
213
214 const hit = (event: string, step: string): boolean =>
215 step === "/" ? event === "/" : step.startsWith("out:") ? event === step : event === step || event.startsWith(step.endsWith("/") ? step : `${step}/`);
216
217 /** Visits that did each step in order (other pages may come between), with drop-off. The first row is every visit the filter keeps. */
218 export function funnel(db: Database, scope: string, window: ViewWindow, filter: Filter, steps: readonly string[], now: number): FunnelStep[] {
219 const mine = readVisits(db, scope, sinceOf(now, window)).filter((v) => matches(v, filter));
220 const progress = mine.map((v) => {
221 let reached = 0;
222 for (const e of v.events) if (reached < steps.length && hit(e, steps[reached]!)) reached += 1;
223 return reached;
224 });
225 const rows: FunnelStep[] = [{ step: "From this source", visits: mine.length, lost: 0, lostShare: 0 }];
226 let before = mine.length;
227 steps.forEach((step, i) => {
228 const n = progress.filter((p) => p > i).length;
229 rows.push({ step, visits: n, lost: before - n, lostShare: before === 0 ? 0 : Math.round(((before - n) / before) * 100) });
230 before = n;
231 });
232 return rows;
233 }
src/perf/views_store.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * Page views, kept for the traffic view (`views_report.ts`). A request only
3 * pushes a plain row onto an in-memory buffer (`noteView`, no SQL); a timer
4 * writes the whole buffer in one transaction (`flushViews`), off the request
5 * path. The buffer is bounded, so a flood drops views rather than memory, and
6 * a day keeps at most `DAY_MAX` rows per scope. Rows are kept 90 days.
7 *
8 * The visitor is the existing daily-salted hash (`web/measure.ts` `saltFor`),
9 * with the scope; the salt is read once a day, not per view.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { quietly } from "../core/write_clock.ts";
14 import { sha256 } from "../kernel/hash.ts";
15 import { withoutWaiting } from "../kernel/db.ts";
16 import { dayOf, saltFor } from "../web/measure.ts";
17 import { forgetVisits } from "./views_report.ts";
18 import { isScraperAgent, type Origin } from "./sources.ts";
19
20 export const BUFFER_MAX = 5_000;
21 export const DAY_MAX = 50_000;
22 /** Views one visitor may leave in a scope in a day: one client cannot crowd the day out with invented paths. */
23 export const VISITOR_DAY_MAX = 300;
24 export const KEEP_DAYS = 90;
25 const FLUSH_MS = 10_000;
26
27 interface Row {
28 readonly scope: string;
29 readonly day: string;
30 readonly at: number;
31 readonly visitor: string;
32 readonly path: string;
33 readonly origin: Origin;
34 readonly scraper: boolean;
35 }
36
37 const buffers = new WeakMap<Database, Row[]>();
38 const salts = new WeakMap<Database, { day: string; salt: string }>();
39
40 function saltOf(db: Database, at: number): string {
41 const day = dayOf(at);
42 const held = salts.get(db);
43 if (held?.day === day) return held.salt;
44 const salt = saltFor(db, at);
45 salts.set(db, { day, salt });
46 return salt;
47 }
48
49 /** Push one view; false when the buffer is full and it was dropped. No SQL after the day's first. */
50 export function noteView(
51 db: Database,
52 view: { scope: string; path: string; origin: Origin; client: string; agent: string; at?: number },
53 ): boolean {
54 const at = view.at ?? Date.now();
55 let buffer = buffers.get(db);
56 if (buffer === undefined) {
57 buffer = [];
58 buffers.set(db, buffer);
59 }
60 if (buffer.length >= BUFFER_MAX) return false;
61 buffer.push({
62 scope: view.scope,
63 day: dayOf(at),
64 at,
65 visitor: sha256(`${saltOf(db, at)}\0views\0${view.scope}\0${view.client}\0${view.agent}`).slice(0, 32),
66 path: view.path.slice(0, 200),
67 origin: view.origin,
68 scraper: isScraperAgent(view.agent),
69 });
70 return true;
71 }
72
73 /** Views waiting to be written. */
74 export const pendingViews = (db: Database): number => buffers.get(db)?.length ?? 0;
75
76 /** Write what is buffered, in one transaction; on failure the rows stay for the next try. */
77 export function flushViews(db: Database, now: number = Date.now()): number {
78 const buffer = buffers.get(db);
79 if (buffer === undefined || buffer.length === 0) return 0;
80 const taken = buffer.slice();
81 withoutWaiting(
82 db,
83 db.transaction(() =>
84 quietly(db, () => {
85 const insert = db.query(
86 `INSERT INTO traffic_view (scope, day, at, visitor, path, ref_host, source, medium, campaign, out_host, scraper)
87 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`,
88 );
89 const held = new Map<string, number>();
90 const heldVisitor = new Map<string, number>();
91 const ofVisitor = db.query<{ n: number }, [string, string, number]>(
92 "SELECT COUNT(*) AS n FROM traffic_view WHERE scope = ?1 AND visitor = ?2 AND at >= ?3 AND at < ?3 + 86400000",
93 );
94 const count = db.query<{ n: number }, [string, number]>(
95 "SELECT COUNT(*) AS n FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND at < ?2 + 86400000",
96 );
97 for (const r of taken) {
98 const key = `${r.scope}\0${r.day}`;
99 if (!held.has(key)) held.set(key, count.get(r.scope, Date.parse(`${r.day}T00:00:00Z`))?.n ?? 0);
100 const n = held.get(key)!;
101 if (n >= DAY_MAX) continue;
102 const who = `${key}\0${r.visitor}`;
103 if (!heldVisitor.has(who)) heldVisitor.set(who, ofVisitor.get(r.scope, r.visitor, Date.parse(`${r.day}T00:00:00Z`))?.n ?? 0);
104 const mine = heldVisitor.get(who)!;
105 if (mine >= VISITOR_DAY_MAX) continue;
106 heldVisitor.set(who, mine + 1);
107 held.set(key, n + 1);
108 const o = r.origin;
109 insert.run(r.scope, r.day, r.at, r.visitor, r.path, o.refHost, o.source, o.medium, o.campaign, o.out, r.scraper ? 1 : 0);
110 }
111 db.query("DELETE FROM traffic_view WHERE at < ?1").run(now - KEEP_DAYS * 86_400_000);
112 }),
113 ),
114 );
115 buffer.splice(0, taken.length);
116 forgetVisits(db);
117 return taken.length;
118 }
119
120 /** The flush timer, for the server; `stop` writes what is left. */
121 export function createViewWriter(
122 db: Database,
123 log: { warn(message: string, fields?: Record<string, unknown>): void },
124 flushMs: number = FLUSH_MS,
125 ): { stop(): void } {
126 const run = (): void => {
127 try {
128 flushViews(db);
129 } catch (error) {
130 log.warn("could not write page views", { error: String(error) });
131 }
132 };
133 const tick = setInterval(run, flushMs);
134 tick.unref();
135 return {
136 stop() {
137 clearInterval(tick);
138 run();
139 },
140 };
141 }
src/tui/screens/entries.ts +2
@@ -22,6 +22,7 @@ import { entry as crewHandover } from "./crewhandover.ts";
2222 import { entry as crewSplits } from "./crewsplits.ts";
2323 import { entry as domains } from "./domains.ts";
2424 import { entry as edgeOnly } from "./edgeonly.ts";
25 import { entry as traffic } from "./trafficview.ts";
2526 import { entry as feed } from "./feed.ts";
2627 import { entry as forgeView } from "./forgeview.ts";
2728 import { entry as front } from "./front.ts";
@@ -81,6 +82,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
8182 valves,
8283 access,
8384 edgeOnly,
85 traffic,
8486 audit,
8587 secrets,
8688 crewSplits,
src/tui/screens/trafficview.ts added +182
@@ -0,0 +1,182 @@
1 /**
2 * Where people come from, in the terminal (#9427): the web page's reads
3 * (`web/pages/sources.ts`) from the same figures (`perf/views_report.ts`),
4 * gated the same way. A forge admin opens the forge's view from the palette;
5 * a repository's admin opens that app's view from the palette while the
6 * repository is open; anyone else is offered nothing and, were the screen
7 * reached, sees no rows.
8 *
9 * `w` changes the window, `s` flips people and scrapers, Enter narrows every
10 * panel to the value under the cursor, `c` clears the narrowing, `t` types
11 * the steps of a path to a goal. Read-only.
12 */
13
14 import { getAppByRepo } from "../../core/apps.ts";
15 import { canAdmin, type Repo } from "../../core/repos.ts";
16 import {
17 DIMENSIONS,
18 DIRECT,
19 WINDOWS,
20 funnel,
21 parseSteps,
22 sourcesReport,
23 windowOfViews,
24 type Counted,
25 type Dimension,
26 type Filter,
27 type ViewWindow,
28 } from "../../perf/views_report.ts";
29 import type { Ctx, Screen } from "../app/app.ts";
30 import type { Command } from "../app/palette.ts";
31 import { binding } from "../app/keys.ts";
32 import { style } from "../term/style.ts";
33 import { truncate } from "../term/width.ts";
34 import type { Session } from "../session.ts";
35 import { fill, len, split } from "../ui/layout.ts";
36 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
37 import type { PaletteEntry } from "./entries.ts";
38
39 /** The scope this viewer may read, or undefined: the forge for an admin, the app for its repository's admin. */
40 export function readableScope(f: Session, repo?: Repo): string | undefined {
41 if (f.user === undefined) return undefined;
42 if (repo === undefined) return f.user.isAdmin ? "forge" : undefined;
43 if (!canAdmin(f.db, repo, f.user)) return undefined;
44 return getAppByRepo(f.db, repo.id)?.id;
45 }
46
47 interface Line {
48 readonly row: ListRow;
49 readonly dimension?: Dimension;
50 readonly key?: string;
51 }
52
53 const PANELS: ReadonlyArray<[string, keyof ReturnType<typeof sourcesReport>, Dimension | null]> = [
54 ["Source", "sources", "source"],
55 ["Medium", "mediums", "medium"],
56 ["Campaign", "campaigns", "campaign"],
57 ["Landing", "landings", "landing"],
58 ["Left at", "exits", "exit"],
59 ["Outbound", "outbound", null],
60 ];
61
62 export function trafficLines(f: Session, scope: string, window: ViewWindow, filter: Filter, steps: readonly string[], now = Date.now()): Line[] {
63 const r = sourcesReport(f.db, scope, window, filter, now);
64 const out: Line[] = [
65 {
66 row: {
67 title: `${filter.who === "scrapers" ? "Scraper visits" : "Visits"}: ${r.visits} · page views ${r.pageViews} · ${r.pagesPerVisit} pages a visit`,
68 meta: `${WINDOWS[window]} days · ${filter.who === "scrapers" ? `${r.people} people` : `${r.scrapers} scrapers`} kept apart`,
69 },
70 },
71 ];
72 for (const [title, field, dimension] of PANELS) {
73 for (const c of r[field] as readonly Counted[])
74 out.push({
75 row: { title: `${title}: ${c.key}`, meta: `${c.visits}` },
76 ...(dimension === null ? {} : { dimension, key: c.key }),
77 });
78 }
79 if (steps.length > 0)
80 for (const s of funnel(f.db, scope, window, filter, steps, now))
81 out.push({ row: { title: `Path: ${s.step}`, meta: `${s.visits} visits · lost ${s.lost} (${s.lostShare}%)` } });
82 out.push({
83 row: {
84 title: "Not seen",
85 note: "Outbound clicks are reported by the visitor's browser and can be forged; scripts off, Do-Not-Track and hidden referrers are not counted.",
86 },
87 });
88 return out;
89 }
90
91 export function trafficScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
92 const state = new ListState();
93 let window: ViewWindow = "7d";
94 let who: Filter["who"] = "people";
95 let narrow: Partial<Record<Dimension, string>> = {};
96 let steps: string[] = [];
97 const scope = (): string | undefined => readableScope(f, repo);
98 const filter = (): Filter => ({ who, ...narrow });
99 const lines = (): Line[] => {
100 const s = scope();
101 return s === undefined ? [] : trafficLines(f, s, window, filter(), steps);
102 };
103 const screen: Screen = {
104 title: repo === undefined ? "Traffic sources" : `${repo.ownerName}/${repo.name} — Traffic sources`,
105 keys: [
106 binding(["j", "k"], "move cursor"),
107 binding(["w"], "window"),
108 binding(["s"], "people/scrapers"),
109 binding(["enter"], "narrow"),
110 binding(["c"], "clear"),
111 binding(["t"], "path steps"),
112 ],
113 render(area, s) {
114 const c = ctx.theme.c;
115 const [head, list] = split(area, "v", [len(1), fill(1)]);
116 const on = DIMENSIONS.filter((d) => narrow[d] !== undefined).map((d) => `${d}=${narrow[d] === "" ? DIRECT : narrow[d]}`);
117 s.text(head!.x, head!.y, truncate(`${WINDOWS[window]} days · ${who}${on.length > 0 ? ` · ${on.join(" ")}` : ""}${steps.length > 0 ? ` · path ${steps.join(",")}` : ""}`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
118 drawList(s, list!, c, state, lines().map((l) => l.row));
119 },
120 key(e) {
121 if (scope() === undefined) return false;
122 const all = lines();
123 if (state.key(e, all.length)) return true;
124 if (e.key === "w") {
125 window = windowOfViews(window === "7d" ? "30d" : window === "30d" ? "90d" : "7d");
126 return true;
127 }
128 if (e.key === "s") {
129 who = who === "people" ? "scrapers" : "people";
130 return true;
131 }
132 if (e.key === "c") {
133 narrow = {};
134 return true;
135 }
136 if (e.key === "enter") {
137 const at = all[state.index];
138 if (at?.dimension !== undefined && at.key !== undefined) narrow = { ...narrow, [at.dimension]: at.key === DIRECT ? "" : at.key };
139 return true;
140 }
141 if (e.key === "t") {
142 ctx.push(
143 promptScreen(ctx, screen, {
144 title: "Path to a goal",
145 label: "Steps, in order",
146 placeholder: "/, /explore, out:github.com",
147 value: steps.join(","),
148 onSubmit: (value) => {
149 steps = parseSteps(value);
150 return undefined;
151 },
152 }),
153 );
154 return true;
155 }
156 return false;
157 },
158 };
159 return screen;
160 }
161
162 /** The palette's forge-wide view, to site admins. */
163 export const entry: PaletteEntry = {
164 id: "traffic-sources",
165 title: "Traffic sources",
166 hint: "where people come from and where they go, for the forge",
167 offered: (f) => readableScope(f) !== undefined,
168 run: (f, ctx) => ctx.push(trafficScreen(f, ctx)),
169 };
170
171 /** The palette's per-app view while a repository is open, to the repository's admins. */
172 export function trafficCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
173 if (repo === undefined || readableScope(f, repo) === undefined) return [];
174 return [
175 {
176 id: "traffic-sources-app",
177 title: `${repo.ownerName}/${repo.name} — Traffic sources`,
178 hint: "where this app's visitors come from and go",
179 run: () => ctx.push(trafficScreen(f, ctx, repo)),
180 },
181 ];
182 }
src/tui/screens/workspace.ts +2
@@ -138,6 +138,7 @@ import { iterationsCommands } from "./iterations.ts";
138138 import { labelsCommands } from "./labels.ts";
139139 import { appEnvCommands } from "./appenv.ts";
140140 import { autonomyCommands } from "./autonomy.ts";
141 import { trafficCommands } from "./trafficview.ts";
141142 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
142143 import { finderScreen } from "./finder.ts";
143144 import { issuesScreen } from "./issues.ts";
@@ -1456,6 +1457,7 @@ export function workspaceScreen(
14561457 ...iterationsCommands(f, ctx, repo),
14571458 ...appEnvCommands(f, ctx, repo),
14581459 ...autonomyCommands(f, ctx, repo),
1460 ...trafficCommands(f, ctx, repo),
14591461 ],
14601462 get title() {
14611463 return sourceName(src);
src/web/beacon.ts +6 −2
@@ -8,16 +8,20 @@
88 * being measured belongs to the page that was loaded. Nothing
99 * is sent under Do-Not-Track or with the `forge_measure=off` cookie; the
1010 * server checks both again. A metric it could not measure is 0 (or absent).
11 * It also sends the referrer, the query string and the host of the last external
12 * link clicked; the server keeps only the referrer's host, the three `utm_`
13 * fields, and a clicked host that is in the named set (`perf/sources.ts`).
1114 */
1215 export const beaconJs = `!function(n,d,w){
1316 if(n.doNotTrack=="1"||/(^|; )forge_measure=off/.test(d.cookie)||!n.sendBeacon||!w.PerformanceObserver)return;
14 var c=0,i=0,l=0,x=0,a=location.pathname;
17 var c=0,i=0,l=0,x=0,u="",a=location.pathname;
1518 function o(t,f,e){try{new PerformanceObserver(function(s){s.getEntries().forEach(f)}).observe(Object.assign({type:t,buffered:true},e))}catch(_){}}
1619 o("largest-contentful-paint",function(e){l=e.startTime});
1720 o("layout-shift",function(e){e.hadRecentInput||(c+=e.value)});
1821 o("event",function(e){e.interactionId&&e.duration>i&&(i=e.duration)},{durationThreshold:40});
22 d.addEventListener("click",function(e){var t=e.target,k=t&&t.closest&&t.closest("a[href]");k&&k.hostname&&k.hostname!=location.hostname&&(u=k.hostname)},true);
1923 function s(){if(x)return;x=1;var v=performance.getEntriesByType("navigation")[0]||{};
20 n.sendBeacon("/_measure",JSON.stringify({path:a,ttfb:v.responseStart||0,load:v.loadEventEnd||0,lcp:l,inp:i,cls:c}))}
24 n.sendBeacon("/_measure",JSON.stringify({path:a,ttfb:v.responseStart||0,load:v.loadEventEnd||0,lcp:l,inp:i,cls:c,ref:d.referrer.slice(0,300),q:location.search.slice(0,300),out:u}))}
2125 d.addEventListener("visibilitychange",function(){d.visibilityState=="hidden"&&s()});
2226 w.addEventListener("pagehide",s)
2327 }(navigator,document,window)`;
src/web/measure.ts +21 −1
@@ -32,9 +32,22 @@ export interface Report {
3232 readonly lcp: number | null;
3333 readonly inp: number | null;
3434 readonly cls: number;
35 /** document.referrer, location.search and the host of the link the visitor left by; absent from an older beacon. */
36 readonly ref?: string;
37 readonly q?: string;
38 readonly out?: string;
3539 }
3640
37 const FIELDS = new Set(["path", "ttfb", "load", "lcp", "inp", "cls"]);
41 const FIELDS = new Set(["path", "ttfb", "load", "lcp", "inp", "cls", "ref", "q", "out"]);
42
43 /** An optional text field: absent is fine, a wrong kind or a long or control-laden one is malformed. */
44 function optionalText(body: Record<string, unknown>, key: string, max: number): string | undefined | "malformed" {
45 const given = body[key];
46 if (given === undefined) return undefined;
47 // eslint-disable-next-line no-control-regex
48 if (typeof given !== "string" || given.length > max || /[\u0000-\u001f\u007f]/.test(given)) return "malformed";
49 return given;
50 }
3851
3952 const bounded = (value: unknown, max: number): number | undefined =>
4053 typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= max
@@ -71,6 +84,10 @@ export function parseReport(text: string): Report | "malformed" {
7184 // 0 is how the beacon says "not measured".
7285 times[key] = value === 0 ? null : value;
7386 }
87 const ref = optionalText(body, "ref", 300);
88 const q = optionalText(body, "q", 300);
89 const out = optionalText(body, "out", 253);
90 if (ref === "malformed" || q === "malformed" || out === "malformed") return "malformed";
7491 return {
7592 path,
7693 ttfb: times["ttfb"] ?? null,
@@ -78,6 +95,9 @@ export function parseReport(text: string): Report | "malformed" {
7895 lcp: times["lcp"] ?? null,
7996 inp: times["inp"] ?? null,
8097 cls,
98 ...(ref === undefined ? {} : { ref }),
99 ...(q === undefined ? {} : { q }),
100 ...(out === undefined ? {} : { out }),
81101 };
82102 }
83103
src/web/pages/settings.ts +2 −1
@@ -796,7 +796,8 @@ export function appVisitorsView(v: AppVitals): Html {
796796 : `p75 load each day over the last 30 days, latest ${ms(latest ?? undefined)}, range ${ms(Math.min(...known))} to ${ms(Math.max(...known))}`,
797797 );
798798 return card(
799 html`<p class="muted small mb">
799 html`<p class="small mb"><a href="app/traffic">Where visitors come from and where they go</a></p>
800 <p class="muted small mb">
800801 Counted by the beacon the template ships, through the forge: no
801802 address is kept, and a visitor is the same only within a day, so the
802803 7 day figure adds the days up.
src/web/pages/sources.ts added +298
@@ -0,0 +1,298 @@
1 /**
2 * Where people come from and where they go (#9427): one view for the forge
3 * (`/settings/traffic/sources`, admin) and one per app
4 * (`/:owner/:name/settings/app/traffic`, those who administer it). Visits over
5 * time, sources, mediums, campaigns, landing pages and exits; choosing a value
6 * narrows every other panel; a path from a source through named steps to a
7 * goal; GitHub's own traffic for the forge. Every number links to the probe
8 * (`/api/v1/admin/traffic/probe`) that counts it again another way. Built from
9 * stored views (`perf/views_store.ts`) in one indexed read.
10 */
11
12 import { getAppById } from "../../core/apps.ts";
13 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
14 import type { Ctx } from "../../http/context.ts";
15 import { githubSummary } from "../../perf/github_traffic.ts";
16 import { outboundNames } from "../../perf/sources.ts";
17 import {
18 DIMENSIONS,
19 DIRECT,
20 WINDOWS,
21 funnel,
22 parseSteps,
23 sinceOf,
24 sourcesReport,
25 windowOfViews,
26 type Counted,
27 type Dimension,
28 type Filter,
29 type ViewWindow,
30 } from "../../perf/views_report.ts";
31 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
32 import { escapeHtml, html, raw, type Html } from "../html.ts";
33 import { page } from "../layout.ts";
34 import { btn, card, empty, seg } from "../ui.ts";
35 import { accountSections } from "./origins.ts";
36 import { settingsPageResponse, timedAdminRepo } from "./settings.ts";
37 import { getAppByRepo } from "../../core/apps.ts";
38
39 const CHART_W = 600;
40 const CHART_H = 72;
41 const PROBE = "/api/v1/admin/traffic/probe";
42
43 interface Query {
44 readonly scope: string;
45 readonly base: string;
46 readonly extra: Record<string, string>;
47 readonly window: ViewWindow;
48 readonly filter: Filter;
49 readonly steps: string[];
50 }
51
52 const text = (v: string | null): string | undefined => (v !== null && v.length > 0 && v.length <= 200 ? v : undefined);
53
54 function queryOf(url: URL, scope: string, base: string, extra: Record<string, string>): Query {
55 const p = url.searchParams;
56 const pick = (k: string): { [k: string]: string } => {
57 const v = text(p.get(k));
58 return v === undefined ? {} : { [k]: v };
59 };
60 const source = p.get("source") === DIRECT ? { source: "" } : pick("source");
61 return {
62 scope,
63 base,
64 extra,
65 window: windowOfViews(p.get("window")),
66 filter: {
67 who: p.get("who") === "scrapers" ? "scrapers" : "people",
68 ...source,
69 ...pick("medium"),
70 ...pick("campaign"),
71 ...pick("landing"),
72 ...pick("exit"),
73 },
74 steps: parseSteps(p.get("steps")),
75 };
76 }
77
78 /** This view's address with the given parameters changed; a null removes one. */
79 function href(q: Query, change: Record<string, string | null>): string {
80 const params = new URLSearchParams(q.extra);
81 params.set("window", q.window);
82 if (q.filter.who === "scrapers") params.set("who", "scrapers");
83 for (const d of DIMENSIONS) {
84 const v = q.filter[d];
85 if (v !== undefined) params.set(d, d === "source" && v === "" ? DIRECT : v);
86 }
87 if (q.steps.length > 0) params.set("steps", q.steps.join(","));
88 for (const [k, v] of Object.entries(change)) v === null ? params.delete(k) : params.set(k, v);
89 return `${q.base}?${params}`;
90 }
91
92 const probe = (q: Query, ask: Record<string, string>): string =>
93 `${PROBE}?${new URLSearchParams({ scope: q.scope, window: q.window, who: q.filter.who, ...ask })}`;
94
95 const figure = (q: Query, n: number, ask: Record<string, string>): Html =>
96 html`<a href="${probe(q, ask)}" title="How this was counted">${n}</a>`;
97
98 function columns(days: readonly Counted[], label: string): Html {
99 const top = Math.max(1, ...days.map((d) => d.visits));
100 const step = CHART_W / Math.max(1, days.length);
101 const bars = days.map((d, i) => {
102 const h = (d.visits / top) * CHART_H;
103 return h <= 0 ? "" : `<rect x="${(i * step).toFixed(1)}" y="${(CHART_H - h).toFixed(1)}" width="${Math.max(1, step - 1).toFixed(1)}" height="${h.toFixed(1)}" fill="currentColor" fill-opacity="0.7"><title>${escapeHtml(d.key)}: ${d.visits}</title></rect>`;
104 });
105 return raw(`<svg viewBox="0 0 ${CHART_W} ${CHART_H}" width="${CHART_W}" height="${CHART_H}" role="img" aria-label="${escapeHtml(label)}">${bars.join("")}</svg>`);
106 }
107
108 function panel(q: Query, title: string, rows: readonly Counted[], dimension: Dimension | null): Html {
109 return card(
110 rows.length === 0
111 ? html`<p class="muted small">None in the window.</p>`
112 : html`<div class="table-scroll">
113 <table class="table" data-dense>
114 <caption class="sr-only">${title}</caption>
115 <thead>
116 <tr><th scope="col">${title}</th><th scope="col" class="num">Visits</th></tr>
117 </thead>
118 <tbody>
119 ${rows.map(
120 (r) => html`<tr>
121 <td class="break">${dimension === null
122 ? r.key
123 : html`<a href="${href(q, { [dimension]: r.key })}" title="Narrow every panel to this">${r.key}</a>`}</td>
124 <td class="num">${dimension === null
125 ? figure(q, r.visits, { number: "outbound" })
126 : figure(q, r.visits, { dimension, key: r.key })}</td>
127 </tr>`,
128 )}
129 </tbody>
130 </table>
131 </div>`,
132 );
133 }
134
135 function filters(q: Query): Html {
136 const on = DIMENSIONS.filter((d) => q.filter[d] !== undefined);
137 if (on.length === 0) return html``;
138 return html`<p class="small mb">
139 Narrowed to
140 ${on.map((d) => html`<strong>${d}</strong> ${q.filter[d] === "" ? DIRECT : q.filter[d]} <a href="${href(q, { [d]: null })}">(clear)</a> `)}
141 </p>`;
142 }
143
144 function path(q: Query, now: number, db: Ctx["app"]["db"]): Html {
145 const rows = q.steps.length === 0 ? [] : funnel(db, q.scope, q.window, q.filter, q.steps, now);
146 return card(
147 html`<p class="muted small mb">
148 Name the steps a visit takes, in order, ending in a goal: a path such as
149 <code>/signup</code> (a path also counts its sub-pages) or an outbound
150 click such as <code>out:github.com</code>. Other pages may come between.
151 Choose a source above to start from it.
152 </p>
153 <form class="row-fields" method="get" action="${q.base}">
154 ${Object.entries({ ...q.extra, window: q.window, ...(q.filter.who === "scrapers" ? { who: "scrapers" } : {}), ...Object.fromEntries(DIMENSIONS.flatMap((d) => (q.filter[d] === undefined ? [] : [[d, q.filter[d] === "" && d === "source" ? DIRECT : q.filter[d]!]]))) }).map(
155 ([k, v]) => html`<input type="hidden" name="${k}" value="${v}" />`,
156 )}
157 <input class="input mono" name="steps" value="${q.steps.join(",")}" placeholder="/, /explore, out:github.com" aria-label="Steps, comma separated" />
158 ${btn("Follow", { size: "sm", variant: "primary" })}
159 </form>
160 ${rows.length === 0
161 ? null
162 : html`<div class="table-scroll mt">
163 <table class="table" data-dense>
164 <caption class="sr-only">Visits at each step and who dropped off</caption>
165 <thead>
166 <tr><th scope="col">Step</th><th scope="col" class="num">Visits</th><th scope="col" class="num">Lost</th></tr>
167 </thead>
168 <tbody>
169 ${rows.map(
170 (r, i) => html`<tr>
171 <td class="break">${r.step}</td>
172 <td class="num">${figure(q, r.visits, { steps: q.steps.join(","), number: `funnel_${i}` })}</td>
173 <td class="num">${i === 0 ? "–" : html`${r.lost} (${r.lostShare}%)`}</td>
174 </tr>`,
175 )}
176 </tbody>
177 </table>
178 </div>`}`,
179 );
180 }
181
182 function github(db: Ctx["app"]["db"], q: Query, now: number): Html {
183 const rows = githubSummary(db, new Date(sinceOf(now, q.window)).toISOString().slice(0, 10));
184 if (rows.length === 0)
185 return html`<p class="muted small mt">
186 GitHub's own traffic is not pulled: set <code>FORGE_GITHUB_TOKEN</code> (a token that may read repository
187 traffic) and <code>FORGE_GITHUB_REPOS</code> (<code>owner/name,…</code>) and it is stored daily, since GitHub keeps only 14 days.
188 </p>`;
189 return card(
190 html`<div class="table-scroll">
191 <table class="table" data-dense>
192 <caption class="sr-only">GitHub repository traffic, pulled daily</caption>
193 <thead>
194 <tr><th scope="col">Repository</th><th scope="col" class="num">Views</th><th scope="col" class="num">Viewers</th><th scope="col" class="num">Clones</th><th scope="col" class="num">Cloners</th><th scope="col">Top referrers, last pull</th></tr>
195 </thead>
196 <tbody>
197 ${rows.map(
198 (r) => html`<tr>
199 <td class="break">${r.repo}</td>
200 <td class="num">${r.views}</td><td class="num">${r.viewers}</td>
201 <td class="num">${r.clones}</td><td class="num">${r.cloners}</td>
202 <td class="break">${r.error !== null ? `Last pull failed: ${r.error}` : r.referrers.map((x) => `${x.key} ${x.count}`).join(", ") || "none"}</td>
203 </tr>`,
204 )}
205 </tbody>
206 </table>
207 </div>`,
208 { head: html`<h2>GitHub</h2>` },
209 );
210 }
211
212 export function sourcesBody(ctx: Ctx, q: Query, label: string): Html {
213 const db = ctx.app.db;
214 const now = Date.now();
215 const r = sourcesReport(db, q.scope, q.window, q.filter, now);
216 const scraping = q.filter.who === "scrapers";
217 const empties = r.people + r.scrapers === 0;
218 return html`<div class="mb">
219 ${seg(Object.keys(WINDOWS).map((w) => ({ label: `${WINDOWS[w as ViewWindow]} days`, href: href(q, { window: w }), current: w === q.window })))}
220 ${seg([
221 { label: "People", href: href(q, { who: null }), current: !scraping },
222 { label: "Scrapers", href: href(q, { who: "scrapers" }), current: scraping },
223 ])}
224 </div>
225 ${filters(q)}
226 ${empties
227 ? empty({ icon: "users", title: `No visits recorded for ${label} in ${WINDOWS[q.window]} days`, note: "Views are written every few seconds; a page counts when it is left." })
228 : html`${card(
229 html`<dl class="stats">
230 <div><dt>${scraping ? "Scraper visits" : "Visits"}</dt><dd>${figure(q, r.visits, { number: scraping ? "scrapers" : "visits" })}</dd></div>
231 <div><dt>Pages per visit</dt><dd><a href="${probe(q, { number: "page_views" })}" title="How this was counted">${r.pagesPerVisit}</a></dd></div>
232 <div><dt>Page views</dt><dd>${figure(q, r.pageViews, { number: "page_views" })}</dd></div>
233 <div><dt>${scraping ? "People, kept apart" : "Scrapers, kept apart"}</dt><dd>${figure(q, scraping ? r.people : r.scrapers, { number: scraping ? "visits" : "scrapers" })}</dd></div>
234 </dl>
235 <div class="table-scroll muted mt">${columns(r.days, `Visits each day over the last ${WINDOWS[q.window]} days`)}</div>`,
236 )}
237 ${panel(q, "Source", r.sources, "source")}
238 ${panel(q, "Medium (utm_medium)", r.mediums, "medium")}
239 ${panel(q, "Campaign (utm_campaign)", r.campaigns, "campaign")}
240 ${panel(q, "Landing page", r.landings, "landing")}
241 ${panel(q, "Where people left (last page)", r.exits, "exit")}
242 ${panel(q, `Outbound clicks to ${outboundNames().join(", ")}`, r.outbound, null)}`}
243 <h2 class="mt">A path to a goal</h2>
244 ${path(q, now, db)}
245 ${q.scope === "forge" ? github(db, q, now) : null}
246 <p class="small muted mt">
247 <strong>How it was counted.</strong> A visit is one visitor's views on one day: a visitor is a hash of the address and
248 browser under a salt that changes daily and is then deleted, so no address or cookie is kept, nobody is followed
249 across days, and the 7- or 30-day figures add days up. A visit's source, medium and campaign are those of its first
250 page (<code>utm_source</code> wins over the referring site; a referrer from this site is not a source; none is "${DIRECT}").
251 Scrapers are told by user agent (crawlers, headless browsers, HTTP libraries, none at all) and counted apart.
252 ${q.scope === "forge" ? "Forge pages are filed by their route (/:owner/:name), never by a path that could name a private repository." : null}
253 </p>
254 <p class="small muted">
255 <strong>What it cannot see.</strong> Visitors with scripts off or who ask not to be measured (Do-Not-Track), anyone whose
256 browser hides the referrer, pages that do not run the beacon (GitHub's own pages, other people's sites; GitHub's
257 figures are pulled in separately above), apps whose <code>beacon.js</code> predates the referrer fields, and an outbound
258 click when the browser leaves before it can send. Outbound clicks are reported by the visitor's own browser,
259 so anyone can post one that never happened: read them as a hint, not a count. Click a number to see how it was counted.
260 </p>`;
261 }
262
263 /** GET /settings/traffic/sources — the forge's view, or `?app=<id>` for any app; admins only. */
264 export function sourcesPage(ctx: Ctx): Response {
265 if (!ctx.auth.user) return signInFirst(ctx);
266 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
267 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
268 const asked = ctx.url.searchParams.get("app");
269 const record = asked === null ? undefined : getAppById(ctx.app.db, asked);
270 if (asked !== null && record === undefined) return notFoundPage(ctx);
271 const q = queryOf(ctx.url, record?.id ?? "forge", "/settings/traffic/sources", record === undefined ? {} : { app: record.id });
272 const label = record === undefined ? "this forge" : "this app";
273 const rendering = performance.now();
274 const response = page({
275 ctx,
276 title: "Sources",
277 body: html`<div class="page-head"><h1>Where people come from</h1></div>
278 ${settingsLayout(accountSections("traffic", { admin: true }), sourcesBody(ctx, q, label), "Account settings")}`,
279 });
280 notePhase(ctx.request, "render", performance.now() - rendering);
281 return response;
282 }
283
284 /** GET /:owner/:name/settings/app/traffic — one app's view, for those who administer it. */
285 export function appSourcesPage(ctx: Ctx): Response {
286 const repo = timedAdminRepo(ctx);
287 if (!repo) return notFoundPage(ctx);
288 const record = getAppByRepo(ctx.app.db, repo.id);
289 if (record === undefined) {
290 notePhase(ctx.request, "render", 0);
291 return notFoundPage(ctx);
292 }
293 const q = queryOf(ctx.url, record.id, `/${ctx.params.owner}/${ctx.params.name}/settings/app/traffic`, {});
294 const rendering = performance.now();
295 const response = settingsPageResponse(ctx, repo, "app", "Where people come from", sourcesBody(ctx, q, "this app"));
296 notePhase(ctx.request, "render", performance.now() - rendering);
297 return response;
298 }
src/web/pages/traffic.ts +3
@@ -171,6 +171,9 @@ export function trafficPage(ctx: Ctx): Response {
171171 ${top("Address prefix", tops.prefixes, t)}
172172 ${top("Country", tops.countries, t)}
173173 ${top("Request signature", tops.signatures, t)}
174 <p class="mt">
175 <a href="/settings/traffic/sources">Where people come from and where they go: sources, campaigns, a path to a goal</a>
176 </p>
174177 <p class="mt">
175178 <a href="/settings/traffic/scrapes">What was scraped, and who: reads, networks, captures</a>
176179 </p>
test/apps-measure.test.ts +11
@@ -107,6 +107,17 @@ describe("a report through the proxy", () => {
107107 expect((await report("notes", { ...good, app: ids["diary"] })).status).toBe(400);
108108 });
109109
110 test("its referrer and campaign are buffered against that app, as host and utm fields only (#9427)", async () => {
111 const { flushViews } = await import("../src/perf/views_store.ts");
112 await report("notes", { ...good, path: "/a?x=1", ref: "https://espodev.com/blog?secret=1", q: "?utm_source=post&utm_campaign=c1&token=zzz", out: "github.com" });
113 flushViews(app.db);
114 const row = app.db
115 .query<Record<string, unknown>, [string]>("SELECT path, ref_host, source, campaign, out_host FROM traffic_view WHERE scope = ?1 ORDER BY id DESC LIMIT 1")
116 .get(ids["notes"]!);
117 expect(row).toEqual({ path: "/a", ref_host: "espodev.com", source: "post", campaign: "c1", out_host: "github.com" });
118 expect(app.db.query("SELECT 1 FROM traffic_view WHERE scope = ?1").all(ids["diary"]!)).toEqual([]);
119 });
120
110121 test("keeps no address and refuses a foreign origin, a bad body and an opted-out visitor", async () => {
111122 expect((await report("notes", good, { origin: "http://evil.test" })).status).toBe(403);
112123 expect((await report("notes", "nope")).status).toBe(400);
test/palette-roster.test.ts +2
@@ -125,6 +125,7 @@ test("a site-admin person is offered the whole roster", () => {
125125 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
126126 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
127127 ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"],
128 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
128129 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
129130 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
130131 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
@@ -190,6 +191,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
190191 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
191192 ["can", "What I can do", "your role and what it allows here"],
192193 ["bugs", "Bugs", "open bugs and the bug clock"],
194 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
193195 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
194196 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
195197 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
test/traffic-sources.test.ts added +285
@@ -0,0 +1,285 @@
1 /**
2 * Where people come from and where they go (#9427): the beacon's referrer and
3 * campaign fields are reduced to what may be kept, buffered off the request
4 * path, told apart from scrapers, narrowed by a filter, followed through a
5 * path to a goal, and every number is counted again by the probe.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { entriesOf, githubSummary, pullGithub, githubConfig, type Fetch } from "../src/perf/github_traffic.ts";
16 import { hostOf, originOf, outboundOf, isScraperAgent } from "../src/perf/sources.ts";
17 import { probeViews } from "../src/perf/views_probe.ts";
18 import { funnel, parseSteps, readVisits, sourcesReport } from "../src/perf/views_report.ts";
19 import { VISITOR_DAY_MAX, flushViews, noteView, pendingViews } from "../src/perf/views_store.ts";
20 import { parseReport } from "../src/web/measure.ts";
21 import { entry, readableScope, trafficLines } from "../src/tui/screens/trafficview.ts";
22 import { beaconJs } from "../src/web/beacon.ts";
23
24 let dir: string;
25 let app: App;
26 let servers: Servers;
27 let cookie: string;
28 const PERSON = "Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0 Safari/537.36";
29 const BOT = "Mozilla/5.0 (compatible; Reflectionbot/1.0)";
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-sources-"));
33 app = createApp({ dataDir: dir, port: 0 });
34 servers = await serve(app);
35 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", password: "correct-horse-battery", isAdmin: true });
36 if (!made.ok) throw new Error(made.error);
37 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
38 });
39 afterAll(async () => {
40 await servers.stop();
41 await rm(dir, { recursive: true, force: true });
42 });
43
44 const at = Date.now();
45 const view = (scope: string, client: string, path: string, o: { ref?: string; q?: string; out?: string } = {}, agent = PERSON, when = at) =>
46 noteView(app.db, { scope, path, origin: originOf(o.ref, o.q, "forge.test", o.out), client, agent, at: when });
47
48 describe("what is kept of a referrer", () => {
49 test("only hosts, only the three utm fields, only named outbound links", () => {
50 expect(hostOf("https://www.Espodev.com/blog/post?token=secret#x")).toBe("espodev.com");
51 expect(hostOf("javascript:alert(1)")).toBe("");
52 expect(hostOf("https://bad host/")).toBe("");
53 const o = originOf("https://news.example/item?id=1", "?utm_source=Post&utm_medium=social&utm_campaign=launch-1&other=1", "forge.test", "github.com");
54 expect(o).toEqual({ refHost: "news.example", source: "post", medium: "social", campaign: "launch-1", out: "github.com" });
55 expect(originOf("https://forge.test/explore", "", "forge.test:3000", undefined).source).toBe("");
56 expect(originOf(undefined, "?utm_source=<script>", "x", "evil.example")).toEqual({ refHost: "", source: "", medium: "", campaign: "", out: "" });
57 expect(outboundOf("gist.github.com")).toBe("github.com");
58 expect(outboundOf("notgithub.com")).toBe("");
59 });
60 test("a program is not a person", () => {
61 expect(isScraperAgent(BOT)).toBe(true);
62 expect(isScraperAgent("")).toBe(true);
63 expect(isScraperAgent("HeadlessChrome/120")).toBe(true);
64 expect(isScraperAgent(PERSON)).toBe(false);
65 });
66 test("the beacon's new fields are optional, bounded and the old beacon still passes", () => {
67 const base = { path: "/", cls: 0 };
68 expect(parseReport(JSON.stringify(base))).toEqual({ path: "/", ttfb: null, load: null, lcp: null, inp: null, cls: 0 });
69 expect(parseReport(JSON.stringify({ ...base, ref: "https://a.test/", q: "?utm_source=x", out: "github.com" }))).toMatchObject({ ref: "https://a.test/", out: "github.com" });
70 expect(parseReport(JSON.stringify({ ...base, ref: "x".repeat(301) }))).toBe("malformed");
71 expect(parseReport(JSON.stringify({ ...base, out: 5 }))).toBe("malformed");
72 expect(beaconJs).toContain("document".slice(0, 0) + "referrer");
73 expect(new TextEncoder().encode(beaconJs).length).toBeLessThan(2048);
74 });
75 });
76
77 describe("buffered views, visits and filters", () => {
78 test("a view costs no SQL until the flush, and the flush writes them in one go", () => {
79 // Ada from a post (campaign), reads two pages and leaves for GitHub.
80 view("forge", "10.0.0.1", "/", { ref: "https://blog.example/post", q: "?utm_source=post&utm_medium=social&utm_campaign=launch" }, PERSON, at);
81 view("forge", "10.0.0.1", "/explore", { ref: "https://forge.test/" }, PERSON, at + 1000);
82 view("forge", "10.0.0.1", "/explore", { out: "github.com" }, PERSON, at + 2000);
83 // Bo comes from the personal site and bounces.
84 view("forge", "10.0.0.2", "/", { ref: "https://espodev.com/" }, PERSON, at + 3000);
85 // Cy arrives direct and signs up.
86 view("forge", "10.0.0.3", "/", {}, PERSON, at + 4000);
87 view("forge", "10.0.0.3", "/signup", {}, PERSON, at + 5000);
88 // A scraper, and another scope.
89 view("forge", "10.0.0.9", "/", {}, BOT, at + 6000);
90 view("app1", "10.0.0.1", "/", { ref: "https://espodev.com/" }, PERSON, at + 7000);
91 const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM traffic_view").get()!.n;
92 expect(before).toBe(0);
93 expect(pendingViews(app.db)).toBe(8);
94 expect(flushViews(app.db, at + 10_000)).toBe(8);
95 expect(pendingViews(app.db)).toBe(0);
96 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM traffic_view").get()!.n).toBe(8);
97 });
98
99 test("visits take the first view's source and the last view's exit; scrapers stay apart; scopes stay apart", () => {
100 const visits = readVisits(app.db, "forge", at - 1000);
101 expect(visits.length).toBe(4);
102 const r = sourcesReport(app.db, "forge", "7d", { who: "people" }, at + 20_000);
103 expect(r.visits).toBe(3);
104 expect(r.scrapers).toBe(1);
105 expect(r.pageViews).toBe(6);
106 expect(r.pagesPerVisit).toBe(2);
107 expect(r.sources.map((s) => `${s.key}:${s.visits}`).sort()).toEqual(["(direct):1", "espodev.com:1", "post:1"]);
108 expect(r.campaigns).toEqual([{ key: "launch", visits: 1 }]);
109 expect(r.landings[0]).toEqual({ key: "/", visits: 3 });
110 expect(r.exits.map((e) => e.key).sort()).toEqual(["/", "/explore", "/signup"]);
111 expect(r.outbound).toEqual([{ key: "github.com", visits: 1 }]);
112 expect(r.days.at(-1)?.visits).toBeGreaterThan(0);
113 expect(sourcesReport(app.db, "forge", "7d", { who: "scrapers" }, at + 20_000).visits).toBe(1);
114 expect(sourcesReport(app.db, "app1", "7d", { who: "people" }, at + 20_000).visits).toBe(1);
115 });
116
117 test("choosing a value narrows every other panel", () => {
118 const r = sourcesReport(app.db, "forge", "7d", { who: "people", source: "post" }, at + 20_000);
119 expect(r.visits).toBe(1);
120 expect(r.exits).toEqual([{ key: "/explore", visits: 1 }]);
121 expect(r.campaigns).toEqual([{ key: "launch", visits: 1 }]);
122 expect(r.outbound).toEqual([{ key: "github.com", visits: 1 }]);
123 const direct = sourcesReport(app.db, "forge", "7d", { who: "people", source: "" }, at + 20_000);
124 expect(direct.exits).toEqual([{ key: "/signup", visits: 1 }]);
125 });
126
127 test("a path from a source to a goal counts each step and what was lost", () => {
128 expect(parseSteps("/, /signup, out:github.com, nonsense, ../x")).toEqual(["/", "/signup", "out:github.com"]);
129 const all = funnel(app.db, "forge", "7d", { who: "people" }, ["/", "/signup"], at + 20_000);
130 expect(all.map((s) => s.visits)).toEqual([3, 3, 1]);
131 expect(all[2]).toMatchObject({ lost: 2, lostShare: 67 });
132 const fromPost = funnel(app.db, "forge", "7d", { who: "people", source: "post" }, ["/explore", "out:github.com"], at + 20_000);
133 expect(fromPost.map((s) => s.visits)).toEqual([1, 1, 1]);
134 const wrongOrder = funnel(app.db, "forge", "7d", { who: "people" }, ["out:github.com", "/"], at + 20_000);
135 expect(wrongOrder.map((s) => s.visits)).toEqual([3, 1, 0]);
136 });
137
138 test("the probe counts every number again by another route and they agree", () => {
139 const p = probeViews(app.db, "forge", "7d", "people", at + 20_000, { dimension: "source", key: "post", steps: "/,/signup" });
140 const ids = p.numbers.map((n) => n.id);
141 expect(ids).toEqual(expect.arrayContaining(["visits", "page_views", "outbound", "scrapers", "cell", "funnel_0", "funnel_2"]));
142 for (const n of p.numbers) expect({ id: n.id, agrees: n.agrees }).toEqual({ id: n.id, agrees: true });
143 expect(p.numbers.find((n) => n.id === "cell")?.value).toBe(1);
144 const exit = probeViews(app.db, "forge", "7d", "people", at + 20_000, { dimension: "exit", key: "/explore" });
145 expect(exit.numbers.find((n) => n.id === "cell")).toMatchObject({ value: 1, recomputed: 1 });
146 const direct = probeViews(app.db, "forge", "7d", "people", at + 20_000, { dimension: "source", key: "(direct)" });
147 expect(direct.numbers.find((n) => n.id === "cell")).toMatchObject({ value: 1, recomputed: 1 });
148 });
149
150 test("the buffer is bounded and a read of visits walks the index", () => {
151 const plan = app.db
152 .query("EXPLAIN QUERY PLAN SELECT day, visitor, path, source, medium, campaign, out_host, scraper FROM traffic_view WHERE scope = ?1 AND at >= ?2 ORDER BY at, id LIMIT ?3")
153 .all("x", 0, 5)
154 .map((r) => (r as { detail: string }).detail)
155 .join("\n");
156 expect(plan).toContain("traffic_view_scope_at");
157 expect(plan).not.toMatch(/^SCAN (?!.*USING)/m);
158 });
159 });
160
161 describe("through the doors", () => {
162 test("a report on the forge's own door is buffered with its route, not its path, and the page and probe answer an admin alone", async () => {
163 const res = await fetch(`${servers.url}/_measure`, {
164 method: "POST",
165 headers: { origin: servers.url, "user-agent": PERSON },
166 body: JSON.stringify({ path: "/explore", cls: 0, ref: "https://news.example/x", q: "?utm_source=hn&utm_campaign=show" }),
167 });
168 expect(res.status).toBe(204);
169 expect(pendingViews(app.db)).toBe(1);
170 flushViews(app.db);
171 const row = app.db.query<{ path: string; source: string; campaign: string }, []>("SELECT path, source, campaign FROM traffic_view WHERE source = 'hn'").get()!;
172 expect(row).toEqual({ path: "/explore", source: "hn", campaign: "show" });
173
174 const page = await fetch(`${servers.url}/settings/traffic/sources?window=7d&source=hn&steps=/explore,out:github.com`, { headers: { cookie } });
175 expect(page.status).toBe(200);
176 const body = await page.text();
177 expect(body).toContain("What it cannot see");
178 expect(body).toContain("Scrapers are told by user agent");
179 expect(body).toContain("/api/v1/admin/traffic/probe?");
180 expect(body).toContain("Narrowed to");
181 expect(await (await fetch(`${servers.url}/settings/traffic/sources`)).text()).not.toContain("How it was counted");
182 expect((await fetch(`${servers.url}/settings/traffic/sources?app=nope`, { headers: { cookie } })).status).toBe(404);
183
184 const probe = await fetch(`${servers.url}/api/v1/admin/traffic/probe?scope=forge&number=visits`, { headers: { cookie } });
185 expect(probe.status).toBe(200);
186 const json = (await probe.json()) as { numbers: { id: string; agrees: boolean }[] };
187 expect(json.numbers.map((n) => n.id)).toEqual(["visits"]);
188 expect(json.numbers[0]!.agrees).toBe(true);
189 expect(await (await fetch(`${servers.url}/api/v1/admin/traffic/probe`)).text()).not.toContain("agrees");
190 });
191 });
192
193 describe("GitHub's own traffic", () => {
194 const answers: Record<string, unknown> = {
195 referrers: [{ referrer: "espodev.com", count: 9, uniques: 4 }],
196 views: { count: 5, uniques: 3, views: [{ timestamp: "2026-11-06T00:00:00Z", count: 5, uniques: 3 }] },
197 clones: { count: 2, uniques: 1, clones: [{ timestamp: "2026-11-07T00:00:00Z", count: 2, uniques: 1 }] },
198 };
199 const seen: { url: string; auth: string }[] = [];
200 const fake: Fetch = async (url, init) => {
201 seen.push({ url, auth: init.headers["authorization"] ?? "" });
202 const kind = url.includes("referrers") ? "referrers" : url.includes("views") ? "views" : "clones";
203 return Response.json(answers[kind]);
204 };
205
206 test("needs a token and a valid repository, validates answers, and stores them", async () => {
207 expect(githubConfig({})).toBeNull();
208 expect(githubConfig({ FORGE_GITHUB_TOKEN: "t", FORGE_GITHUB_REPOS: "../x, a/b" })?.repos).toEqual(["a/b"]);
209 expect(entriesOf("views", { views: [{ timestamp: "nope", count: 1, uniques: 1 }] }, "2026-11-08")).toBe("malformed");
210 expect(entriesOf("referrers", [{ referrer: "<b>", count: 1, uniques: 1 }], "2026-11-08")).toBe("malformed");
211 const stored = await pullGithub(app.db, { token: "tok", repos: ["trevato/forge"] }, fake, Date.parse("2026-11-08T03:00:00Z"));
212 expect(stored).toBe(1);
213 expect(seen.map((s) => s.url)).toEqual([
214 "https://api.github.com/repos/trevato/forge/traffic/popular/referrers",
215 "https://api.github.com/repos/trevato/forge/traffic/views",
216 "https://api.github.com/repos/trevato/forge/traffic/clones",
217 ]);
218 expect(seen[0]!.auth).toBe("Bearer tok");
219 const [s] = githubSummary(app.db, "2026-11-01");
220 expect(s).toMatchObject({ repo: "trevato/forge", views: 5, viewers: 3, clones: 2, error: null });
221 expect(s!.referrers).toEqual([{ key: "espodev.com", count: 9, uniques: 4 }]);
222 // Pulling again the same day replaces rather than doubles.
223 await pullGithub(app.db, { token: "tok", repos: ["trevato/forge"] }, fake, Date.parse("2026-11-08T05:00:00Z"));
224 expect(githubSummary(app.db, "2026-11-01")[0]!.views).toBe(5);
225 expect(JSON.stringify(app.db.query("SELECT * FROM github_pull").all())).not.toContain("tok");
226 });
227
228 test("a refusal is recorded, not thrown, and stores nothing new", async () => {
229 const refuse: Fetch = async () => new Response("no", { status: 403 });
230 expect(await pullGithub(app.db, { token: "tok", repos: ["a/b"] }, refuse)).toBe(0);
231 const row = app.db.query<{ error: string }, []>("SELECT error FROM github_pull WHERE repo = 'a/b'").get()!;
232 expect(row.error).toBe("referrers: GitHub answered 403");
233 });
234 });
235
236 describe("review: caps, cache, gates and the terminal door", () => {
237 test("one visitor cannot fill a day with invented paths", () => {
238 const scope = "capped-scope";
239 for (let i = 0; i < VISITOR_DAY_MAX + 20; i++) view(scope, "10.9.9.9", `/junk-${i}`);
240 view(scope, "10.1.1.1", "/real");
241 flushViews(app.db);
242 const n = () =>
243 app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM traffic_view WHERE scope = ?1").get(scope)!.n;
244 expect(n()).toBe(VISITOR_DAY_MAX + 1);
245 expect(app.db.query("SELECT 1 FROM traffic_view WHERE scope = ?1 AND path = '/real'").all(scope)).toHaveLength(1);
246 });
247
248 test("a read is held briefly and a write drops what is held", () => {
249 const scope = "held-scope";
250 const since = Date.now() - 86_400_000;
251 view(scope, "10.2.2.2", "/a");
252 flushViews(app.db);
253 const first = readVisits(app.db, scope, since);
254 expect(readVisits(app.db, scope, since)).toBe(first);
255 view(scope, "10.3.3.3", "/b");
256 flushViews(app.db);
257 expect(readVisits(app.db, scope, since)).toHaveLength(2);
258 });
259
260 test("a non-admin gets 404 on both pages and the probe; the pages carry the security floor", async () => {
261 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "guest1", email: "[email protected]", password: "correct-horse-battery", isAdmin: false });
262 if (!made.ok) throw new Error(made.error);
263 const plain = `forge_session=${createSession(app.db, made.value.id).token}`;
264 for (const path of ["/settings/traffic/sources", "/ada/none/settings/app/traffic"])
265 expect((await fetch(`${servers.url}${path}`, { headers: { cookie: plain } })).status).toBe(404);
266 expect((await fetch(`${servers.url}/api/v1/admin/traffic/probe?scope=forge`, { headers: { cookie: plain } })).status).not.toBe(200);
267 const ok = await fetch(`${servers.url}/settings/traffic/sources`, { headers: { cookie } });
268 expect(ok.status).toBe(200);
269 expect(ok.headers.get("x-content-type-options")).toBe("nosniff");
270 expect(ok.headers.get("referrer-policy")).toBeTruthy();
271 expect(ok.headers.get("content-security-policy")).toBeTruthy();
272 expect(await ok.text()).toContain("reported by the visitor");
273 });
274
275 test("the terminal reads what the page reads, to the same people", () => {
276 const admin = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'ada'").get()!;
277 const asAdmin = { db: app.db, app, user: { id: admin.id, isAdmin: true } } as never;
278 const asGuest = { db: app.db, app, user: { id: "x", isAdmin: false } } as never;
279 expect(readableScope(asAdmin)).toBe("forge");
280 expect(readableScope(asGuest)).toBeUndefined();
281 expect(readableScope({ db: app.db, app } as never)).toBeUndefined();
282 expect(entry.offered(asGuest)).toBe(false);
283 expect(trafficLines(asAdmin, "forge", "7d", { who: "people" }, ["/"]).length).toBeGreaterThan(1);
284 });
285 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs