Skip to content

Count people on the statistics dashboard, and fix what it mismeasured - #156

Merged
TeoSlayer merged 1 commit into
TeoSlayer:mainfrom
pstayets:feat/stats-uniques-funnel
Sep 15, 2026
Merged

TeoSlayer merged 1 commit into
TeoSlayer:mainfrom
pstayets:feat/stats-uniques-funnel

Conversation

@pstayets

@pstayets pstayets commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What changed

The private statistics dashboard counted events and could not tell a crawler from a customer, a returning user from a new one, or a docs reader from a 404. This PR fixes what was mismeasured, counts people, and lays the whole path from a first look to a first keystroke out as one funnel with each step saying what it counts.

Measurement fixes

  • The web app, CLI, Refstream and platforms documentation pages were counted as "Not found", because the page-view classifier only knew nine paths. Every documentation route, current or versioned, now resolves through the shared route resolver to its own target (docs_app, docs_cli, …). wrangler.example.jsonc routes those paths through the Worker too; a page served straight from the assets binding is never counted. The production wrangler.production.jsonc needs the same four run_worker_first entries.
  • Unknown paths that the site answers with the landing page are counted as unknown_path, apart from the landing page and apart from real 404s, which were never recorded at all (the tracker returned early on any non-OK response) and now are.
  • Clicks on "Sign up free", "Manage a team" and the footer's "Web app" link are counted (cta_click, one target per link). The click navigates as before; the report is a keepalive beacon.
  • The dashboard names things by what they are: "Installer fetched" versus "Install completed" (the binary the installer downloads last), "Opened in a browser" (a session's first viewer), "Typed from a browser" (a session's first remote keystroke), and every docs page by name.

People, counted as keyed hashes

The Worker computes SHA-256 over a secret salt, the connecting address and the browser family (the user agent with version numbers removed, so an update is the same person) and keeps twenty hex characters of it. That hash goes only to the dashboard's Durable Object, never to Analytics Engine, and only for events with a person behind them: site page views and clicks, installer and binary fetches, session creation (the machine) and viewer connections (the browser). The store keeps one row per person per day per surface and one row per person with first and last day, and forgets both 120 days after the person was last seen. Without STATS_VISITOR_SALT (16+ characters) nothing is hashed and the dashboard says so in a banner; every event count is unaffected.

The dashboard then shows unique, new and returning visitors, installers, CLI machines and viewers, and weekly cohort grids: of the machines that first started a session in a given week, how many started one again in each later week, and the same for site visitors.

Accounts, exact

The accounts app keeps the only exact count of people. It now records the days an account used the app (a last_seen_at on the membership that moves at most hourly, and one account_activity row per account per day, kept 400 days and deleted with the account) and answers GET /api/stats/accounts with aggregates only, to a bearer STATS_TOKEN (32+ characters). The Worker fetches that when APP_STATS_URL and APP_STATS_TOKEN are set and shows total, new and active accounts, new accounts per day, and sign-up cohorts. Nothing per person leaves the app; the response carries no uid or email, and a test asserts it.

Privacy policy (app/src/routes/Privacy.tsx): "Your account" now includes the days the account used the app; the analytics section describes the keyed hash, its 120-day life and that the key never leaves the server; retention and deletion lists mention the activity days; last-updated moves to 14 September. docs/self-hosting.md documents the four dashboard secrets.

Security and privacy considerations. No raw address, user agent, session id or URL is stored anywhere new; the hash is not reversible without the salt and is stored only in the private dashboard's own storage. Analytics Engine's schema is unchanged and still identifier-free. The accounts route is invisible until a token is configured, compares the token in constant time, and returns aggregates. The client event endpoint accepts a fixed set of targets and keeps its per-address limiter. No new dependencies.

Verification

  • Added or updated regression tests where behavior changed: document targets, unique surfaces, visitor hashing (tests/analytics.test.ts); the funnel, uniques, and retention cohort math (worker/stats-store.test.ts); the app's account figures and cohorts (app/server/routes/stats.test.ts), the route's auth and shape (app/server/app.test.ts), and activity tracking, deletion and purging on both stores (app/server/lib/store-conformance.test.ts)
  • Ran npm run check and npm run build:web: root typecheck, 106 tests and the installer, Docker entrypoint, deploy guard, landing SEO and mobile checks pass; the web build succeeds. In app/: typecheck clean, lint clean on every touched file, 850 tests pass
  • Ran go test -race ./... and go vet ./...: pass; gofmt -l empty (no Go changed)
  • Considered bearer-link, terminal-input, secret, and privacy implications: see above
  • Did not include real share links, terminal contents, credentials, or Cloudflare configuration

Not run: the store conformance suite against Postgres (npm run test:pg), no database on this machine. Migration 012 is additive and the new statements mirror touchToken and the comments table. The Durable Object's SQL runs only inside Workers, so its new queries are covered by the tests on the pure functions they feed and should get one look on a preview deployment.

Rendered the dashboard from a made-up snapshot to check the layout, with and without a salt.

Rebased onto main after #153 and #154 merged; the conflicts were in files both PRs extended (privacy policy, changelog, app store and route wiring) and were resolved by keeping both. The full app suite passes with both features together: 877 tests.

Changelog

Added and Fixed entries under Unreleased.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ueq619VZvd44TY7bF43KDR

@pstayets
pstayets requested a review from TeoSlayer as a code owner September 15, 2026 00:21
The dashboard counted events and could not tell a crawler from a
customer, a returning user from a new one, or a docs reader from a 404.

Fixes first. The web app, CLI, Refstream and platforms documentation
pages were counted as "Not found" because the page-view classifier only
knew nine paths; every documentation route, current or versioned, now
resolves to its own target, and the example Wrangler config routes those
paths through the Worker so they are seen at all. Paths the site answers
with the landing page are counted as unknown_path, apart from real 404s,
which were never recorded and now are. Clicks on the landing page's
sign-up links are counted.

People, as keyed hashes. The Worker hashes a secret salt with the
connecting address and the browser family (version numbers removed, so
an update is the same person) and keeps twenty hex characters. The hash
goes only to the dashboard's Durable Object, never to Analytics Engine,
and only for events with a person behind them. One row per person per
day per surface, forgotten 120 days after they were last seen. From
that: unique, new and returning visitors, installers, CLI machines and
viewers, weekly cohorts of who came back, and a funnel from a first look
to a first keystroke in which every step says what it counts and what
it is a share of. Without STATS_VISITOR_SALT nobody is hashed and the
dashboard says so.

Accounts, exactly. The app records the days an account used it and
answers aggregates only, to a bearer STATS_TOKEN, which the Worker reads
when APP_STATS_URL and APP_STATS_TOKEN are set: totals, new per day and
sign-up cohorts, with no uid or email in the response. The privacy
policy says what is kept and for how long.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ueq619VZvd44TY7bF43KDR
@pstayets
pstayets force-pushed the feat/stats-uniques-funnel branch from 31ce692 to 9bc8ef0 Compare September 15, 2026 00:28
@TeoSlayer
TeoSlayer merged commit c9bf42b into TeoSlayer:main Sep 15, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants