Skip to content

Commit 64ba690

Browse files
ralyodioclaude
andauthored
stats API: say where the traffic came from (#279)
`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) <noreply@anthropic.com>
1 parent af0725b commit 64ba690

3 files changed

Lines changed: 56 additions & 1 deletion

File tree

‎lib/dashboard/stats-text.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type StatsAnswerish = {
1313
sources?: StatsItem[] | null;
1414
referrers?: StatsItem[] | null;
1515
pages?: StatsItem[] | null;
16+
countries?: StatsItem[] | null;
17+
cities?: StatsItem[] | null;
1618
};
1719

1820
const list = (v: StatsItem[] | null | undefined): StatsItem[] => (Array.isArray(v) ? v : []);
@@ -42,6 +44,12 @@ export function renderStats(
4244
section("Sources", list(answer.sources));
4345
section("Referrers", list(answer.referrers));
4446
section("Pages", list(answer.pages));
47+
// Last, but the two that settle an argument. A referrer is whatever the
48+
// client typed; the country is where the packets came from. Traffic that
49+
// reads as a broad audience in Sources and as a single city here is one
50+
// operator, not an audience.
51+
section("Countries", list(answer.countries));
52+
section("Cities", list(answer.cities));
4553

4654
// Nothing at all is a real answer, and the likeliest cause is worth naming.
4755
if (!(totals.pageviews ?? 0) && !(totals.events ?? 0) && !list(answer.sources).length) {

‎lib/tracker/apiStats.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,24 @@ export type ProjectRow = { id: string; name: string; url: string; tracker_enable
3333
* Deliberately not every panel: a CLI answer people read in a terminal is the
3434
* sources, the pages and the shape over time. Devices and browsers are a
3535
* different question and cost another query each.
36+
*
37+
* Geography is here because it answers a question the others cannot. Sources
38+
* and referrers describe what a caller SAYS it is, and both are trivially
39+
* forged; where the packets entered from is not. When traffic to one site
40+
* turned out to be almost entirely one city, that single fact identified it as
41+
* one operator on a cloud region rather than the scattered readership the
42+
* referrer panel implied — and nothing in the terminal could show it, because
43+
* these two panels rendered only in the dashboard. Two extra queries is a
44+
* cheap price for the panel that tells you who you are actually talking to.
3645
*/
37-
export const STATS_PANELS: PanelKey[] = ["series", "sources", "pages", "referrers"];
46+
export const STATS_PANELS: PanelKey[] = [
47+
"series",
48+
"sources",
49+
"pages",
50+
"referrers",
51+
"countries",
52+
"cities",
53+
];
3854

3955
export type ResolveResult =
4056
| { ok: true; project: ProjectRow }
@@ -123,6 +139,9 @@ export type StatsAnswer = {
123139
sources: ListItem[];
124140
referrers: ListItem[];
125141
pages: ListItem[];
142+
/** Where the traffic entered from. Unforgeable, unlike a referrer. */
143+
countries: ListItem[];
144+
cities: ListItem[];
126145
/**
127146
* The shape over time, present only with `detail`. It is what the totals were
128147
* summed from, so asking for it costs nothing extra.
@@ -241,6 +260,8 @@ export async function projectStats(
241260
sources: asList(panels.sources),
242261
referrers: asList(panels.referrers),
243262
pages: asList(panels.pages),
263+
countries: asList(panels.countries),
264+
cities: asList(panels.cities),
244265
};
245266
if (!detail) return answer;
246267

‎tests/dashboard-package-cli.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,30 @@ describe("renderStats", () => {
103103
const text = renderStats({ totals: { visitors: 0, pageviews: 0 } }, { range: "1d", who: "humans" });
104104
expect(text).toContain("Check the tag is on the page");
105105
});
106+
107+
it("prints where the traffic came from", () => {
108+
const text = renderStats(
109+
{
110+
project: { name: "site.com" },
111+
totals: { visitors: 10, pageviews: 40 },
112+
countries: [{ label: "Singapore (SG)", value: 38 }],
113+
cities: [{ label: "Singapore, SG", value: 38 }],
114+
},
115+
{ range: "1d", who: "all" },
116+
);
117+
expect(text).toContain("Countries");
118+
expect(text).toContain("Singapore (SG)");
119+
expect(text).toContain("Cities");
120+
});
121+
122+
it("omits the geo sections when the panels are empty", () => {
123+
// Same rule as every other section: an empty panel prints nothing rather
124+
// than an empty heading, so a quiet window stays readable.
125+
const text = renderStats(
126+
{ project: { name: "site.com" }, totals: { visitors: 1, pageviews: 1 }, pages: [{ label: "/", value: 1 }] },
127+
{ range: "1d", who: "humans" },
128+
);
129+
expect(text).not.toContain("Countries");
130+
expect(text).not.toContain("Cities");
131+
});
106132
});

0 commit comments

Comments
 (0)