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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9427-see-where-people-come-from-and-where-the → main 5 commits fork 2 threads · 2 unresolved
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 | ||
| 14 | 14 | |
| 15 | 15 | A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`. |
| 16 | 16 | |
| 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`, | ||
| 137 | 137 | - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen. |
| 138 | 138 | - `/settings/security` — `pages/security.ts` · the Shield and the evidence an admin needs to act with no AI · `/settings/traffic`, `/settings/backups` · none seen. |
| 139 | 139 | - `/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. | |
| 140 | 141 | - `/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. |
| 141 | 142 | |
| 142 | 143 | ## 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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "egress": [ |
| 3 | "api.anthropic.com" | |
| 3 | "api.anthropic.com", | |
| 4 | "api.github.com" | |
| 4 | 5 | ], |
| 5 | 6 | "app": { |
| 6 | 7 | "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"; | ||
| 14 | 14 | import { clientAddress, clientBucket } from "../http/peer.ts"; |
| 15 | 15 | import { optedOut, parseReport, REPORT_MAX } from "../web/measure.ts"; |
| 16 | 16 | import type { AppTarget } from "./proxy.ts"; |
| 17 | import { originOf } from "../perf/sources.ts"; | |
| 18 | import { noteView } from "../perf/views_store.ts"; | |
| 17 | 19 | import { recordAppVital } from "./vitals.ts"; |
| 18 | 20 | |
| 19 | 21 | const PER_CLIENT = 30; |
| @@ -80,5 +82,12 @@ export async function appMeasure( | ||
| 80 | 82 | clientAddress(request), |
| 81 | 83 | request.headers.get("user-agent") ?? "", |
| 82 | 84 | ); |
| 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), | |
| 89 | client: clientAddress(request), | |
| 90 | agent: request.headers.get("user-agent") ?? "", | |
| 91 | }); | |
| 83 | 92 | return refuse(204); |
| 84 | 93 | } |
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"; | ||
| 48 | 48 | export { route as getApiV1AdminShield } from "./getApiV1AdminShield.ts"; |
| 49 | 49 | export { route as getApiV1AdminSubscriptions } from "./getApiV1AdminSubscriptions.ts"; |
| 50 | 50 | export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts"; |
| 51 | export { route as getApiV1AdminTrafficProbe } from "./getApiV1AdminTrafficProbe.ts"; | |
| 51 | 52 | export { route as getApiV1Agents } from "./getApiV1Agents.ts"; |
| 52 | 53 | export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts"; |
| 53 | 54 | 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 @@ | ||
| 7 | 7 | |
| 8 | 8 | |
| 9 | 9 | import { crawlReport } from "../../perf/crawlers.ts"; |
| 10 | import { getAppById } from "../../core/apps.ts"; | |
| 11 | import { probeViews } from "../../perf/views_probe.ts"; | |
| 10 | 12 | import { kpiProbe } from "../../web/studio/kpi_probe.ts"; |
| 11 | 13 | import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts"; |
| 12 | 14 | import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; |
| @@ -147,6 +149,24 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 147 | 149 | return json(kpiProbe(ctx.app)); |
| 148 | 150 | }); |
| 149 | 151 | |
| 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 | ||
| 150 | 170 | // The forge's capacity (`core/capacity.ts`): read and changed live by an |
| 151 | 171 | // admin, never by an agent; anyone else is told the door is not there. |
| 152 | 172 | 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 | ||
| 15 | 15 | import type { Ctx } from "./context.ts"; |
| 16 | 16 | import { readBounded } from "./limits.ts"; |
| 17 | 17 | import { clientAddress, clientBucket } from "./peer.ts"; |
| 18 | import { originOf } from "../perf/sources.ts"; | |
| 19 | import { noteView } from "../perf/views_store.ts"; | |
| 18 | 20 | |
| 19 | 21 | const PER_CLIENT = 30; |
| 20 | 22 | const PER_FORGE = 1200; |
| @@ -63,5 +65,13 @@ export async function measureSubmit(ctx: Ctx, routeOf: RouteOf): Promise<Respons | ||
| 63 | 65 | clientAddress(request), |
| 64 | 66 | request.headers.get("user-agent") ?? "", |
| 65 | 67 | ); |
| 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 | }); | |
| 66 | 76 | return refuse(204); |
| 67 | 77 | } |
src/http/server.ts +9
| @@ -461,6 +461,9 @@ import { | ||
| 461 | 461 | import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts"; |
| 462 | 462 | import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts"; |
| 463 | 463 | 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"; | |
| 464 | 467 | import { createReadWriter, readOf } from "../perf/reads.ts"; |
| 465 | 468 | import { parseServerTiming } from "../perf/record.ts"; |
| 466 | 469 | import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts"; |
| @@ -1370,6 +1373,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1370 | 1373 | proposalTokenRevoke, |
| 1371 | 1374 | ); |
| 1372 | 1375 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); |
| 1376 | router.add("GET", "/:owner/:name/settings/app/traffic", appSourcesPage); | |
| 1373 | 1377 | router.add("GET", "/:owner/:name/settings/machines", machinesSettingsPage); |
| 1374 | 1378 | router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate); |
| 1375 | 1379 | router.add("POST", "/:owner/:name/settings/app/disable", appDisable); |
| @@ -2250,6 +2254,9 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2250 | 2254 | const timings = new Map<Database, RouteTiming>(); |
| 2251 | 2255 | // The minute ledger's history: one write a minute, never on a request. |
| 2252 | 2256 | 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); | |
| 2253 | 2260 | const readWriter = createReadWriter(app.db, readLedger, app.log); |
| 2254 | 2261 | const counted = ( |
| 2255 | 2262 | owner: App, |
| @@ -2553,6 +2560,8 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2553 | 2560 | } |
| 2554 | 2561 | timings.clear(); |
| 2555 | 2562 | trafficWriter.stop(); |
| 2563 | viewWriter.stop(); | |
| 2564 | githubPull?.stop(); | |
| 2556 | 2565 | readWriter.stop(); |
| 2557 | 2566 | // A child's hooks stop answering before its database closes. |
| 2558 | 2567 | 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"; | ||
| 20 | 20 | import { spendPage } from "../web/pages/spend-tab.ts"; |
| 21 | 21 | import { securityPage, securityShieldSet } from "../web/pages/security.ts"; |
| 22 | 22 | import { trafficPage } from "../web/pages/traffic.ts"; |
| 23 | import { sourcesPage } from "../web/pages/sources.ts"; | |
| 23 | 24 | import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts"; |
| 24 | 25 | import { |
| 25 | 26 | accountAct, |
| @@ -190,6 +191,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 190 | 191 | router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns)); |
| 191 | 192 | router.add("GET", "/settings/spend", spendPage); |
| 192 | 193 | router.add("GET", "/settings/traffic", trafficPage); |
| 194 | router.add("GET", "/settings/traffic/sources", sourcesPage); | |
| 193 | 195 | router.add("GET", "/settings/traffic/scrapes", scrapesPage); |
| 194 | 196 | router.add("GET", "/settings/traffic/scrapes/export", scrapesExport); |
| 195 | 197 | 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"; | ||
| 22 | 22 | import { entry as crewSplits } from "./crewsplits.ts"; |
| 23 | 23 | import { entry as domains } from "./domains.ts"; |
| 24 | 24 | import { entry as edgeOnly } from "./edgeonly.ts"; |
| 25 | import { entry as traffic } from "./trafficview.ts"; | |
| 25 | 26 | import { entry as feed } from "./feed.ts"; |
| 26 | 27 | import { entry as forgeView } from "./forgeview.ts"; |
| 27 | 28 | import { entry as front } from "./front.ts"; |
| @@ -81,6 +82,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 81 | 82 | valves, |
| 82 | 83 | access, |
| 83 | 84 | edgeOnly, |
| 85 | traffic, | |
| 84 | 86 | audit, |
| 85 | 87 | secrets, |
| 86 | 88 | 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"; | ||
| 138 | 138 | import { labelsCommands } from "./labels.ts"; |
| 139 | 139 | import { appEnvCommands } from "./appenv.ts"; |
| 140 | 140 | import { autonomyCommands } from "./autonomy.ts"; |
| 141 | import { trafficCommands } from "./trafficview.ts"; | |
| 141 | 142 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 142 | 143 | import { finderScreen } from "./finder.ts"; |
| 143 | 144 | import { issuesScreen } from "./issues.ts"; |
| @@ -1456,6 +1457,7 @@ export function workspaceScreen( | ||
| 1456 | 1457 | ...iterationsCommands(f, ctx, repo), |
| 1457 | 1458 | ...appEnvCommands(f, ctx, repo), |
| 1458 | 1459 | ...autonomyCommands(f, ctx, repo), |
| 1460 | ...trafficCommands(f, ctx, repo), | |
| 1459 | 1461 | ], |
| 1460 | 1462 | get title() { |
| 1461 | 1463 | return sourceName(src); |
src/web/beacon.ts +6 −2
| @@ -8,16 +8,20 @@ | ||
| 8 | 8 | * being measured belongs to the page that was loaded. Nothing |
| 9 | 9 | * is sent under Do-Not-Track or with the `forge_measure=off` cookie; the |
| 10 | 10 | * 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`). | |
| 11 | 14 | */ |
| 12 | 15 | export const beaconJs = `!function(n,d,w){ |
| 13 | 16 | 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; | |
| 15 | 18 | function o(t,f,e){try{new PerformanceObserver(function(s){s.getEntries().forEach(f)}).observe(Object.assign({type:t,buffered:true},e))}catch(_){}} |
| 16 | 19 | o("largest-contentful-paint",function(e){l=e.startTime}); |
| 17 | 20 | o("layout-shift",function(e){e.hadRecentInput||(c+=e.value)}); |
| 18 | 21 | 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); | |
| 19 | 23 | 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}))} | |
| 21 | 25 | d.addEventListener("visibilitychange",function(){d.visibilityState=="hidden"&&s()}); |
| 22 | 26 | w.addEventListener("pagehide",s) |
| 23 | 27 | }(navigator,document,window)`; |
src/web/measure.ts +21 −1
| @@ -32,9 +32,22 @@ export interface Report { | ||
| 32 | 32 | readonly lcp: number | null; |
| 33 | 33 | readonly inp: number | null; |
| 34 | 34 | 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; | |
| 35 | 39 | } |
| 36 | 40 | |
| 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 | } | |
| 38 | 51 | |
| 39 | 52 | const bounded = (value: unknown, max: number): number | undefined => |
| 40 | 53 | typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= max |
| @@ -71,6 +84,10 @@ export function parseReport(text: string): Report | "malformed" { | ||
| 71 | 84 | // 0 is how the beacon says "not measured". |
| 72 | 85 | times[key] = value === 0 ? null : value; |
| 73 | 86 | } |
| 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"; | |
| 74 | 91 | return { |
| 75 | 92 | path, |
| 76 | 93 | ttfb: times["ttfb"] ?? null, |
| @@ -78,6 +95,9 @@ export function parseReport(text: string): Report | "malformed" { | ||
| 78 | 95 | lcp: times["lcp"] ?? null, |
| 79 | 96 | inp: times["inp"] ?? null, |
| 80 | 97 | cls, |
| 98 | ...(ref === undefined ? {} : { ref }), | |
| 99 | ...(q === undefined ? {} : { q }), | |
| 100 | ...(out === undefined ? {} : { out }), | |
| 81 | 101 | }; |
| 82 | 102 | } |
| 83 | 103 |
src/web/pages/settings.ts +2 −1
| @@ -796,7 +796,8 @@ export function appVisitorsView(v: AppVitals): Html { | ||
| 796 | 796 | : `p75 load each day over the last 30 days, latest ${ms(latest ?? undefined)}, range ${ms(Math.min(...known))} to ${ms(Math.max(...known))}`, |
| 797 | 797 | ); |
| 798 | 798 | 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"> | |
| 800 | 801 | Counted by the beacon the template ships, through the forge: no |
| 801 | 802 | address is kept, and a visitor is the same only within a day, so the |
| 802 | 803 | 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 { | ||
| 171 | 171 | ${top("Address prefix", tops.prefixes, t)} |
| 172 | 172 | ${top("Country", tops.countries, t)} |
| 173 | 173 | ${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> | |
| 174 | 177 | <p class="mt"> |
| 175 | 178 | <a href="/settings/traffic/scrapes">What was scraped, and who: reads, networks, captures</a> |
| 176 | 179 | </p> |
test/apps-measure.test.ts +11
| @@ -107,6 +107,17 @@ describe("a report through the proxy", () => { | ||
| 107 | 107 | expect((await report("notes", { ...good, app: ids["diary"] })).status).toBe(400); |
| 108 | 108 | }); |
| 109 | 109 | |
| 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 | ||
| 110 | 121 | test("keeps no address and refuses a foreign origin, a bad body and an opted-out visitor", async () => { |
| 111 | 122 | expect((await report("notes", good, { origin: "http://evil.test" })).status).toBe(403); |
| 112 | 123 | 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", () => { | ||
| 125 | 125 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 126 | 126 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 127 | 127 | ["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"], | |
| 128 | 129 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 129 | 130 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 130 | 131 | ["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", | ||
| 190 | 191 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 191 | 192 | ["can", "What I can do", "your role and what it allows here"], |
| 192 | 193 | ["bugs", "Bugs", "open bugs and the bug clock"], |
| 194 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], | |
| 193 | 195 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 194 | 196 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 195 | 197 | ["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 | }); |
new above
offline
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.