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.
This adds a sources view for the forge and for each app, with a path to a goal, outbound-click counts, a probe behind every number and a daily GitHub pull. The new tests pass, and so do `tsc`, `scripts/gate.ts`, and the openapi, design-audit, design-map, design-lint, measure and apps-measure suites. I ran those suites, not the whole suite, and the forge's check runs the full one on push. The `keep…
shipped production is serving it deploy b2c98db
This adds a sources view for the forge and for each app, with a path to a goal, outbound-click counts, a probe behind every number and a daily GitHub pull. The new tests pass, and so do tsc, scripts/gate.ts, and the openapi, design-audit, design-map, design-lint, measure and apps-measure suites. I ran those suites, not the whole suite, and the forge's check runs the full one on push. The keep_plan tool wasn't available to me, so the issue has no plan list.
What changed
- Recording: the page-view beacon now also sends the referrer, the query string and the host of the last external link clicked. The server keeps only:
- the referrer's host;
- utm_source, utm_medium and utm_campaign, restricted to a safe alphabet;
- a clicked host that is in the named set (github.com and espodev.com; FORGE_OUTBOUND_LINKS replaces it).
No cookie, no address, no third party. The visitor is the existing daily-salted hash. Forge pages are filed by route pattern, never by a path that could name a private repository.
- Off the request path: a beacon request only pushes a row onto a bounded in-memory buffer. A timer writes it every 10 seconds in one transaction (
src/perf/views_store.ts). The sources view reads it with one indexed query, and a test checks the query plan. - Scrapers: crawlers, headless browsers, HTTP libraries and empty user agents are flagged and shown under their own tab, separate from people. The page says how each was counted.
- The view:
/settings/traffic/sourcesis for admins, with?app=<id>for any app./:owner/:name/settings/app/trafficis for whoever administers the app's repository. It shows visits per day, sources, mediums, campaigns, landing pages, where people left and outbound clicks. Clicking a value narrows every other panel. The page states what it can't see. - Path to a goal: you name steps such as
/, /explore, out:github.com. The table shows visits and drop-off at each step, and the source filter sets where it starts. - Probe: every number links to a new admin endpoint,
GET /api/v1/admin/traffic/probe. It counts the number again in SQL by a different route and reports whether the two agree. It is documented in the OpenAPI contract. - GitHub: with
FORGE_GITHUB_TOKENandFORGE_GITHUB_REPOS=owner/name,…set, the forge pulls referrers, views and clones once a day intogithub_traffic. Answers are validated, a refusal is recorded without throwing, and the token is never stored. I addedapi.github.comto theforge.jsonegress list. - Docs and migration:
docs/features/traffic-sources.md, the audit entry indocs/design/audit.md, and migration20261108150000-traffic-viewswith its pin.
Not done
- Existing apps: apps already scaffolded keep their old
static/beacon.js, which still works but sends no referrer data until it is replaced with the template's copy. - No-script visitors: nothing counts them, so they stay invisible.
- Funnel recount: the probe recounts the path in the same order-matching logic as the page, but over the raw views instead of the built visits.
- GitHub pull untested live: it is tested with a fake fetch only, since this sandbox can't reach GitHub.
- Beacon SQL: the forge's existing
/_measuredoor still does its old per-request SQL for page vitals. I left that alone and kept my new recording buffered.
To try it: sign in as an admin and open /settings/traffic, then follow "Where people come from". Visit with ?utm_source=post&utm_campaign=x and leave a page to see a row appear within about 10 seconds. Click any number to see its probe.
🤖 Generated with Claude Code
The check failed on two gate rules that read commit messages, not code. I added both lines in a new commit (77c343a8) and pushed it. I didn't re-run the gate or any tests after pushing, so I haven't confirmed the rules now pass. The earlier code and tests are unchanged.
- "Why now": the owner asked for referrers, campaigns and a traffic path in #9427.
- "Try": sign in as admin on the preview, open
/settings/traffic/sources, and click any number to reach/api/v1/admin/traffic/probe.
The commit is empty, so it adds no files. I couldn't amend the earlier commit without a force push, which isn't allowed here.
I fixed the failing test and pushed the fix (d0ceda54).
The test "every GET page names session and render in Server-Timing" failed because the two new sources pages never reported a render phase. /settings/traffic/sources and /:owner/:name/settings/app/traffic now do, including the not-found case when a repo has no app.
After the change, test/server-timing-sweep.test.ts and test/traffic-sources.test.ts pass (16 tests), tsc reports nothing and scripts/gate.ts is clean. I did not re-run the rest of the fast tier here. The forge's check runs that on the push.
Changes requested. One blocker, plus smaller points.
- No terminal door (blocker). The pull adds no screen in
src/tui/for the sources view, its filters, the path-to-goal or the probe, andAGENTS.mdhas no entry for one. The web page and the terminal must offer the same reads, gated the same way: an admin sees the forge scope, a repository admin sees their app, and a guest sees nothing.docs/features/traffic-sources.mdshould name the terminal entry. The existingsrc/tui/screens/sources.tsis the unrelated followed-sources screen, so use a different name.
- Client-forged outbound clicks.
outcomes from the beacon body, so anyone can postout=github.comand inflate the outbound counts and anyout:goal step. Counting clicks reported by the visitor is unavoidable without server-side redirects. At minimum, say so on the page under what it cannot see, and indocs/features/traffic-sources.md.
- Memory cost of a read.
readVisitsloads up to 200,000 rows into memory for each page render and each probe call, and the probe recomputes it several times. Aggregate in SQL, or cache the result per scope and window for a short time.
- App-scope paths are client-supplied.
src/apps/measure.tsstoresreport.pathfrom the body, capped at 200 characters. A client can post unbounded distinct paths and fill the 50,000-row day cap with junk. The cap bounds the damage, but it lets one client crowd out real views. Count the cap per visitor, or normalise paths for apps.
- Tests and preview. I could not read the part of
test/traffic-sources.test.tsorsrc/web/pages/sources.tsthat the diff cut off. Make sure the tests cover the per-app authorization, so a non-admin gets 404, and that the new pages carry the security-floor headers. The preview redirected my token to sign-in (303), so I did not see the page render.
Review points 1–5 are addressed and pushed. The gate, design lint and tsc are clean. The traffic and apps-measure suites and the palette and hot-read suites pass here. I did not run the full suite; the forge runs it on the push.
What changed
- Terminal door (1). New
src/tui/screens/trafficview.ts, named apart from the followed-sources screen. It shows the page's figures: source, medium, campaign, landing, exit and outbound, the pages-per-visit line, and a path to a goal.
- Keys: w window, s people/scrapers, Enter narrows to the row under the cursor, c clears, t types path steps.
- Gates: an admin gets the palette entry "Traffic sources" for the forge, added to ENTRIES. A repository admin gets "<owner>/<name> — Traffic sources" while the repository is open, and only if the repository is an app. A guest is offered neither, and the screen draws nothing for them.
- Docs: docs/features/traffic-sources.md and AGENTS.md name the entry.
- Forged outbound clicks (2). The page's "What it cannot see" now says outbound clicks are reported by the visitor's browser and can be forged, so read them as a hint. The feature doc says the same, and so does the terminal screen.
- Memory cost (3).
readVisitsnow holds its result per scope and window for 15 seconds, andflushViewsdrops it when views are written. A page and its probe calls share one read. The probe still recounts in SQL, so it stays an independent check. - Client-supplied paths (4). The flush keeps at most 300 views per visitor per scope per day (
VISITOR_DAY_MAX). One client posting invented paths can no longer fill the 50,000-row day cap and crowd out real views. I did not normalise paths, because the per-visitor cap bounds the damage without hiding real pages. - Tests (5).
- New tests: the per-visitor cap, cache reuse and invalidation, the terminal gating, and a non-admin getting 404 on both pages. The admin page is checked for CSP, X-Content-Type-Options and Referrer-Policy.
- Preview 303: the page does not redirect an admin session; the 303 you saw was most likely your token being sent to sign-in. The page needs a session cookie, not just a bearer token.
Left out
- The terminal does not call the probe or show GitHub's own traffic; those stay on the web page.
- The terminal has no forge-admin view of a single app (
?app=). It has the forge scope and the per-app scope for a repository admin. - The new tests call
readableScopewith a stub session, not a full terminal session, so the screen's key handling is untested.
Try it: open the palette as an admin and choose "Traffic sources". Or open /settings/traffic/sources on the preview with a session cookie.
🤖 Generated with Claude Code
I fixed the two failing roster tests and pushed. test/palette-roster.test.ts now passes (15 of 15) and bun x tsc --noEmit is clean. I didn't run bun scripts/gate.ts, the other tests or the full suite here; the forge's check will run them on this push.
The traffic-sources work added a "Traffic sources" entry to the terminal palette for site admins. Both tests list the palette entries in order and didn't include it. The code was right and the test was wrong. I added the entry (traffic-sources, "Traffic sources", "where people come from and where they go, for the forge") before "Who can do what" in the site-admin person list and the site-admin agent list. Only the test file changed.
Round check since d0ceda54: the only change is the palette-roster test, which now lists the new traffic-sources entry. The code was right and the test was wrong. Earlier asks: (1) terminal door src/tui/screens/trafficview.ts, gated like the page, with its entry and docs/features/traffic-sources.md — met. (2) Outbound clicks can be forged, and the page, doc and terminal now say so — met. (3) readVisits is held 15s and dropped on flush — met. (4) 300 views per visitor per scope per day cap — met. (5) Tests for the cap, cache, terminal gating and non-admin 404 — met. Non-blocking: the terminal screen's key handling is untested (only readableScope is), and existing apps keep the old beacon until they copy the template's.
This adds a sources view for the forge and for each app, with a path to a goal, outbound-click counts, a probe behind every number and a daily GitHub pull. The new tests pass, and so do
tsc,scripts/gate.ts, and the openapi, design-audit, design-map, design-lint, measure and apps-measure suites. I ran those suites, not the whole suite, and the forge's check runs the full one on push. Thekeep_plantool wasn't available to me, so the issue has no plan list.What changed
- the referrer's host; -
utm_source,utm_mediumandutm_campaign, restricted to a safe alphabet; - a clicked host that is in the named set (github.comandespodev.com;FORGE_OUTBOUND_LINKSreplaces it).No cookie, no address, no third party. The visitor is the existing daily-salted hash. Forge pages are filed by route pattern, never by a path that could name a private repository.
src/perf/views_store.ts). The sources view reads it with one indexed query, and a test checks the query plan./settings/traffic/sourcesis for admins, with?app=<id>for any app./:owner/:name/settings/app/trafficis for whoever administers the app's repository. It shows visits per day, sources, mediums, campaigns, landing pages, where people left and outbound clicks. Clicking a value narrows every other panel. The page states what it can't see./, /explore, out:github.com. The table shows visits and drop-off at each step, and the source filter sets where it starts.GET /api/v1/admin/traffic/probe. It counts the number again in SQL by a different route and reports whether the two agree. It is documented in the OpenAPI contract.FORGE_GITHUB_TOKENandFORGE_GITHUB_REPOS=owner/name,…set, the forge pulls referrers, views and clones once a day intogithub_traffic. Answers are validated, a refusal is recorded without throwing, and the token is never stored. I addedapi.github.comto theforge.jsonegress list.docs/features/traffic-sources.md, the audit entry indocs/design/audit.md, and migration20261108150000-traffic-viewswith its pin.Not done
static/beacon.js, which still works but sends no referrer data until it is replaced with the template's copy./_measuredoor still does its old per-request SQL for page vitals. I left that alone and kept my new recording buffered.To try it: sign in as an admin and open
/settings/traffic, then follow "Where people come from". Visit with?utm_source=post&utm_campaign=xand leave a page to see a row appear within about 10 seconds. Click any number to see its probe.🤖 Generated with Claude Code
Closes #9427