Skip to content

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 into
TeoSlayer:mainfrom
pstayets:fix/stats-funnel-measurement
Sep 15, 2026
Merged

Alexgodoroja merged 11 commits into
TeoSlayer:mainfrom
pstayets:fix/stats-funnel-measurement

Conversation

@pstayets

@pstayets pstayets commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

⚠️ Required deployment step (cannot be done from this PR)

wrangler.production.jsonc is gitignored, so this PR cannot change it. Before or with the deploy of this PR, add these four entries to assets.run_worker_first in the production config, matching wrangler.example.jsonc:

"/app/*",
"/cli/*",
"/refstream/*",
"/platforms/*"

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:production now runs scripts/check-worker-routing.mjs first 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 a content-security-policy header and cache-control: no-store, like /docs/ does.

  • Four run_worker_first entries added to wrangler.production.jsonc
  • Deployed, and /app/, /cli/, /refstream/, /platforms/ return the Worker's headers

What 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

  • People were counted from the day STATS_VISITOR_SALT was 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.
  • Crawlers were counted as people. The funnel note said they were not; the Worker hashed every request with an address. Self-identified crawlers now get no visitor hash. They stay in every event total.
  • Four documentation pages never reached the Worker in production. /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 than wrangler.example.jsonc does, 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

  • Under the title: what is live this minute, apart from any range.
  • Six headline figures, each with a chip showing its change against the period of equal length before (withheld on the all-time range, when the period before reaches back past collection, or for people that were not counted for all of it).
  • The funnel counts what a person is plausibly behind and lists beside each step what it left out: "Visited the site" is page views from browsers, with crawler and tool views beside it; "Ran the installer" is fetches by curl or wget, with reads in a browser and crawler fetches beside it; "Completed an install" is binaries served to anything but a crawler. The "Clicked Sign up" note says that most accounts start in the app, from an invite, or from the CLI, none of which pass through the landing page.
  • Traffic, sessions, retention and accounts follow, each section opening with its finding in one sentence computed from the same figures it shows. Breakdowns carry a footnote where the number is easy to misread (Direct includes every hidden referrer; Homebrew and source builds are not installs here).
  • The raw event totals stay in the ledger, untouched.

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, audiences and previous from 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:

  • Intent before the terminal. "Copied an install command" is a funnel step, from the copy events the landing page already sent. Sessions created but never connected are listed 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 allowlist of names; the web app is its own source.
  • After a link is opened. The first open records whether typing is allowed and how long the link waited; the first keystroke, how long after the open it came; a viewer's disconnect, how long they stayed. Browsers turned away by a full, expired or unknown session are counted by reason (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.
  • From the installer to the first session. The installer and the CLI on one machine were two different people (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 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.
  • The installer's last word. Both install scripts send one request at their end and on failure: a single outcome word (ok, unsupported_arch, checksum_mismatch, ...) and the binary name, nothing else. SHELL_ONLINE_INSTALL_REPORT=0 skips 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.
  • What accounts do. The accounts app counts machines linked, sessions registered, commands sent, vaults created, invites sent and accepted, feedback sent: one row per kind per day, no account, address or session, purged with the activity days. Migration 013_app_events.sql adds 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:

  • An install report from a self-identified crawler counted as an install outcome. Crawlers are now skipped there too.
  • A resumed persistent session's "link waited for its first open" was measured from the session's original creation, not this run's start, so idle days counted as waiting. It now uses the run's start, as session lifetimes do.
  • The PowerShell installer could report twice on one failure (Fail reports its reason, then the catch reported "failed"). A flag makes the catch report only when nothing has.

Coverage added, server side first:

  • The Durable Object's SQL, against a real SQLite. Its queries ran only inside Workers and had no tests. They now live in worker/stats-database.ts over the two cursor methods they use, and tests/stats-database.test.ts drives them from node: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.
  • Every app counter at its route: login links a machine, registration registers a session, a start command, a vault, an invite created and accepted, feedback. The stats route is checked to honour its range (an event 40 days old is out of 7d, in all). A store whose recordAppEvent rejects still answers 201.
  • The routing guard has its own tests: comments, trailing commas and a // inside a string in the JSONC, a missing path named in the error, run_worker_first: true, unreadable and shapeless configs, usage.
  • The page's copy (insight sentences, delta chip, formatters, outcome phrases) moved to shared/stats-copy.ts with no DOM in it, and is tested for the full sentences on a fixture and every zero state.
  • Small gaps closed in worker/stats-store.test.ts (audience folding, unknown device classes, non-audience events) and tests/analytics.test.ts (the install report's outcome allowlist).

Root suite: 113 tests before this pass, 138 after.

Verification

  • Regression tests: audiences and figures, the funnel's counts and exclusions, the comparison and when it is withheld, the "since" day (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 check and npm 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 succeeds
  • In app/: 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 mirror touchMembership and are covered by the same suite under npm run test:pg, not run here (no database)
  • Not run: install.ps1 (no PowerShell on this machine). Its change is a Report function called from Fail and at the end, wrapped in try/catch so it cannot change how an install goes
  • Rendered the dashboard from a made-up snapshot in a headless DOM (not committed): no errors; every insight sentence, delta chip, funnel exclusion and footer note comes out as intended
  • Verified on production that the click endpoint accepts same-origin posts (400 for a bogus target with a real Origin, 403 with a null Origin), so a zero sign-up count is real data, not a broken beacon

Not 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

philip638 and others added 2 commits September 15, 2026 08:50
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>
@pstayets
pstayets requested a review from TeoSlayer as a code owner September 15, 2026 16:12
philip638 and others added 5 commits September 15, 2026 09:43
… 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>
@pstayets pstayets changed the title Fix the statistics funnel's measurement and lay the dashboard out as a story Fix the statistics funnel's measurement, lay the dashboard out as a story, and count what it could not see Sep 15, 2026
Alexgodoroja and others added 4 commits September 15, 2026 10:18
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>
@Alexgodoroja
Alexgodoroja merged commit ac7d5a1 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