From 7ce7aa3f212ad9a9b4c5f16cb31e1bf46976eca0 Mon Sep 17 00:00:00 2001 From: Philip Stayetski Date: Tue, 15 Sep 2026 08:50:54 -0700 Subject: [PATCH 1/8] Say since when the dashboard counts people, and count no crawler as one The funnel put thirty days of page views beside one day of people: events are counted from the first event, people from the day STATS_VISITOR_SALT was set, and nothing said so. The snapshot now carries the earliest day people are counted from, and the funnel, headline tiles and footer name it whenever it falls inside the range. Crawlers that identify themselves were hashed and counted as people, while the funnel said they were not. They now count as views only. Production serves the web app, CLI, Refstream and platforms guides from the assets binding, so their views never reach the Worker and are not counted. The deploy script now refuses a production config that routes fewer paths through the Worker than wrangler.example.jsonc does. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 11 ++++ docs/self-hosting.md | 4 +- scripts/check-worker-routing.mjs | 83 +++++++++++++++++++++++++++++++ scripts/deploy-production.sh | 5 ++ scripts/test-deploy-production.sh | 26 +++++++++- shared/stats-snapshot.ts | 19 +++++++ shared/stats.ts | 7 +++ tests/analytics.test.ts | 9 +++- web/stats.ts | 31 +++++++++--- worker/analytics.ts | 11 +++- worker/stats-store.test.ts | 28 +++++++++++ worker/stats-store.ts | 5 ++ 12 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 scripts/check-worker-routing.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b2bd527..f774a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,17 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve as "Not found". Every documentation route, current or versioned, now has its own page-view target; unknown paths the site answers with the landing page are counted apart from real 404s, and real 404s are counted at all. +- The statistics dashboard says since when people have been counted. Event + counts run from the first event and people from the day the visitor salt was + set, so a 30-day range could show thirty days of views beside one day of + people. A people figure over fewer days than the count beside it now names + that day, in the funnel, the headline tiles and the footer. +- Crawlers that identify themselves are no longer counted as people. The + funnel said they were not, and they were. +- Deploying to production refuses a Wrangler config that serves a documentation + page from the assets binding instead of the Worker, since such a page is + never counted. Production served the web app, CLI, Refstream and platforms + pages that way. ### Changed diff --git a/docs/self-hosting.md b/docs/self-hosting.md index a5bb176..8a31b9a 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -68,7 +68,9 @@ Set `SHELL_ONLINE_SERVER` to the URL Wrangler prints. Add a `routes` entry to the copied config for a custom domain. The Worker path requires Durable Objects, Rate Limiting, Analytics Engine, and static assets. Keep every documentation path in `run_worker_first`: a page served straight from the -assets binding is never counted. +assets binding is never counted. `npm run deploy:production` refuses a +production config that routes fewer of these paths through the Worker than +`wrangler.example.jsonc` does. The private statistics dashboard is optional and configured with Worker secrets (`npx wrangler secret put `): diff --git a/scripts/check-worker-routing.mjs b/scripts/check-worker-routing.mjs new file mode 100644 index 0000000..543fe6d --- /dev/null +++ b/scripts/check-worker-routing.mjs @@ -0,0 +1,83 @@ +/* + * A page the Worker never sees is never counted. Static assets are served + * before the Worker runs unless their path is in `assets.run_worker_first`, + * so a deployment config that lists fewer paths than the example config + * quietly drops those pages from the statistics dashboard. Production once + * served the web app, CLI, Refstream and platforms guides that way, and + * "Visited the site" was short by every one of their views and readers. + * + * The example config is the reference: a deployment config must route at + * least what it routes. Run before building, so a bad config costs nothing. + * + * node scripts/check-worker-routing.mjs + */ +import { readFileSync } from "node:fs"; + +const [referencePath, deploymentPath] = process.argv.slice(2); +if (!referencePath || !deploymentPath) { + console.error("usage: check-worker-routing.mjs "); + process.exit(2); +} + +const reference = workerFirstPaths(referencePath); +const deployment = workerFirstPaths(deploymentPath); +const missing = deployment === true || reference === true + ? [] + : reference.filter((path) => !deployment.includes(path)); +if (missing.length > 0) { + console.error(`${deploymentPath} does not route these paths through the Worker, so their views would never be counted:`); + for (const path of missing) console.error(` ${path}`); + console.error(`Add them to assets.run_worker_first, as in ${referencePath}.`); + process.exit(1); +} +console.log(`${deploymentPath} routes every counted path through the Worker.`); + +/** The `assets.run_worker_first` list, or true when the config routes everything through the Worker. */ +function workerFirstPaths(path) { + let config; + try { + config = JSON.parse(stripJsonc(readFileSync(path, "utf8"))); + } catch (error) { + console.error(`${path}: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + const paths = config?.assets?.run_worker_first; + if (paths === true) return true; + if (!Array.isArray(paths) || !paths.every((entry) => typeof entry === "string")) { + console.error(`${path}: assets.run_worker_first must be a list of paths, or true`); + process.exit(1); + } + return paths; +} + +/** Comments and trailing commas outside strings, which is all JSONC adds to JSON. */ +function stripJsonc(source) { + let output = ""; + let index = 0; + while (index < source.length) { + const char = source[index]; + if (char === '"') { + const end = closingQuote(source, index); + output += source.slice(index, end + 1); + index = end + 1; + } else if (char === "/" && source[index + 1] === "/") { + const end = source.indexOf("\n", index); + index = end === -1 ? source.length : end; + } else if (char === "/" && source[index + 1] === "*") { + const end = source.indexOf("*/", index + 2); + index = end === -1 ? source.length : end + 2; + } else { + output += char; + index += 1; + } + } + return output.replace(/,(\s*[\]}])/g, "$1"); +} + +function closingQuote(source, start) { + for (let index = start + 1; index < source.length; index += 1) { + if (source[index] === "\\") index += 1; + else if (source[index] === '"') return index; + } + return source.length - 1; +} diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index f1fdded..0e37256 100755 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -13,6 +13,11 @@ if [ ! -f "$config" ]; then exit 1 fi +# A page served straight from the assets binding never reaches the Worker and +# is never counted. Refuse a config that routes fewer paths through the Worker +# than the example does, before anything is built. +node "$repository_root/scripts/check-worker-routing.mjs" "$repository_root/wrangler.example.jsonc" "$config" + # Wrangler resolves `main` and `assets.directory` beside its config file. A # private config kept in another checkout would otherwise deploy that other # checkout while this script builds the current one. Stage only the config diff --git a/scripts/test-deploy-production.sh b/scripts/test-deploy-production.sh index 27bd37b..d88b1c2 100755 --- a/scripts/test-deploy-production.sh +++ b/scripts/test-deploy-production.sh @@ -9,7 +9,9 @@ fake_bin="$test_root/bin" command_log="$test_root/commands" config="$test_root/wrangler.production.jsonc" mkdir -p "$fake_bin" -printf 'production-test-config\n' > "$config" +# A production config routes at least what the example routes through the +# Worker; the example itself, with a comment, stands in for one. +{ printf '// production-test-config\n'; cat "$repository_root/wrangler.example.jsonc"; } > "$config" cat > "$fake_bin/npm" <<'SCRIPT' #!/bin/sh @@ -28,7 +30,7 @@ case "$4" in "$SHELL_ONLINE_TEST_REPOSITORY_ROOT"/.wrangler.production.*.jsonc) ;; *) printf 'config was not staged beside the source: %s\n' "$4" >&2; exit 43 ;; esac -test "$(cat "$4")" = "production-test-config" +cmp -s "$4" "$SHELL_ONLINE_TEST_CONFIG" shift 4 printf 'npx wrangler deploy --config %s\n' "${*:+ $*}" >> "$SHELL_ONLINE_TEST_COMMAND_LOG" SCRIPT @@ -37,6 +39,7 @@ chmod 755 "$fake_bin/npm" "$fake_bin/npx" SHELL_ONLINE_TEST_COMMAND_LOG="$command_log" \ SHELL_ONLINE_TEST_REPOSITORY_ROOT="$repository_root" \ +SHELL_ONLINE_TEST_CONFIG="$config" \ SHELL_ONLINE_WRANGLER_CONFIG="$config" \ PATH="$fake_bin:$PATH" \ sh "$repository_root/scripts/deploy-production.sh" @@ -50,6 +53,7 @@ set +e SHELL_ONLINE_TEST_VERIFY_FAIL=1 \ SHELL_ONLINE_TEST_COMMAND_LOG="$command_log" \ SHELL_ONLINE_TEST_REPOSITORY_ROOT="$repository_root" \ +SHELL_ONLINE_TEST_CONFIG="$config" \ SHELL_ONLINE_WRANGLER_CONFIG="$config" \ PATH="$fake_bin:$PATH" \ sh "$repository_root/scripts/deploy-production.sh" @@ -69,4 +73,22 @@ if SHELL_ONLINE_TEST_COMMAND_LOG="$command_log" \ fi test ! -s "$command_log" +# A config that serves a documentation page from the assets binding would +# leave that page out of the statistics; the deploy must refuse it before +# building anything. +short_config="$test_root/short.jsonc" +grep -v '"/app/\*",' "$config" > "$short_config" +: > "$command_log" +if SHELL_ONLINE_TEST_COMMAND_LOG="$command_log" \ + SHELL_ONLINE_TEST_REPOSITORY_ROOT="$repository_root" \ + SHELL_ONLINE_TEST_CONFIG="$short_config" \ + SHELL_ONLINE_WRANGLER_CONFIG="$short_config" \ + PATH="$fake_bin:$PATH" \ + sh "$repository_root/scripts/deploy-production.sh" 2>"$test_root/short.err"; then + printf 'Deployment unexpectedly accepted a config that skips the Worker for /app/*.\n' >&2 + exit 1 +fi +grep -q '/app/\*' "$test_root/short.err" +test ! -s "$command_log" + echo "production deployment guard tests passed" diff --git a/shared/stats-snapshot.ts b/shared/stats-snapshot.ts index 386f3b5..32196f8 100644 --- a/shared/stats-snapshot.ts +++ b/shared/stats-snapshot.ts @@ -82,6 +82,8 @@ export interface StatsSnapshotRows { uniqueDays: UniqueDayRow[]; retention: RetentionRow[]; uniquesConfigured: boolean; + /** Midnight UTC of the earliest visitor day still kept, or null when there is none. */ + uniquesSince: number | null; } /** Midnight UTC of the day that contains `at`. */ @@ -342,11 +344,28 @@ function buildUniques(rows: StatsSnapshotRows): StatsUniques { return { configured: rows.uniquesConfigured, memoryDays: VISITOR_MEMORY_DAYS, + since: rows.uniquesConfigured ? rows.uniquesSince : null, surfaces, daily: [...days.values()].sort((left, right) => left.day - right.day), }; } +/** + * The day people counts start from, when that is after the range began, or + * null when people cover the whole range. Events are counted from the first + * event and people from the day the visitor salt was set, for at most + * VISITOR_MEMORY_DAYS, so a 30-day range can hold thirty days of events and + * one day of people. A people figure shown beside an event count over such a + * range has to say so, or 29,333 views next to 84 people reads as nonsense. + */ +export function peopleCountedSince( + uniques: Pick, + rangeStart: number, +): number | null { + if (!uniques.configured || uniques.since === null) return null; + return uniques.since > dayStart(rangeStart) ? uniques.since : null; +} + export function statsRangeStart( range: StatsRange, now: number, diff --git a/shared/stats.ts b/shared/stats.ts index 3f7e075..14da7c6 100644 --- a/shared/stats.ts +++ b/shared/stats.ts @@ -59,6 +59,13 @@ export interface StatsUniques { /** False until the Worker has a visitor salt; every count is then zero. */ configured: boolean; memoryDays: number; + /** + * Midnight UTC of the earliest day anyone was counted, or null when nobody + * has been. Events are counted from the first event and people from the + * day the salt was set, so a range can hold more days of events than of + * people; peopleCountedSince says when a figure has to say so. + */ + since: number | null; surfaces: Record; daily: StatsUniqueDay[]; } diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts index f55717d..0a7be3a 100644 --- a/tests/analytics.test.ts +++ b/tests/analytics.test.ts @@ -135,7 +135,7 @@ describe("analytics", () => { await expect(visitorKey("another-salt-of-some-length", "203.0.113.7", "Mozilla/5.0 (Macintosh) Chrome/129.0.0.0 Safari/537.36")).resolves.not.toBe(one); }); - it("counts nobody without a salt worth the name or without an address", async () => { + it("counts nobody without a salt worth the name, without an address, or behind a crawler", async () => { const request = new Request("https://shell.online/", { headers: { "CF-Connecting-IP": "203.0.113.7", "User-Agent": "curl/8.4.0" }, }); @@ -145,5 +145,12 @@ describe("analytics", () => { await expect(requestVisitor("short", request)).resolves.toBeUndefined(); await expect(requestVisitor("a-salt-long-enough-to-count", new Request("https://shell.online/"))).resolves.toBeUndefined(); await expect(requestVisitor("a-salt-long-enough-to-count", request)).resolves.toMatch(/^[a-f0-9]{20}$/); + const crawler = new Request("https://shell.online/", { + headers: { + "CF-Connecting-IP": "203.0.113.9", + "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + }, + }); + await expect(requestVisitor("a-salt-long-enough-to-count", crawler)).resolves.toBeUndefined(); }); }); diff --git a/web/stats.ts b/web/stats.ts index 8433f8a..de45127 100644 --- a/web/stats.ts +++ b/web/stats.ts @@ -6,6 +6,7 @@ import { type StatsSeriesPoint, type StatsSnapshot, } from "../shared/stats"; +import { peopleCountedSince } from "../shared/stats-snapshot"; import { RELEASE_CHECKSUMS_PATH, RELEASE_VERSION } from "../shared/release"; import "./stats.css"; @@ -333,6 +334,14 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { const people = snapshot.uniques; const site = people.surfaces.site; const cli = people.surfaces.cli; + /* + * People are counted from the day the salt was set, events from the first + * event. A people figure over fewer days than the count beside it says so, + * or thirty days of views next to one day of people reads as nonsense. + */ + const peopleSince = peopleCountedSince(people, snapshot.rangeStart); + /* Plain text: renderKpi escapes its detail. */ + const sinceNote = peopleSince === null ? "" : ` · counted since ${formatDay(peopleSince)}`; const ctaByLink = snapshot.targets .filter((metric) => metric.event === "cta_click") .map((metric) => ({ label: metric.target, value: metric.count })) @@ -357,7 +366,7 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { "Unique visitors", people.configured ? site.unique : "—", people.configured - ? `${integerFormatter.format(site.new)} new · ${integerFormatter.format(site.returning)} returning` + ? `${integerFormatter.format(site.new)} new · ${integerFormatter.format(site.returning)} returning${sinceNote}` : `${integerFormatter.format(metrics.landingViews)} landing views, people not counted`, snapshot.trend, "pageViews", @@ -375,7 +384,7 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { "Sessions started", metrics.sessionsStarted, people.configured - ? `from ${integerFormatter.format(cli.unique)} machine${cli.unique === 1 ? "" : "s"}, ${integerFormatter.format(cli.new)} new` + ? `from ${integerFormatter.format(cli.unique)} machine${cli.unique === 1 ? "" : "s"}, ${integerFormatter.format(cli.new)} new${sinceNote}` : `${integerFormatter.format(metrics.sessionsCreated)} created`, snapshot.trend, "sessions", @@ -408,7 +417,8 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void {
From a first look to a first keystroke

Funnel

${escapeHtml(rangeLabel)} - ${renderFunnel(snapshot)} + ${renderFunnel(snapshot, peopleSince)} + ${peopleSince === null ? "" : `

People have been counted since ${escapeHtml(formatDay(peopleSince))}; event counts run from the start of the range. Until the range begins after that day, a people figure covers fewer days than the count beside it.

`} ${renderUniquesStrip(snapshot)} @@ -506,6 +516,7 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void {
Showing ${escapeHtml(rangeLabel)}. ${snapshot.collectingSince ? `Collecting exact dashboard metrics since ${formatDate(snapshot.collectingSince)}.` : "Waiting for the first event."} + ${people.configured && people.since !== null ? `Counting people since ${escapeHtml(formatDay(people.since, "long"))}.` : ""} People are keyed hashes of address and browser family, forgotten ${people.memoryDays} days after they were last seen; a person seen again after that counts as new.
`; @@ -601,8 +612,9 @@ function renderTimeChart( `; } -function renderFunnel(snapshot: StatsSnapshot): string { +function renderFunnel(snapshot: StatsSnapshot, peopleSince: number | null): string { const steps = snapshot.funnel; + const since = peopleSince === null ? "" : ` since ${escapeHtml(formatDay(peopleSince))}`; const colors = ["#9ab7e8", "#8eafff", "#819de5", "#f4bd78", "#8eafff", "#75dac2", "#d7a6ff"]; const maximum = Math.max(1, ...steps.map((step) => step.count)); return ` @@ -618,7 +630,7 @@ function renderFunnel(snapshot: StatsSnapshot): string { return `
${escapeHtml(step.label)} - ${integerFormatter.format(step.count)}${step.unique === null ? "" : `${integerFormatter.format(step.unique)} ${step.unique === 1 ? "person" : "people"}`} + ${integerFormatter.format(step.count)}${step.unique === null ? "" : `${integerFormatter.format(step.unique)} ${step.unique === 1 ? "person" : "people"}${since}`}
${escapeHtml(share)} ${escapeHtml(step.note)} @@ -734,8 +746,15 @@ function renderAccounts(snapshot: StatsSnapshot): string { `; } +/** A dashboard day is a UTC day, so it is named in UTC wherever the reader is. */ +function formatDay(at: number, month: "short" | "long" = "short"): string { + return new Date(at).toLocaleDateString([], month === "long" + ? { month, day: "numeric", year: "numeric", timeZone: "UTC" } + : { month, day: "numeric", timeZone: "UTC" }); +} + function formatWeek(weekStart: number): string { - return new Date(weekStart).toLocaleDateString([], { month: "short", day: "numeric", timeZone: "UTC" }); + return formatDay(weekStart); } function renderDonut(items: StatsBreakdownItem[]): string { diff --git a/worker/analytics.ts b/worker/analytics.ts index fc7da5a..6a83ebb 100644 --- a/worker/analytics.ts +++ b/worker/analytics.ts @@ -149,12 +149,19 @@ export async function visitorKey(salt: string, address: string, userAgent: strin return Array.from(digest.subarray(0, 10), (byte) => byte.toString(16).padStart(2, "0")).join(""); } -/** The visitor hash for a request, or nothing when the Worker has no salt or the edge sent no address. */ +/** + * The visitor hash for a request, or nothing when the Worker has no salt, the + * edge sent no address, or the request came from a crawler. A crawler is a + * request to count, not a person: it stays in every event total and out of + * every people figure, which is what the dashboard says of it. + */ export async function requestVisitor(salt: unknown, request: Request): Promise { if (!hasVisitorSalt(salt)) return undefined; const address = request.headers.get("CF-Connecting-IP"); if (!address) return undefined; - return visitorKey(salt, address, request.headers.get("User-Agent") ?? ""); + const userAgent = request.headers.get("User-Agent") ?? ""; + if (classifyDevice(userAgent, request.headers.get("Sec-CH-UA-Mobile")) === "bot") return undefined; + return visitorKey(salt, address, userAgent); } /** diff --git a/worker/stats-store.test.ts b/worker/stats-store.test.ts index 628d83d..8408201 100644 --- a/worker/stats-store.test.ts +++ b/worker/stats-store.test.ts @@ -3,6 +3,8 @@ import { buildRetentionCohorts, buildStatsSnapshot, DAY_MS, + dayStart, + peopleCountedSince, STATS_PRESENCE_LEASE_MS, STATS_PRESENCE_REFRESH_MS, WEEK_MS, @@ -29,6 +31,7 @@ describe("statistics live presence", () => { uniqueDays: [], retention: [], uniquesConfigured: false, + uniquesSince: null, }, "all", now, collectingSince); expect(snapshot.metrics).toMatchObject({ @@ -56,6 +59,7 @@ describe("statistics live presence", () => { uniqueDays: [], retention: [], uniquesConfigured: false, + uniquesSince: null, }, "24h", now, now - 24 * 60 * 60 * 1_000); expect(snapshot.metrics.activeSessions).toBe(0); @@ -101,6 +105,7 @@ describe("people and the funnel", () => { ], retention: [], uniquesConfigured: true, + uniquesSince: dayStart(now - 20 * DAY_MS), }; it("keeps docs, unknown paths and 404s apart and counts people beside events", () => { @@ -139,9 +144,32 @@ describe("people and the funnel", () => { it("says when nobody is being counted", () => { const snapshot = buildStatsSnapshot({ ...rows, uniques: [], uniquesConfigured: false }, "7d", now, rangeStart); expect(snapshot.uniques.configured).toBe(false); + expect(snapshot.uniques.since).toBeNull(); expect(snapshot.funnel[0].unique).toBeNull(); expect(snapshot.uniques.surfaces.site).toEqual({ unique: 0, new: 0, returning: 0 }); }); + + /* + * The salt was set on the 14th and the dashboard opened on the 15th, on the + * 30-day range: thirty days of views beside one day of people. The snapshot + * has to carry the day people start from, and say when it is inside the + * range, or the funnel reads as 29,333 views from 84 people. + */ + it("says since when people have been counted, and whether that is inside the range", () => { + const covered = buildStatsSnapshot(rows, "7d", now, rangeStart); + expect(covered.uniques.since).toBe(dayStart(now - 20 * DAY_MS)); + expect(peopleCountedSince(covered.uniques, covered.rangeStart)).toBeNull(); + + const yesterday = dayStart(now - DAY_MS); + const partial = buildStatsSnapshot({ ...rows, uniquesSince: yesterday }, "30d", now, now - 30 * DAY_MS); + expect(partial.uniques.since).toBe(yesterday); + expect(peopleCountedSince(partial.uniques, partial.rangeStart)).toBe(yesterday); + + /* People are kept by day, so a range that starts inside their first day is covered. */ + expect(peopleCountedSince({ configured: true, since: dayStart(rangeStart) }, rangeStart + 60_000)).toBeNull(); + expect(peopleCountedSince({ configured: false, since: yesterday }, now - 30 * DAY_MS)).toBeNull(); + expect(peopleCountedSince({ configured: true, since: null }, now - 30 * DAY_MS)).toBeNull(); + }); }); describe("retention cohorts", () => { diff --git a/worker/stats-store.ts b/worker/stats-store.ts index a5f68cd..f2b8dc2 100644 --- a/worker/stats-store.ts +++ b/worker/stats-store.ts @@ -300,6 +300,10 @@ export class StatsStore extends DurableObject> { const collectingSince = this.sql.exec( "SELECT MIN(bucket) AS minimum FROM metric_hourly", ).one().minimum; + /* Taken after the purge, so it is the earliest day people can still be counted from. */ + const uniquesSince = this.sql.exec( + "SELECT MIN(day) AS minimum FROM visitor_days", + ).one().minimum; const rangeStart = statsRangeStart(range, now, collectingSince); const summary = this.sql.exec( `SELECT event, target, @@ -377,6 +381,7 @@ export class StatsStore extends DurableObject> { uniqueDays, retention, uniquesConfigured, + uniquesSince, }, range, now, From f6096aec75249abf6b1f97afd5575fd923c47663 Mon Sep 17 00:00:00 2001 From: Philip Stayetski Date: Tue, 15 Sep 2026 09:12:09 -0700 Subject: [PATCH 2/8] Lay the statistics dashboard out as a story, with crawlers kept apart The page now reads top to bottom: what is live this minute under the title, six headline figures each with its change against the period of equal length before, the funnel, then traffic, sessions, retention and accounts, every section opening with its finding in one sentence. Every figure about people leaves crawlers out and says so beside the step: page views by people, the installer run by curl or wget rather than read in a browser or crawled, installs completed on a person's machine. The store now returns each counted event split by device class and the previous period's totals, and the snapshot carries figures, audiences and a comparison built from them. A comparison is withheld when the period before reaches back past collection, since a comparison with an empty period says everything doubled, and people are compared only when they were counted for all of it. The raw totals stay in the ledger. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 7 + shared/stats-snapshot.ts | 173 ++++++++++++++++++--- shared/stats.ts | 68 +++++++++ web/stats.css | 67 +++++++++ web/stats.ts | 299 ++++++++++++++++++++++++++++++------- worker/stats-store.test.ts | 116 +++++++++++++- worker/stats-store.ts | 74 +++++++-- 7 files changed, 715 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f774a19..c8774d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,13 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve app can add exact account counts and sign-up retention when the two are linked. - Clicks on the landing page's Sign up free and Web app links are counted. +- The statistics dashboard reads top to bottom as a story: what is live now, + six headline figures each with its change against the period before, the + funnel, then traffic, sessions, retention and accounts, each section opening + with the finding in a sentence. Every figure about people leaves crawlers + out and says so beside the step: page views by people, the installer run by + curl or wget rather than read or crawled, installs completed on a person's + machine. The raw totals stay in the ledger. ### Fixed diff --git a/shared/stats-snapshot.ts b/shared/stats-snapshot.ts index 32196f8..d71aaef 100644 --- a/shared/stats-snapshot.ts +++ b/shared/stats-snapshot.ts @@ -4,7 +4,12 @@ import { VISITOR_MEMORY_DAYS, isUniqueSurface, type StatsAccounts, + type StatsAudience, + type StatsAudiences, type StatsBreakdownItem, + type StatsComparison, + type StatsFigures, + type StatsFunnelExclusion, type StatsFunnelStep, type StatsRange, type StatsRetentionCohort, @@ -70,8 +75,33 @@ export interface RetentionRow extends Record { day: number; } +/** One event's count for one device class in the range. */ +export interface AudienceRow extends Record { + event: string; + target: string; + device: string; + count: number; +} + +/** Distinct visitor hashes seen on one surface in a period. */ +export interface PeriodUniqueRow extends Record { + surface: string; + unique_count: number; +} + +/** The period of equal length before the range, for comparison. */ +export interface PreviousPeriodRows { + rangeStart: number; + summary: MetricSummaryRow[]; + byDevice: AudienceRow[]; + uniques: PeriodUniqueRow[]; +} + export interface StatsSnapshotRows { summary: MetricSummaryRow[]; + byDevice: AudienceRow[]; + /** Null on the all-time range. */ + previous: PreviousPeriodRows | null; trend: MetricTrendRow[]; devices: BreakdownRow[]; referrers: BreakdownRow[]; @@ -105,9 +135,7 @@ export function buildStatsSnapshot( rangeStart: number, accounts: StatsAccounts = null, ): StatsSnapshot { - const total = (event: string, target?: string): number => rows.summary - .filter((row) => row.event === event && (target === undefined || row.target === target)) - .reduce((sum, row) => sum + Number(row.count), 0); + const total = (event: string, target?: string): number => sumCounts(rows.summary, event, target); const ended = rows.summary.filter((row) => row.event === "session_ended"); const endedCount = ended.reduce((sum, row) => sum + Number(row.count), 0); const durationSum = ended.reduce((sum, row) => sum + Number(row.value_sum), 0); @@ -124,6 +152,8 @@ export function buildStatsSnapshot( const binaryDownloads = total("binary_download"); const trendStepMs = statsTrendStep(range, now - rangeStart); const uniques = buildUniques(rows); + const audiences = buildAudiences(rows.byDevice); + const figures = buildFigures(rows.summary, audiences); const metrics: StatsSnapshot["metrics"] = { activeSessions: Math.max(0, Number(rows.live.active_sessions)), @@ -170,7 +200,10 @@ export function buildStatsSnapshot( signup: ratio(ctaClicks, landingViews), installed: ratio(binaryDownloads, landingViews), }, - funnel: buildFunnel(metrics, uniques), + figures, + audiences, + previous: buildComparison(rows.previous, rangeStart, rows.collectingSince, uniques), + funnel: buildFunnel(figures, audiences, uniques), uniques, retention: { weeks: RETENTION_WEEKS, @@ -204,75 +237,171 @@ export function buildStatsSnapshot( }; } +function sumCounts(rows: MetricSummaryRow[], event: string, target?: string): number { + return rows + .filter((row) => row.event === event && (target === undefined || row.target === target)) + .reduce((sum, row) => sum + Number(row.count), 0); +} + +const AUDIENCE_EVENTS: Record boolean> = { + views: (event, target) => event === "page_view" && (target === "landing" || target.startsWith("docs")), + installer: (event) => event === "installer_download", + installs: (event) => event === "binary_download", + viewers: (event) => event === "viewer_connected", +}; + +/** Which audience a device class belongs to: the classifier's classes, folded to three that matter and a rest. */ +export function audienceOf(device: string): keyof StatsAudience { + if (device === "desktop" || device === "mobile" || device === "tablet") return "browsers"; + if (device === "cli") return "tools"; + if (device === "bot") return "crawlers"; + return "unknown"; +} + +export function buildAudiences(rows: AudienceRow[]): StatsAudiences { + const empty = (): StatsAudience => ({ browsers: 0, tools: 0, crawlers: 0, unknown: 0 }); + const audiences: StatsAudiences = { views: empty(), installer: empty(), installs: empty(), viewers: empty() }; + for (const row of rows) { + for (const key of Object.keys(AUDIENCE_EVENTS) as (keyof StatsAudiences)[]) { + if (AUDIENCE_EVENTS[key](row.event, row.target)) audiences[key][audienceOf(row.device)] += Number(row.count); + } + } + return audiences; +} + +/** Everything but crawlers: a binary went to a person's machine, whatever fetched it. */ +export function installsCompleted(installs: StatsAudience): number { + return installs.browsers + installs.tools + installs.unknown; +} + +export function buildFigures(summary: MetricSummaryRow[], audiences: StatsAudiences): StatsFigures { + return { + siteViews: audiences.views.browsers, + crawlerViews: audiences.views.crawlers, + ctaClicks: sumCounts(summary, "cta_click"), + installerRuns: audiences.installer.tools, + installs: installsCompleted(audiences.installs), + sessionsStarted: sumCounts(summary, "session_started"), + sharesOpened: sumCounts(summary, "share_opened"), + collaborations: sumCounts(summary, "collaboration_started"), + }; +} + +/** + * The period before the range, when there is one worth comparing with: not + * on the all-time range, and not when it reaches back before collection + * began, since a comparison with an empty period says everything doubled. + * People are compared only when they were counted for the whole of it. + */ +export function buildComparison( + previous: PreviousPeriodRows | null, + rangeStart: number, + collectingSince: number | null, + uniques: StatsUniques, +): StatsComparison | null { + if (!previous || collectingSince === null || previous.rangeStart < collectingSince) return null; + const audiences = buildAudiences(previous.byDevice); + const covered = uniques.configured && uniques.since !== null && uniques.since <= dayStart(previous.rangeStart); + let people: StatsComparison["people"] = null; + if (covered) { + people = Object.fromEntries(UNIQUE_SURFACES.map((surface) => [surface, 0])) as Record; + for (const row of previous.uniques) { + if (isUniqueSurface(row.surface)) people[row.surface] = Number(row.unique_count); + } + } + return { + rangeStart: previous.rangeStart, + rangeEnd: rangeStart, + figures: buildFigures(previous.summary, audiences), + people, + }; +} + /* * The path from a first look to a first keystroke, one row per step, each - * saying what it counts. A step's count is an event total; its unique figure - * is how many distinct people were behind it, on the surfaces that count - * people. The two are shown side by side rather than blended, because a - * hundred page views from one crawler and a hundred visitors are different - * news. + * saying what it counts and what it leaves out. A step's count is of requests + * that a person is plausibly behind; its unique figure is how many distinct + * people were, on the surfaces that count people. Crawlers are listed beside + * the step they were kept out of, because a hundred page views from one + * crawler and a hundred visitors are different news. */ export function buildFunnel( - metrics: StatsSnapshot["metrics"], + figures: StatsFigures, + audiences: StatsAudiences, uniques: StatsUniques, ): StatsFunnelStep[] { const people = (surface: UniqueSurface): number | null => uniques.configured ? uniques.surfaces[surface].unique : null; + const excluded = (entries: StatsFunnelExclusion[]): StatsFunnelExclusion[] => entries.filter((entry) => entry.count > 0); return [ { key: "visited", label: "Visited the site", - count: metrics.landingViews + metrics.docsViews, + count: figures.siteViews, unique: people("site"), - note: "Landing and documentation page views. Crawlers count as views, not as people.", + note: "Landing and documentation page views from a browser.", + excluded: excluded([ + { label: "by crawlers", count: audiences.views.crawlers }, + { label: "by tools", count: audiences.views.tools + audiences.views.unknown }, + ]), basis: null, }, { key: "signup", label: "Clicked Sign up", - count: metrics.ctaClicks, + count: figures.ctaClicks, unique: null, - note: "Any Sign up free or Web app link on the landing page.", + note: "Any Sign up free or Web app link on the landing page. Most accounts start elsewhere: the app, an invite, the CLI.", + excluded: [], basis: "visited", }, { key: "installer", - label: "Fetched the installer", - count: metrics.installs, + label: "Ran the installer", + count: figures.installerRuns, unique: people("install"), - note: "Requests for the install script. Reading it counts; so does piping it to sh.", + note: "The install script fetched by curl or wget, which is how it is run.", + excluded: excluded([ + { label: "read in a browser", count: audiences.installer.browsers }, + { label: "by crawlers", count: audiences.installer.crawlers }, + { label: "unknown", count: audiences.installer.unknown }, + ]), basis: "visited", }, { key: "installed", label: "Completed an install", - count: metrics.binaryDownloads, + count: figures.installs, unique: null, - note: "Release binaries served, the installer's last step. Homebrew and source builds are not in this number.", + note: "A release binary served, the installer's last step. Homebrew and source builds are not in this number.", + excluded: excluded([{ label: "by crawlers", count: audiences.installs.crawlers }]), basis: "installer", }, { key: "session", label: "Started a session", - count: metrics.sessionsStarted, + count: figures.sessionsStarted, unique: people("cli"), note: "A shell command connected its process to the relay. Not a share of the step before: sessions come from every install to date.", + excluded: [], basis: null, }, { key: "opened", label: "Opened it in a browser", - count: metrics.sharesOpened, + count: figures.sharesOpened, unique: people("viewer"), note: "Sessions whose link was opened at least once, by anyone, the owner included.", + excluded: [], basis: "session", }, { key: "typed", label: "Typed from a browser", - count: metrics.collaborations, + count: figures.collaborations, unique: null, note: "Sessions that received at least one keystroke from a browser.", + excluded: [], basis: "opened", }, ]; diff --git a/shared/stats.ts b/shared/stats.ts index 14da7c6..d95b4d2 100644 --- a/shared/stats.ts +++ b/shared/stats.ts @@ -70,6 +70,68 @@ export interface StatsUniques { daily: StatsUniqueDay[]; } +/** + * One event's requests by who made them, from the user agent. A browser is a + * person looking; a tool is a person's machine doing what it was told; a + * crawler is neither, and is kept out of every figure about people. + */ +export interface StatsAudience { + /** Desktop, tablet and phone browsers. */ + browsers: number; + /** curl, wget and the shell CLI. */ + tools: number; + /** Crawlers, monitors and headless browsers that say so. */ + crawlers: number; + /** No user agent, or one the classifier could not place. */ + unknown: number; +} + +export interface StatsAudiences { + /** Landing and documentation page views. */ + views: StatsAudience; + /** Requests for the install script. */ + installer: StatsAudience; + /** Release binaries served. */ + installs: StatsAudience; + /** Browser connections to a shared terminal. */ + viewers: StatsAudience; +} + +/** + * The figures the page is built on, each with the crawlers taken out, so a + * step of the funnel and its comparison with the period before mean the same + * thing. The raw event totals stay in metrics and the ledger. + */ +export interface StatsFigures { + /** Landing and documentation views from browsers: people looking. */ + siteViews: number; + /** The same pages fetched by self-identified crawlers. */ + crawlerViews: number; + ctaClicks: number; + /** Install script fetches by curl or wget: the installer actually run, not read. */ + installerRuns: number; + /** Release binaries served to anything but a crawler: an install completed. */ + installs: number; + sessionsStarted: number; + sharesOpened: number; + collaborations: number; +} + +/** The same figures for the period of equal length before the range. */ +export interface StatsComparison { + rangeStart: number; + rangeEnd: number; + figures: StatsFigures; + /** Distinct people per surface in that period, or null when people were not counted for all of it. */ + people: Record | null; +} + +/** Requests a funnel step leaves out, so the reader sees what was not counted and why. */ +export interface StatsFunnelExclusion { + label: string; + count: number; +} + export interface StatsFunnelStep { key: string; label: string; @@ -78,6 +140,8 @@ export interface StatsFunnelStep { unique: number | null; /** What the count is, in one sentence, so nobody has to guess. */ note: string; + /** What the count leaves out: crawler views, an installer read in a browser. */ + excluded: StatsFunnelExclusion[]; /** * The step this one is a share of, or null when it is its own population: * sessions in a range come from every install ever made, not from this @@ -158,6 +222,10 @@ export interface StatsSnapshot { /** Completed installs per landing view. */ installed: number; }; + figures: StatsFigures; + audiences: StatsAudiences; + /** Null on the all-time range, or when the period before is older than collection. */ + previous: StatsComparison | null; funnel: StatsFunnelStep[]; uniques: StatsUniques; retention: StatsRetention; diff --git a/web/stats.css b/web/stats.css index ab31ed4..6217a08 100644 --- a/web/stats.css +++ b/web/stats.css @@ -2035,3 +2035,70 @@ html.stats-document #app { .accounts-panel { padding: 20px 22px 18px; } + +/* The line under the title: what is live this minute, apart from any range. */ +.stats-live { + margin: 14px 0 0; + color: #9fb0cc; + font-size: 12.5px; + line-height: 1.5; +} + +.stats-live b { + color: #edf2fa; + font-weight: 560; + font-variant-numeric: tabular-nums; +} + +/* How a headline figure moved against the period before. */ +.kpi-delta { + display: inline-block; + margin-left: 8px; + padding: 2px 7px; + border-radius: 999px; + background: rgb(255 255 255 / 5%); + color: #8490a5; + font-size: 9.5px; + font-style: normal; + font-weight: 600; + letter-spacing: 0; + line-height: 1.3; + vertical-align: middle; + font-variant-numeric: tabular-nums; +} + +.kpi-delta.up { + background: rgb(117 218 194 / 12%); + color: #75dac2; +} + +.kpi-delta.down { + background: rgb(240 160 160 / 12%); + color: #f0a0a0; +} + +/* The finding before the numbers: one sentence at the top of a panel. */ +.panel-insight { + margin: -10px 0 16px; + color: #b3bfd2; + font-size: 12.5px; + line-height: 1.55; +} + +.funnel-step > .funnel-excluded { + color: #6e7a8f; + font-size: 10px; + font-style: normal; + font-variant-numeric: tabular-nums; + line-height: 1.4; +} + +.traffic-split .split-people i { background: #8eafff; } +.traffic-split .split-crawlers i { background: #f4bd78; } +.traffic-split .split-tools i { background: #a6b6d3; } +.traffic-split .split-page i { background: #6d7992; } + +.breakdown-panel > .cohort-empty { + margin-top: 12px; + font-size: 11px; +} diff --git a/web/stats.ts b/web/stats.ts index de45127..9223876 100644 --- a/web/stats.ts +++ b/web/stats.ts @@ -6,7 +6,7 @@ import { type StatsSeriesPoint, type StatsSnapshot, } from "../shared/stats"; -import { peopleCountedSince } from "../shared/stats-snapshot"; +import { DAY_MS, peopleCountedSince } from "../shared/stats-snapshot"; import { RELEASE_CHECKSUMS_PATH, RELEASE_VERSION } from "../shared/release"; import "./stats.css"; @@ -207,6 +207,7 @@ function renderAuthenticatedDashboard(root: HTMLElement): () => void {
Aggregate product signal

What shell.online
is doing.

+

Checking what is live…

Loading live metrics @@ -328,7 +329,11 @@ function renderAuthenticatedDashboard(root: HTMLElement): () => void { function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { const metrics = snapshot.metrics; + const figures = snapshot.figures; + const audiences = snapshot.audiences; + const previous = snapshot.previous; const rangeLabel = snapshot.range === "all" ? "all time" : `last ${snapshot.range}`; + const priorLabel = snapshot.range === "all" ? "" : `the ${snapshot.range} before`; const outcomes = snapshot.breakdowns.outcomes; const endedSessions = outcomes.reduce((sum, item) => sum + item.value, 0); const people = snapshot.uniques; @@ -342,10 +347,27 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { const peopleSince = peopleCountedSince(people, snapshot.rangeStart); /* Plain text: renderKpi escapes its detail. */ const sinceNote = peopleSince === null ? "" : ` · counted since ${formatDay(peopleSince)}`; + const totalViews = metrics.landingViews + metrics.docsViews; + const otherViews = audiences.views.tools + audiences.views.unknown; const ctaByLink = snapshot.targets .filter((metric) => metric.event === "cta_click") .map((metric) => ({ label: metric.target, value: metric.count })) .sort((left, right) => right.value - left.value); + const installsByPlatform = snapshot.targets + .filter((metric) => metric.event === "binary_download") + .map((metric) => ({ label: metric.target, value: metric.count })) + .sort((left, right) => right.value - left.value); + const installerAudience = [ + { label: "Piped to a shell (curl, wget)", value: audiences.installer.tools }, + { label: "Read in a browser", value: audiences.installer.browsers }, + { label: "Fetched by crawlers", value: audiences.installer.crawlers }, + { label: "No user agent", value: audiences.installer.unknown }, + ].filter((item) => item.value > 0); + const delta = (current: number, before: number | null | undefined): string => + renderDelta(current, before ?? null, priorLabel); + + renderLive(metrics); + container.innerHTML = ` ${people.configured ? "" : `

@@ -355,60 +377,70 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { `}

${renderKpi( - "Active now", - metrics.activeSessions, - `${integerFormatter.format(metrics.activeViewers)} viewer${metrics.activeViewers === 1 ? "" : "s"} connected now`, - snapshot.trend, - "sessions", - "blue", - )} - ${renderKpi( - "Unique visitors", + "Visitors", people.configured ? site.unique : "—", people.configured ? `${integerFormatter.format(site.new)} new · ${integerFormatter.format(site.returning)} returning${sinceNote}` - : `${integerFormatter.format(metrics.landingViews)} landing views, people not counted`, + : "people are not counted yet", snapshot.trend, "pageViews", "silver", + people.configured ? delta(site.unique, previous?.people?.site) : "", + )} + ${renderKpi( + "Views by people", + figures.siteViews, + figures.crawlerViews === 0 + ? "no crawler views in this range" + : `${integerFormatter.format(figures.crawlerViews)} more by crawlers, kept out`, + snapshot.trend, + "pageViews", + "blue", + delta(figures.siteViews, previous?.figures.siteViews), )} ${renderKpi( "Installs completed", - metrics.binaryDownloads, - `${integerFormatter.format(metrics.installs)} installer fetch${metrics.installs === 1 ? "" : "es"}`, + figures.installs, + figures.installerRuns === 0 + ? "no installer runs in this range" + : `${formatPercent(ratio(figures.installs, figures.installerRuns))} of ${integerFormatter.format(figures.installerRuns)} installer run${figures.installerRuns === 1 ? "" : "s"}`, snapshot.trend, "sessions", "amber", + delta(figures.installs, previous?.figures.installs), )} ${renderKpi( "Sessions started", - metrics.sessionsStarted, + figures.sessionsStarted, people.configured - ? `from ${integerFormatter.format(cli.unique)} machine${cli.unique === 1 ? "" : "s"}, ${integerFormatter.format(cli.new)} new${sinceNote}` + ? `on ${integerFormatter.format(cli.unique)} machine${cli.unique === 1 ? "" : "s"}, ${integerFormatter.format(cli.new)} new${sinceNote}` : `${integerFormatter.format(metrics.sessionsCreated)} created`, snapshot.trend, "sessions", "violet", + delta(figures.sessionsStarted, previous?.figures.sessionsStarted), )} ${renderKpi( "Opened in a browser", - metrics.sharesOpened, - metrics.sessionsStarted === 0 - ? "No sessions in this range" - : `${formatPercent(ratio(metrics.sharesOpened, metrics.sessionsStarted))} of sessions started`, + figures.sharesOpened, + figures.sessionsStarted === 0 + ? "no sessions in this range" + : `${formatPercent(ratio(figures.sharesOpened, figures.sessionsStarted))} of sessions started`, snapshot.trend, "shares", "green", + delta(figures.sharesOpened, previous?.figures.sharesOpened), )} ${renderKpi( "Typed from a browser", - metrics.collaborations, - metrics.sharesOpened === 0 - ? "No opened sessions yet" - : `${formatPercent(ratio(metrics.collaborations, metrics.sharesOpened))} of opened sessions`, + figures.collaborations, + figures.sharesOpened === 0 + ? "no opened sessions yet" + : `${formatPercent(ratio(figures.collaborations, figures.sharesOpened))} of opened sessions`, snapshot.trend, "collaborations", "pink", + delta(figures.collaborations, previous?.figures.collaborations), )}
@@ -417,11 +449,52 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void {
From a first look to a first keystroke

Funnel

${escapeHtml(rangeLabel)} +

${escapeHtml(funnelInsight(snapshot))}

${renderFunnel(snapshot, peopleSince)} ${peopleSince === null ? "" : `

People have been counted since ${escapeHtml(formatDay(peopleSince))}; event counts run from the start of the range. Until the range begins after that day, a people figure covers fewer days than the count beside it.

`} ${renderUniquesStrip(snapshot)} +
+
+
+
Who came

Traffic

+ ${integerFormatter.format(totalViews)} views +
+

${escapeHtml(trafficInsight(snapshot))}

+ ${renderTimeChart("traffic", snapshot.trend, TRAFFIC_SERIES, snapshot.range, true)} +
+ People ${integerFormatter.format(audiences.views.browsers)} + Crawlers ${integerFormatter.format(audiences.views.crawlers)} + Tools ${integerFormatter.format(otherViews)} + Landing ${integerFormatter.format(metrics.landingViews)} + Docs ${integerFormatter.format(metrics.docsViews)} + Shared terminals ${integerFormatter.format(metrics.terminalViews)} + Unknown paths ${integerFormatter.format(metrics.unknownPaths)} + 404s ${integerFormatter.format(metrics.notFoundViews)} +
+
+ ${renderBreakdown( + "Sources", + "Where landing visits came from", + snapshot.breakdowns.referrers, + "referrer", + "Landing page only. Most browsers send no referrer, so Direct is also everyone they hid.", + )} +
+ +
+ ${renderBreakdown("Pages", "Document views by page, every audience", snapshot.breakdowns.pages, "page")} + ${renderBreakdown("Devices", "Page views by device class", snapshot.breakdowns.devices, "device")} + ${renderBreakdown( + "Sign-up clicks", + "Which landing link was clicked", + ctaByLink, + "cta", + "Accounts also start in the app, from an invite, or from the CLI, none of which pass here.", + )} +
+
@@ -430,6 +503,7 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { ${ACTIVITY_SERIES.map((series) => `${series.label}`).join("")}
+

${escapeHtml(sessionsInsight(snapshot))}

${renderTimeChart("activity", snapshot.trend, ACTIVITY_SERIES, snapshot.range)} @@ -448,6 +522,26 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { +
+ ${renderBreakdown( + "Installer", + "Who fetched the install script", + installerAudience, + "download", + metrics.skillDownloads === 0 + ? "Only a fetch by curl or wget counts as a run." + : `Only a fetch by curl or wget counts as a run. The agent skill file was fetched ${integerFormatter.format(metrics.skillDownloads)} time${metrics.skillDownloads === 1 ? "" : "s"}.`, + )} + ${renderBreakdown( + "Installs by platform", + "Release binaries served", + installsByPlatform, + "download", + "Homebrew and source builds are not counted.", + )} + ${renderBreakdown("CLI clients", "Versions creating sessions", snapshot.breakdowns.clients, "client")} +
+
${renderCohorts( "Machines that came back", @@ -469,32 +563,8 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { ${renderAccounts(snapshot)} -
-
-
Attention

Traffic pulse

- ${integerFormatter.format(metrics.landingViews + metrics.docsViews + metrics.terminalViews)} views -
- ${renderTimeChart("traffic", snapshot.trend, TRAFFIC_SERIES, snapshot.range, true)} -
- Landing ${integerFormatter.format(metrics.landingViews)} - Docs ${integerFormatter.format(metrics.docsViews)} - Shared terminals ${integerFormatter.format(metrics.terminalViews)} - Unknown paths ${integerFormatter.format(metrics.unknownPaths)} - 404s ${integerFormatter.format(metrics.notFoundViews)} -
-
- -
- ${renderBreakdown("Acquisition", "Where landing visits came from", snapshot.breakdowns.referrers, "referrer")} - ${renderBreakdown("Pages", "Document views by page", snapshot.breakdowns.pages, "page")} - ${renderBreakdown("Sign-up clicks", "Which link was clicked", ctaByLink, "cta")} - ${renderBreakdown("Devices", "Browsers opening shell.online", snapshot.breakdowns.devices, "device")} - ${renderBreakdown("CLI clients", "Versions creating sessions", snapshot.breakdowns.clients, "client")} - ${renderBreakdown("Delivery", "Installer, skill and binary requests", snapshot.breakdowns.downloads, "download")} -
-
- Complete event ledgerEvery tracked aggregate + Complete event ledgerEvery tracked aggregate, every audience
@@ -514,9 +584,10 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void {
- Showing ${escapeHtml(rangeLabel)}. + Showing ${escapeHtml(rangeLabel)}${previous ? `, compared with ${escapeHtml(priorLabel)}` : ""}. ${snapshot.collectingSince ? `Collecting exact dashboard metrics since ${formatDate(snapshot.collectingSince)}.` : "Waiting for the first event."} ${people.configured && people.since !== null ? `Counting people since ${escapeHtml(formatDay(people.since, "long"))}.` : ""} + Crawlers are requests whose user agent says so; they stay in the ledger and out of every figure about people. People are keyed hashes of address and browser family, forgotten ${people.memoryDays} days after they were last seen; a person seen again after that counts as new.
`; @@ -525,6 +596,112 @@ function renderSnapshot(container: HTMLElement, snapshot: StatsSnapshot): void { bindChartInteraction(container.querySelector("#traffic-chart"), snapshot.trend, TRAFFIC_SERIES); } +/** What is happening this minute, under the page title, so it is not mistaken for a range figure. */ +function renderLive(metrics: StatsSnapshot["metrics"]): void { + const live = document.getElementById("stats-live"); + if (!live) return; + if (metrics.activeSessions === 0 && metrics.activeViewers === 0) { + live.textContent = "Nothing is live right now."; + return; + } + live.innerHTML = `Live now: ${integerFormatter.format(metrics.activeSessions)} session${metrics.activeSessions === 1 ? "" : "s"}, ${integerFormatter.format(metrics.activeViewers)} viewer${metrics.activeViewers === 1 ? "" : "s"} connected.`; +} + +/* + * One sentence per section, computed from the same figures the section + * shows, so the reader gets the finding before the numbers. Each guards its + * ratios: a range with no sessions says so instead of dividing by zero. + */ +function funnelInsight(snapshot: StatsSnapshot): string { + const figures = snapshot.figures; + const people = snapshot.uniques; + const parts: string[] = []; + if (figures.siteViews === 0) { + parts.push("No page views from browsers in this range."); + } else { + const visitors = people.configured ? ` from ${integerFormatter.format(people.surfaces.site.unique)} visitor${people.surfaces.site.unique === 1 ? "" : "s"}` : ""; + const crawlers = figures.crawlerViews === 0 ? "" : `, and ${integerFormatter.format(figures.crawlerViews)} by crawlers kept out`; + parts.push(`${integerFormatter.format(figures.siteViews)} views by people${visitors}${crawlers}.`); + const runs = figures.installerRuns; + parts.push(runs === 0 + ? "Nobody ran the installer." + : `${integerFormatter.format(runs)} installer run${runs === 1 ? "" : "s"}, ${integerFormatter.format(figures.installs)} completed (${formatPercent(ratio(figures.installs, runs))}).`); + } + if (figures.sessionsStarted === 0) { + parts.push("No session started."); + } else { + const machines = people.configured ? ` on ${integerFormatter.format(people.surfaces.cli.unique)} machine${people.surfaces.cli.unique === 1 ? "" : "s"}` : ""; + const opened = formatPercent(ratio(figures.sharesOpened, figures.sessionsStarted)); + const typed = figures.sharesOpened === 0 ? "" : `, and ${formatPercent(ratio(figures.collaborations, figures.sharesOpened))} of those were typed into`; + parts.push(`${integerFormatter.format(figures.sessionsStarted)} session${figures.sessionsStarted === 1 ? "" : "s"} started${machines}; ${opened} were opened in a browser${typed}.`); + } + return parts.join(" "); +} + +function trafficInsight(snapshot: StatsSnapshot): string { + const metrics = snapshot.metrics; + const audiences = snapshot.audiences.views; + const total = metrics.landingViews + metrics.docsViews; + if (total === 0) return "No landing or documentation views in this range."; + const parts = [`${formatPercent(ratio(audiences.crawlers, total))} of ${integerFormatter.format(total)} views were crawlers.`]; + const referrers = snapshot.breakdowns.referrers; + const referred = referrers.reduce((sum, item) => sum + item.value, 0); + if (referrers.length > 0 && referred > 0) { + parts.push(`Top source of landing visits: ${humanize(referrers[0].label)} (${formatPercent(ratio(referrers[0].value, referred))}).`); + } + if (metrics.unknownPaths > 0) { + parts.push(`${integerFormatter.format(metrics.unknownPaths)} request${metrics.unknownPaths === 1 ? "" : "s"} hit a path the site does not know and got the landing page.`); + } + return parts.join(" "); +} + +function sessionsInsight(snapshot: StatsSnapshot): string { + const metrics = snapshot.metrics; + const days = Math.max(1, (snapshot.generatedAt - snapshot.rangeStart) / DAY_MS); + if (metrics.sessionsCreated === 0) return "No session was created in this range."; + const perDay = metrics.sessionsStarted / days; + const parts = [ + `${integerFormatter.format(metrics.sessionsCreated)} created, ${integerFormatter.format(metrics.sessionsStarted)} started (${formatPercent(ratio(metrics.sessionsStarted, metrics.sessionsCreated))}), about ${perDay >= 10 ? integerFormatter.format(perDay) : numberFormatter.format(perDay)} a day.`, + ]; + const outcomes = snapshot.breakdowns.outcomes; + const ended = outcomes.reduce((sum, item) => sum + item.value, 0); + if (ended > 0 && outcomes.length > 0) { + parts.push(`${formatPercent(ratio(outcomes[0].value, ended))} of the ${integerFormatter.format(ended)} that ended ${outcomePhrase(outcomes[0].label)}; a session lasted ${formatDuration(metrics.averageDurationSeconds)} on average.`); + } + return parts.join(" "); +} + +/** A session outcome as the end of a sentence that begins "the sessions that ended". */ +function outcomePhrase(outcome: string): string { + const phrases: Record = { + task_exit: "did so because the task exited", + persistent_task_exit: "did so because a persistent task exited", + disconnected_timeout: "timed out after the host disconnected", + never_started: "never started", + expired: "expired", + }; + return phrases[outcome] ?? `ended as “${humanize(outcome)}”`; +} + +/** + * How a headline figure moved against the period before, as a chip beside + * it. A period with nothing in it makes any change infinite, so it says + * "new" instead; a change past tenfold is shown as a multiple. + */ +function renderDelta(current: number, before: number | null, priorLabel: string): string { + if (before === null || priorLabel === "") return ""; + const title = `${integerFormatter.format(before)} in ${priorLabel}`; + if (before === 0 && current === 0) return `same`; + if (before === 0) return `new`; + const change = (current - before) / before; + if (Math.abs(change) < 0.005) return `same`; + const tone = change > 0 ? "up" : "down"; + const text = Math.abs(change) >= 10 + ? `${change > 0 ? "▲" : "▼"}${numberFormatter.format(current / before)}×` + : `${change > 0 ? "▲" : "▼"}${Math.round(Math.abs(change) * 100)}%`; + return `${text}`; +} + function renderKpi( label: string, value: number | string, @@ -532,10 +709,11 @@ function renderKpi( trend: StatsSeriesPoint[], key: SeriesKey, tone: string, + delta = "", ): string { return `
-
${escapeHtml(label)}${typeof value === "number" ? integerFormatter.format(value) : escapeHtml(value)}
+
${escapeHtml(label)}${typeof value === "number" ? integerFormatter.format(value) : escapeHtml(value)}${delta}
${renderSparkline(trend.map((point) => point[key]))}
${escapeHtml(detail)}
@@ -627,12 +805,16 @@ function renderFunnel(snapshot: StatsSnapshot, peopleSince: number | null): stri : basis.count === 0 ? `no ${basis.label.toLowerCase()} to compare with` : `${formatPercent(ratio(step.count, basis.count))} of ${basis.label.toLowerCase()}`; + const excluded = step.excluded.length === 0 + ? "" + : `+ ${step.excluded.map((entry) => `${integerFormatter.format(entry.count)} ${escapeHtml(entry.label)}`).join(" · ")}`; return `
${escapeHtml(step.label)} ${integerFormatter.format(step.count)}${step.unique === null ? "" : `${integerFormatter.format(step.unique)} ${step.unique === 1 ? "person" : "people"}${since}`}
${escapeHtml(share)} + ${excluded} ${escapeHtml(step.note)}
`; @@ -787,6 +969,7 @@ function renderBreakdown( description: string, items: StatsBreakdownItem[], kind: string, + footnote = "", ): string { const maximum = Math.max(1, ...items.map((item) => item.value)); return ` @@ -804,6 +987,7 @@ function renderBreakdown( `).join("") || "No events in this range yet."} + ${footnote ? `

${escapeHtml(footnote)}

` : ""} `; } @@ -962,9 +1146,24 @@ function humanize(value: string): string { darwin_amd64: "macOS amd64", linux_arm64: "Linux arm64", linux_amd64: "Linux amd64", + bot: "Crawlers", + desktop: "Desktop", + mobile: "Phone", + tablet: "Tablet", + unknown: "Unknown", + google: "Google", + github: "GitHub", + reddit: "Reddit", + x: "X", + other: "Other sites", }; const normalized = value.replace(/-/g, "_"); if (aliases[normalized]) return aliases[normalized]; + const platform = normalized.match(/^(darwin|windows|linux|freebsd|openbsd|netbsd|dragonfly|solaris)_([a-z0-9]+)$/); + if (platform) { + const names: Record = { darwin: "macOS", windows: "Windows", linux: "Linux", freebsd: "FreeBSD", openbsd: "OpenBSD", netbsd: "NetBSD", dragonfly: "DragonFly", solaris: "Solaris" }; + return `${names[platform[1]]} ${platform[2]}`; + } return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase()); } diff --git a/worker/stats-store.test.ts b/worker/stats-store.test.ts index 8408201..1568e8a 100644 --- a/worker/stats-store.test.ts +++ b/worker/stats-store.test.ts @@ -27,6 +27,8 @@ describe("statistics live presence", () => { clients: [], live: { active_sessions: 1, active_viewers: 3 }, collectingSince, + byDevice: [], + previous: null, uniques: [], uniqueDays: [], retention: [], @@ -55,6 +57,8 @@ describe("statistics live presence", () => { clients: [], live: { active_sessions: 0, active_viewers: 0 }, collectingSince: now - 60_000, + byDevice: [], + previous: null, uniques: [], uniqueDays: [], retention: [], @@ -86,6 +90,23 @@ describe("people and the funnel", () => { metric("share_opened", "viewer", 6), metric("collaboration_started", "remote_input", 5), ], + /* The same totals by who made the requests; each event's rows add up to its summary row. */ + byDevice: [ + audience("page_view", "landing", "desktop", 700), + audience("page_view", "landing", "mobile", 60), + audience("page_view", "landing", "bot", 200), + audience("page_view", "landing", "cli", 9), + audience("page_view", "docs_app", "desktop", 30), + audience("page_view", "docs", "bot", 18), + audience("page_view", "unknown_path", "bot", 57), + audience("installer_download", "posix", "cli", 50), + audience("installer_download", "posix", "desktop", 4), + audience("installer_download", "posix", "bot", 8), + audience("binary_download", "darwin-arm64", "cli", 3), + audience("binary_download", "darwin-arm64", "bot", 1), + audience("viewer_connected", "viewer", "desktop", 12), + ], + previous: null, trend: [], devices: [], referrers: [], @@ -126,21 +147,99 @@ describe("people and the funnel", () => { expect(snapshot.breakdowns.pages.map((page) => page.label)).toEqual(["landing", "unknown_path", "docs_app", "docs", "not_found"]); }); - it("lays the funnel out from a first look to a first keystroke", () => { + it("tells browsers, tools and crawlers apart in every figure about people", () => { + const snapshot = buildStatsSnapshot(rows, "7d", now, rangeStart); + expect(snapshot.audiences.views).toEqual({ browsers: 790, tools: 9, crawlers: 218, unknown: 0 }); + expect(snapshot.audiences.installer).toEqual({ browsers: 4, tools: 50, crawlers: 8, unknown: 0 }); + expect(snapshot.audiences.installs).toEqual({ browsers: 0, tools: 3, crawlers: 1, unknown: 0 }); + expect(snapshot.figures).toEqual({ + siteViews: 790, + crawlerViews: 218, + ctaClicks: 12, + installerRuns: 50, + installs: 3, + sessionsStarted: 48, + sharesOpened: 6, + collaborations: 5, + }); + /* The raw totals are untouched: the ledger still shows every request. */ + expect(snapshot.metrics.landingViews + snapshot.metrics.docsViews).toBe(1017); + expect(snapshot.metrics.installs).toBe(62); + }); + + /* + * The funnel counts what a person is plausibly behind, and lists beside + * each step what it left out, so 969 landing views do not read as 969 + * visitors and 62 installer fetches do not read as 62 installs attempted. + */ + it("lays the funnel out from a first look to a first keystroke, crawlers beside it", () => { const snapshot = buildStatsSnapshot(rows, "7d", now, rangeStart); expect(snapshot.funnel.map((step) => [step.key, step.count, step.unique])).toEqual([ - ["visited", 1017, 400], + ["visited", 790, 400], ["signup", 12, null], - ["installer", 62, 40], - ["installed", 4, null], + ["installer", 50, 40], + ["installed", 3, null], ["session", 48, 9], ["opened", 6, 5], ["typed", 5, null], ]); + expect(snapshot.funnel.map((step) => step.excluded)).toEqual([ + [{ label: "by crawlers", count: 218 }, { label: "by tools", count: 9 }], + [], + [{ label: "read in a browser", count: 4 }, { label: "by crawlers", count: 8 }], + [{ label: "by crawlers", count: 1 }], + [], + [], + [], + ]); for (const step of snapshot.funnel) expect(step.note.length).toBeGreaterThan(20); expect(snapshot.funnel.map((step) => step.basis)).toEqual([null, "visited", "visited", "installer", null, "session", "opened"]); }); + it("compares with the period before, when there is one worth comparing with", () => { + const previous = { + rangeStart: rangeStart - 7 * DAY_MS, + summary: [ + metric("cta_click", "signup_hero", 6), + metric("session_started", "cli", 40), + metric("share_opened", "viewer", 4), + metric("collaboration_started", "remote_input", 2), + ], + byDevice: [ + audience("page_view", "landing", "desktop", 500), + audience("page_view", "landing", "bot", 300), + audience("installer_download", "posix", "cli", 40), + audience("binary_download", "darwin-arm64", "cli", 2), + ], + uniques: [{ surface: "site", unique_count: 300 }, { surface: "cli", unique_count: 8 }], + }; + const snapshot = buildStatsSnapshot({ ...rows, previous }, "7d", now, rangeStart); + expect(snapshot.previous).toEqual({ + rangeStart: rangeStart - 7 * DAY_MS, + rangeEnd: rangeStart, + figures: { + siteViews: 500, + crawlerViews: 300, + ctaClicks: 6, + installerRuns: 40, + installs: 2, + sessionsStarted: 40, + sharesOpened: 4, + collaborations: 2, + }, + people: { site: 300, cli: 8, viewer: 0, install: 0 }, + }); + + /* People counted from inside the previous period: figures compare, people do not. */ + const late = buildStatsSnapshot({ ...rows, previous, uniquesSince: dayStart(now - 10 * DAY_MS) }, "7d", now, rangeStart); + expect(late.previous?.figures.siteViews).toBe(500); + expect(late.previous?.people).toBeNull(); + + /* Collection began inside the previous period: an empty comparison would say everything doubled. */ + expect(buildStatsSnapshot({ ...rows, previous, collectingSince: now - 10 * DAY_MS }, "7d", now, rangeStart).previous).toBeNull(); + expect(buildStatsSnapshot({ ...rows, previous: null }, "all", now, now - 30 * DAY_MS).previous).toBeNull(); + }); + it("says when nobody is being counted", () => { const snapshot = buildStatsSnapshot({ ...rows, uniques: [], uniquesConfigured: false }, "7d", now, rangeStart); expect(snapshot.uniques.configured).toBe(false); @@ -210,6 +309,15 @@ describe("retention cohorts", () => { }); }); +function audience( + event: string, + target: string, + device: string, + count: number, +): Record & { event: string; target: string; device: string; count: number } { + return { event, target, device, count }; +} + function metric( event: string, target: string, diff --git a/worker/stats-store.ts b/worker/stats-store.ts index f2b8dc2..a206b8d 100644 --- a/worker/stats-store.ts +++ b/worker/stats-store.ts @@ -13,10 +13,13 @@ import { statsRangeStart, WEEK_MS, weekStart, + type AudienceRow, type BreakdownRow, type LivePresenceRow, type MetricSummaryRow, type MetricTrendRow, + type PeriodUniqueRow, + type PreviousPeriodRows, type RetentionRow, type UniqueDayRow, type UniqueSummaryRow, @@ -305,19 +308,32 @@ export class StatsStore extends DurableObject> { "SELECT MIN(day) AS minimum FROM visitor_days", ).one().minimum; const rangeStart = statsRangeStart(range, now, collectingSince); - const summary = this.sql.exec( - `SELECT event, target, - SUM(count) AS count, - SUM(value_sum) AS value_sum, - MAX(value_max) AS value_max, - SUM(auxiliary_sum) AS auxiliary_sum, - MAX(auxiliary_max) AS auxiliary_max - FROM metric_hourly - WHERE bucket >= ? - GROUP BY event, target - ORDER BY count DESC, event, target`, - rangeStart, - ).toArray(); + /* Buckets are hours and events at most ten minutes ahead of this clock, so nothing sits past tomorrow. */ + const rangeEnd = now + DAY_MS; + const summary = this.metricSummary(rangeStart, rangeEnd); + const byDevice = this.audienceRows(rangeStart, rangeEnd); + /* + * The period of equal length before the range, for comparison. People + * are counted by day, so its days are the whole days before the range's + * first day. The all-time range has nothing before it. + */ + let previous: PreviousPeriodRows | null = null; + if (range !== "all") { + const previousStart = rangeStart - (now - rangeStart); + previous = { + rangeStart: previousStart, + summary: this.metricSummary(previousStart, rangeStart), + byDevice: this.audienceRows(previousStart, rangeStart), + uniques: this.sql.exec( + `SELECT surface, COUNT(DISTINCT visitor) AS unique_count + FROM visitor_days + WHERE day >= ? AND day < ? + GROUP BY surface`, + dayStart(previousStart), + dayStart(rangeStart), + ).toArray(), + }; + } const trend = this.sql.exec( `SELECT bucket, event, SUM(count) AS count FROM metric_hourly @@ -371,6 +387,8 @@ export class StatsStore extends DurableObject> { const snapshot = buildStatsSnapshot( { summary, + byDevice, + previous, trend, devices, referrers, @@ -393,6 +411,36 @@ export class StatsStore extends DurableObject> { }); } + private metricSummary(from: number, to: number): MetricSummaryRow[] { + return this.sql.exec( + `SELECT event, target, + SUM(count) AS count, + SUM(value_sum) AS value_sum, + MAX(value_max) AS value_max, + SUM(auxiliary_sum) AS auxiliary_sum, + MAX(auxiliary_max) AS auxiliary_max + FROM metric_hourly + WHERE bucket >= ? AND bucket < ? + GROUP BY event, target + ORDER BY count DESC, event, target`, + from, + to, + ).toArray(); + } + + /** Who made the requests: the same totals, split by device class. */ + private audienceRows(from: number, to: number): AudienceRow[] { + return this.sql.exec( + `SELECT event, target, device, SUM(count) AS count + FROM metric_hourly + WHERE bucket >= ? AND bucket < ? + AND event IN ('page_view', 'installer_download', 'binary_download', 'viewer_connected') + GROUP BY event, target, device`, + from, + to, + ).toArray(); + } + private dimensionBreakdown( dimension: "device" | "client" | "referrer", rangeStart: number, From 78a471d6bd3290681f5c87c9463849443b42592d Mon Sep 17 00:00:00 2001 From: Philip Stayetski Date: Tue, 15 Sep 2026 09:43:53 -0700 Subject: [PATCH 3/8] Count intent before the terminal: copied commands, campaigns, the app as a source The funnel gains "Copied an install command" between visiting and running the installer, from the copy events the landing page already sends, and lists sessions created but never connected beside "Started a session". A named utm_source or ref on a landing link counts as the source when the browser hid the referrer, from a fixed list of names so the dimension stays small. Visits from the accounts app are their own source. Co-Authored-By: Claude Fable 5.1 --- shared/stats-snapshot.ts | 13 +++++++++++- shared/stats.ts | 4 ++++ tests/analytics.test.ts | 17 +++++++++++++++ web/stats.css | 2 +- web/stats.ts | 12 +++++++++++ worker/analytics.ts | 43 +++++++++++++++++++++++++++++++++++++- worker/stats-store.test.ts | 15 +++++++++++-- 7 files changed, 101 insertions(+), 5 deletions(-) diff --git a/shared/stats-snapshot.ts b/shared/stats-snapshot.ts index d71aaef..c8a5a7f 100644 --- a/shared/stats-snapshot.ts +++ b/shared/stats-snapshot.ts @@ -279,9 +279,11 @@ export function buildFigures(summary: MetricSummaryRow[], audiences: StatsAudien siteViews: audiences.views.browsers, crawlerViews: audiences.views.crawlers, ctaClicks: sumCounts(summary, "cta_click"), + installCopies: sumCounts(summary, "copy", "install") + sumCounts(summary, "copy", "brew_install") + sumCounts(summary, "copy", "source_build"), installerRuns: audiences.installer.tools, installs: installsCompleted(audiences.installs), sessionsStarted: sumCounts(summary, "session_started"), + neverStarted: sumCounts(summary, "session_ended", "never_started"), sharesOpened: sumCounts(summary, "share_opened"), collaborations: sumCounts(summary, "collaboration_started"), }; @@ -355,6 +357,15 @@ export function buildFunnel( excluded: [], basis: "visited", }, + { + key: "copied", + label: "Copied an install command", + count: figures.installCopies, + unique: null, + note: "The curl, Homebrew or source-build command copied on the landing page: intent, before a terminal is involved.", + excluded: [], + basis: "visited", + }, { key: "installer", label: "Ran the installer", @@ -383,7 +394,7 @@ export function buildFunnel( count: figures.sessionsStarted, unique: people("cli"), note: "A shell command connected its process to the relay. Not a share of the step before: sessions come from every install to date.", - excluded: [], + excluded: excluded([{ label: "created but never connected", count: figures.neverStarted }]), basis: null, }, { diff --git a/shared/stats.ts b/shared/stats.ts index d95b4d2..d7519c2 100644 --- a/shared/stats.ts +++ b/shared/stats.ts @@ -108,11 +108,15 @@ export interface StatsFigures { /** The same pages fetched by self-identified crawlers. */ crawlerViews: number; ctaClicks: number; + /** The curl, Homebrew or source-build command copied on the landing page: intent before the terminal. */ + installCopies: number; /** Install script fetches by curl or wget: the installer actually run, not read. */ installerRuns: number; /** Release binaries served to anything but a crawler: an install completed. */ installs: number; sessionsStarted: number; + /** Sessions that ended without the host ever connecting: a blocked WebSocket, usually. */ + neverStarted: number; sharesOpened: number; collaborations: number; } diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts index 0a7be3a..ca70e3e 100644 --- a/tests/analytics.test.ts +++ b/tests/analytics.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { binaryDownloadTarget, + campaignSource, classifyClient, classifyDevice, classifyReferrer, @@ -69,6 +70,22 @@ describe("analytics", () => { .toBe("internal"); expect(classifyReferrer("https://example.com/private/path", "https://shell.online")) .toBe("other"); + expect(classifyReferrer("https://app.shell.online/sessions", "https://shell.online")).toBe("app"); + expect(classifyReferrer("https://app.example.test/", "https://example.test")).toBe("app"); + }); + + it("takes a named campaign over a hidden referrer, and only a named one", () => { + expect(campaignSource(new URL("https://shell.online/?utm_source=hn&utm_medium=post"))).toBe("hacker_news"); + expect(campaignSource(new URL("https://shell.online/?ref=producthunt"))).toBe("product_hunt"); + expect(campaignSource(new URL("https://shell.online/?utm_source=
EventTargetCountValue sumMaximum