From 216c7d815d6381c5f6a4b9628c440c383864c25a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 14:30:25 +0000 Subject: [PATCH] stats API: say where the traffic came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crawlproof stats` could show what a caller SAYS it is — its source, its referrer, the pages it asked for — and every one of those is set by the client. Where the packets entered from is not, and that panel rendered only in the dashboard, so the terminal could not settle the one question that distinguishes an audience from an operator. It matters more than it sounds. Traffic that reads as a broad readership in Sources and resolves to a single city here is one machine, and a referrer panel full of your own hostname will never say so. `countries` and `cities` already existed in PANEL_KEYS and already had their RPCs; they were simply not in STATS_PANELS, so the API never asked for them. Two extra queries per call, which the panel comment now justifies rather than leaving to be rediscovered. Both CLIs share lib/dashboard/stats-text, so rendering is one change. Empty panels print nothing, like every other section. Co-Authored-By: Claude Opus 5 (1M context) --- lib/dashboard/stats-text.ts | 8 ++++++++ lib/tracker/apiStats.ts | 23 ++++++++++++++++++++++- tests/dashboard-package-cli.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/dashboard/stats-text.ts b/lib/dashboard/stats-text.ts index fb90f04..fe642b8 100644 --- a/lib/dashboard/stats-text.ts +++ b/lib/dashboard/stats-text.ts @@ -13,6 +13,8 @@ export type StatsAnswerish = { sources?: StatsItem[] | null; referrers?: StatsItem[] | null; pages?: StatsItem[] | null; + countries?: StatsItem[] | null; + cities?: StatsItem[] | null; }; const list = (v: StatsItem[] | null | undefined): StatsItem[] => (Array.isArray(v) ? v : []); @@ -42,6 +44,12 @@ export function renderStats( section("Sources", list(answer.sources)); section("Referrers", list(answer.referrers)); section("Pages", list(answer.pages)); + // Last, but the two that settle an argument. A referrer is whatever the + // client typed; the country is where the packets came from. Traffic that + // reads as a broad audience in Sources and as a single city here is one + // operator, not an audience. + section("Countries", list(answer.countries)); + section("Cities", list(answer.cities)); // Nothing at all is a real answer, and the likeliest cause is worth naming. if (!(totals.pageviews ?? 0) && !(totals.events ?? 0) && !list(answer.sources).length) { diff --git a/lib/tracker/apiStats.ts b/lib/tracker/apiStats.ts index 5403365..242d328 100644 --- a/lib/tracker/apiStats.ts +++ b/lib/tracker/apiStats.ts @@ -33,8 +33,24 @@ export type ProjectRow = { id: string; name: string; url: string; tracker_enable * Deliberately not every panel: a CLI answer people read in a terminal is the * sources, the pages and the shape over time. Devices and browsers are a * different question and cost another query each. + * + * Geography is here because it answers a question the others cannot. Sources + * and referrers describe what a caller SAYS it is, and both are trivially + * forged; where the packets entered from is not. When traffic to one site + * turned out to be almost entirely one city, that single fact identified it as + * one operator on a cloud region rather than the scattered readership the + * referrer panel implied — and nothing in the terminal could show it, because + * these two panels rendered only in the dashboard. Two extra queries is a + * cheap price for the panel that tells you who you are actually talking to. */ -export const STATS_PANELS: PanelKey[] = ["series", "sources", "pages", "referrers"]; +export const STATS_PANELS: PanelKey[] = [ + "series", + "sources", + "pages", + "referrers", + "countries", + "cities", +]; export type ResolveResult = | { ok: true; project: ProjectRow } @@ -123,6 +139,9 @@ export type StatsAnswer = { sources: ListItem[]; referrers: ListItem[]; pages: ListItem[]; + /** Where the traffic entered from. Unforgeable, unlike a referrer. */ + countries: ListItem[]; + cities: ListItem[]; /** * The shape over time, present only with `detail`. It is what the totals were * summed from, so asking for it costs nothing extra. @@ -241,6 +260,8 @@ export async function projectStats( sources: asList(panels.sources), referrers: asList(panels.referrers), pages: asList(panels.pages), + countries: asList(panels.countries), + cities: asList(panels.cities), }; if (!detail) return answer; diff --git a/tests/dashboard-package-cli.test.ts b/tests/dashboard-package-cli.test.ts index b15273f..9cab7d8 100644 --- a/tests/dashboard-package-cli.test.ts +++ b/tests/dashboard-package-cli.test.ts @@ -103,4 +103,30 @@ describe("renderStats", () => { const text = renderStats({ totals: { visitors: 0, pageviews: 0 } }, { range: "1d", who: "humans" }); expect(text).toContain("Check the tag is on the page"); }); + + it("prints where the traffic came from", () => { + const text = renderStats( + { + project: { name: "site.com" }, + totals: { visitors: 10, pageviews: 40 }, + countries: [{ label: "Singapore (SG)", value: 38 }], + cities: [{ label: "Singapore, SG", value: 38 }], + }, + { range: "1d", who: "all" }, + ); + expect(text).toContain("Countries"); + expect(text).toContain("Singapore (SG)"); + expect(text).toContain("Cities"); + }); + + it("omits the geo sections when the panels are empty", () => { + // Same rule as every other section: an empty panel prints nothing rather + // than an empty heading, so a quiet window stays readable. + const text = renderStats( + { project: { name: "site.com" }, totals: { visitors: 1, pageviews: 1 }, pages: [{ label: "/", value: 1 }] }, + { range: "1d", who: "humans" }, + ); + expect(text).not.toContain("Countries"); + expect(text).not.toContain("Cities"); + }); });