Fix the statistics funnel's measurement, lay the dashboard out as a story, and count what it could not see - #160
Merged
Alexgodoroja merged 11 commits intoSep 15, 2026
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
A session's first open now says whether anyone can type there and how long the link waited; the first keystroke says how long after the open it came; a viewer's disconnect says how long they stayed. Browsers turned away by a full, expired or unknown session are counted by reason, and a viewer refused input in a read-only session is counted once. The typed rate is over opened sessions that allow typing, with read-only opens listed beside it, and the dashboard shows typed rate by device and who was turned away. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The installer and the CLI on one machine were two different people, since the hash carried the user agent and curl is not shell/x. Machines are now keyed by address alone, under their own label so a browser at the same address stays apart, and the dashboard says of the machines that installed at least a week ago how many started a session within seven days. The privacy policy names the address-only hash for the installer and the CLI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both install scripts now send one request at their end, and on failure, carrying a single outcome word and the binary name: nothing else. Set SHELL_ONLINE_INSTALL_REPORT=0 to skip it; the script header and the README say so. The Worker records only the codes the scripts can send, and the dashboard shows how installs ended by the script's own account, which is the part of the install funnel that was invisible after the download. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The accounts app kept only the days an account was active, which is why thirty-seven accounts said nothing. It now counts, without an account, address or session: a machine linked, a session registered, a command sent, a vault created, an invite sent or accepted, feedback sent. Both stores keep one row per kind per day, purged with the activity days; migration 013 adds the table. The stats route sums them over the range, and the dashboard shows them under Accounts as things done, not as distinct accounts. Counting never changes an answer: a store that cannot count is nobody's problem on the request path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Durable Object's queries had no tests: they ran only inside Workers. Its SQL now lives in stats-database.ts over the two cursor methods it uses, and tests/stats-database.test.ts drives it from node:sqlite, the engine the object's storage runs: hour buckets and their sums, ranges and the period before, audiences, people by day with new and returning, the memory window, the install-to-session join, presence leases, cohort rows, and both request parsers. The object itself now only parses and delegates. Reviewing the rest turned up three things, fixed here: an install report from a crawler counted as an install; a resumed session's first open was timed from its original creation rather than this run's start; and the PowerShell installer could report twice on one failure. /install/report is routed through the Worker explicitly. The app's counters are asserted at every route that increments one, the stats route is checked to honour its range, and a store that cannot count is shown not to change an answer. The routing guard gets tests of its own, JSONC edge cases included. The page's insight sentences, delta chip and formatters move to shared/stats-copy.ts, with no DOM in them, and are tested there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 tasks
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.
wrangler.production.jsoncis gitignored, so this PR cannot change it. Before or with the deploy of this PR, add these four entries toassets.run_worker_firstin the production config, matchingwrangler.example.jsonc:Why: on production today,
HEAD https://shell.online/app/(and/cli/,/refstream/,/platforms/) comes back without the Worker's headers, so those pages are served straight from the assets binding and none of their views or readers are counted./,/docs/and/mobile/do reach the Worker.How to confirm it is done:
npm run deploy:productionnow runsscripts/check-worker-routing.mjsfirst and refuses a config missing any of these paths, so a deploy that gets past the guard has them. After deploying,curl -sI https://shell.online/app/should show acontent-security-policyheader andcache-control: no-store, like/docs/does.run_worker_firstentries added towrangler.production.jsonc/app/,/cli/,/refstream/,/platforms/return the Worker's headersWhat changed
Follow-up to #156. The 30-day funnel read as "29,333 views, 84 people; 4,632 installer fetches, 9 people; 2,591 sessions, 3 machines", and a sign-up count of zero sat above a dashboard showing 37 accounts. Three measurement faults and one page that did not explain itself.
Measurement fixes
STATS_VISITOR_SALTwas set, events from the first event, and nothing said so. A 30-day range showed a month of views beside hours of people. The snapshot now carries the earliest day people are counted from, and the funnel, headline tiles and footer say "counted since " whenever that day falls inside the range./app/,/cli/,/refstream/and/platforms/are served straight from the assets binding (no Worker headers on the live responses), so their views and readers are not counted. The deploy script now refuses a production config that routes fewer paths through the Worker thanwrangler.example.jsoncdoes, with a test. The production config change itself is the required step at the top of this PR.The page, laid out as a story
Data behind it. The store returns each counted event split by device class and the previous period's summary, device split and distinct visitors; the snapshot builds
figures,audiencesandpreviousfrom them. Analytics Engine's schema is unchanged. No new dependencies.What the funnel could not see, now counted
Five commits, one per area, each with tests:
utm_sourceorrefon a landing link counts as the source when the browser hid the referrer, from a fixed allowlist of names; the web app is its own source.viewer_rejected); a viewer refused input in a read-only session is counted once (input_denied). The typed share is over opened sessions that allow typing. The page shows typed rate by device and who was turned away.shell/x). Machines are now keyed by address alone, under their own label so a browser at the same address stays apart, and the funnel says of the machines that installed at least a week ago how many started a session within seven days. Privacy policy updated. Note: rows recorded before this deploy used the old key, so this figure accrues from the deploy on.ok,unsupported_arch,checksum_mismatch, ...) and the binary name, nothing else.SHELL_ONLINE_INSTALL_REPORT=0skips it; the script header and README say so. The Worker records only the codes the scripts can send; the page shows how installs ended by the script's own account. This is a telemetry decision: it is its own commit and can be dropped alone.013_app_events.sqladds the table (applied on start, like the others). The Worker accepts an app that does not send events yet. The page shows them under Accounts as things done, not distinct accounts.QA review
A pass over the server-side code with tests as the deliverable. Three defects found and fixed:
Failreports its reason, then the catch reported "failed"). A flag makes the catch report only when nothing has.Coverage added, server side first:
worker/stats-database.tsover the two cursor methods they use, andtests/stats-database.test.tsdrives them fromnode:sqlite(Node 22, which CI runs): hour buckets with sums and maxima, a range versus the period before versus all time, audience splits and ranked dimensions, people once per day per surface with new versus returning and the "since" day, the 120-day memory window, the install-to-session join with every branch (within the window, too late, too fresh, never, already had the CLI, before the range), presence leases, cohort rows, and both request parsers with their refusals. The object now only parses requests and delegates.7d, inall). A store whoserecordAppEventrejects still answers 201.//inside a string in the JSONC, a missing path named in the error,run_worker_first: true, unreadable and shapeless configs, usage.shared/stats-copy.tswith no DOM in it, and is tested for the full sentences on a fixture and every zero state.worker/stats-store.test.ts(audience folding, unknown device classes, non-audience events) andtests/analytics.test.ts(the install report's outcome allowlist).Root suite: 113 tests before this pass, 138 after.
Verification
worker/stats-store.test.ts); crawlers get no visitor hash (tests/analytics.test.ts); the deploy guard refuses a config that skips the Worker for/app/*(scripts/test-deploy-production.sh)npm run checkandnpm run build:web: typecheck clean, 138 tests and the installer scenarios (including the new outcome-report scenario against a mock curl, on and off), Docker entrypoint, deploy guard, landing SEO and mobile checks pass; the web build succeedsapp/: typecheck clean, lint clean on every touched file, 901 tests pass (3 skipped: Postgres). Store conformance covers the new counters on the memory store; the Postgres methods mirrortouchMembershipand are covered by the same suite undernpm run test:pg, not run here (no database)install.ps1(no PowerShell on this machine). Its change is aReportfunction called fromFailand at the end, wrapped in try/catch so it cannot change how an install goesNot run: the Durable Object's new SQL runs only inside Workers; the queries mirror the existing summary query with a bounded bucket range and are covered by the tests on the pure functions they feed. One look on a preview deployment is worth it.
Changelog
Added and Fixed entries under Unreleased.
🤖 Generated with Claude Code