Count people on the statistics dashboard, and fix what it mismeasured - #156
Merged
Merged
Conversation
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
force-pushed
the
feat/stats-uniques-funnel
branch
from
September 15, 2026 00:28
31ce692 to
9bc8ef0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
docs_app,docs_cli, …).wrangler.example.jsoncroutes those paths through the Worker too; a page served straight from the assets binding is never counted. The productionwrangler.production.jsoncneeds the same fourrun_worker_firstentries.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.cta_click, one target per link). The click navigates as before; the report is a keepalive beacon.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_aton the membership that moves at most hourly, and oneaccount_activityrow per account per day, kept 400 days and deleted with the account) and answersGET /api/stats/accountswith aggregates only, to a bearerSTATS_TOKEN(32+ characters). The Worker fetches that whenAPP_STATS_URLandAPP_STATS_TOKENare 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.mddocuments 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
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)npm run checkandnpm run build:web: root typecheck, 106 tests and the installer, Docker entrypoint, deploy guard, landing SEO and mobile checks pass; the web build succeeds. Inapp/: typecheck clean, lint clean on every touched file, 850 tests passgo test -race ./...andgo vet ./...: pass;gofmt -lempty (no Go changed)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 mirrortouchTokenand 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