Skip to content

Hosted launch metrics: aggregate counters, ref attribution, admin view - #1002

Draft
nedtwigg wants to merge 7 commits into
hosted-billingfrom
hosted-metrics
Draft

nedtwigg wants to merge 7 commits into
hosted-billingfrom
hosted-metrics

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Oct 4, 2026

Copy link
Copy Markdown
Member

Stacked on #1001. Basic launch analytics: first-party, server-side, and aggregate only. The desktop sends no telemetry, and no page loads an analytics script or sets a cookie.

Counters

Migration 007_metrics.sql adds dormouse_metrics_daily (day, event, label, count). CHECK constraints reject any label shaped like an email, IP or id, and labels come from fixed allowlists. A count is an upsert that runs after the response, through waitUntil; a failure is only logged.

Event Label Counted when
login method a sign-in
account.created — a login whose session started within 10 s of the account being created
checkout.started plan:ref a checkout session is created
checkout.completed plan:ref a processed webhook event, never the return page
subscription.canceled plan a cancellation
subscription.refunded plan an immediate cancel within REFUND_DAYS of starting
enroll.approved, burrow.enrolled — a desktop sign-in
voice.speak ok / capped / error each speak
voice.fallback-cap — the speak that hits the daily cap, once per account per day
push.sent, pocket.signin — each push, each Pocket sign-in
hosted_page.ref ref a Hosted page visit

Where buyers come from

  • HOSTED_REFS covers settings-voice, settings-push, settings-remote, upsell-voice, upsell-push, tutorial, home, pocket-playground and readme.
  • The Hosted page reads ref from the address bar, sends it on the cohort read it already makes (which counts the visit), and puts it on its checkout links.
  • The account checkout page keeps the ref until the checkout completes (dormouse_checkout_refs).
  • Unknown refs count as other.
  • In-app links, the homepage, the Pocket playground and both READMEs now carry a ref.

/admin/metrics

An ADMIN_EMAIL-only page; everyone else gets 404. It shows founding seats left, funnels by ref and by plan, and 30-day bars per event. Plain CSS, no chart library.

Specs

  • hosted.md: a new "Metrics" section.
  • pricing.md: the ref-forwarding rule.
  • security-hosted.md: a FAIL IF that metrics rows hold no identifier, and on the admin gate.
  • security.md: a known gap — visit counts are unauthenticated and can be inflated, and each cohort read now costs one database write.
  • The Privacy page says what is counted.

Decisions to check

  • No tutorial link yet. tutorial is in the allowlist, but no tutorial link to /hosted uses it.
  • account.created is a heuristic and costs one extra get-session call per login. A pgstencil user.create hook would be cleaner.
  • Review suggestions that need pgstencil changes were declined: a login hook, Checkout metadata for the ref, and exporting planOf and the refund rule.

Private (ediso, not committed)

dormouse-private/scripts/launch-stats.mjs takes a daily snapshot of GitHub stars, forks and release downloads, Marketplace installs, and Open VSX downloads, appended to a CSV. With DORMOUSE_HOSTED_COOKIE set, it also pulls /api/admin/metrics.

Test plan

  • Root pnpm test: lib 5677, standalone 280, website 349, vscode-ext 191
  • Hosted on Docker: 178 passed, plus the 1 expected lockfile failure
  • tsc for lib, standalone, hosted and website
  • /admin/metrics end to end on StripeDev in the dev loop
  • Real Stripe and real OAuth callbacks

🤖 Generated with Claude Code

nedtwigg and others added 7 commits October 4, 2026 01:15
… by every Worker

A new dormouse_metrics_daily table holds (day, event, label, count) and
nothing else; labels come from fixed allowlists, unknown ones count as
other. Logins by method and new accounts, checkouts by plan and ref (the
ref carried to the webhook's completion), ended subscriptions as refunded
or canceled, voice outcomes and the day's cap, enrollments, Pocket
sign-ins, push deliveries, and Hosted page visits by ref. Every write
rides waitUntil and only logs a failure. The admin's /api/admin/metrics
reads them, gated on the verified ADMIN_EMAIL.

The app's links to the plans each carry an allowlisted ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Hosted page takes the ref off the address bar, forwards it on the
cohort read it already makes (counted as a visit, unknown refs as other)
and, when allowlisted, on its checkout links; the account's checkout page
keeps it with the pending plan and sends it with the checkout it starts.
The homepage, the Pocket playground, and both READMEs name their links;
public-docs-lint accepts a link to a page that carries a query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Founding seats per cohort, the funnel by ref (Hosted page visits, checkouts
started, completed) and by plan (with refunds and cancellations), and every
event's last 30 days as CSS bars with its all-time total: plain tables on
the product theme tokens, no chart library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…person analytics

hosted.md gains "Metrics": the events and their labels, no identifier in
a row, unknown refs as other, best-effort writes, rows kept indefinitely,
and the admin-only view. pricing.md: the Hosted page forwards a visit's
ref and nothing else. security-hosted.md: an audited FAIL IF on what a
metrics row may hold and who the admin view answers. The privacy policy
says what is counted and that no count records a person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e session read

The labels and the admin route live in metric-labels.ts, which the admin
view imports instead of copying; login detection moves beside the login
gate and shares its get-session read; the account checkout page passes its
ref through App instead of a module variable; the Hosted page sends the
visit's ref as-is (the server counts unknown ones as other) and the cards
take it as a prop; countBilling counts inline through planOfPrice; every
ref link uses HOSTED_REF_PARAM. The voice test counts the metrics' own
waitUntil calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erver-dated admin view

An unknown visit ref reaches checkout as other, so a visit and its
checkout count under one label; an expired incomplete subscription is no
longer read as a refund; the admin view's days come from the Worker's
UTC date and /admin/metrics/ resolves; the cron sweeps refs beside the
resync; the spec names METRIC_LABELS' owners instead of their values;
security.md lists the unauthenticated visit count as a known gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 96441f1
Status: ✅  Deploy successful!
Preview URL: https://620b4fe1.mouseterm.pages.dev
Branch Preview URL: https://hosted-metrics.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Privacy page's new sentence leaves out some of the counts this PR adds; the suggestion is inline.

{ id: "use", title: "How we use it", body: <>
<p>We use this information to create and recognize your account, verify sign-in, connect methods you explicitly select, send requested sign-in codes, prevent abuse, troubleshoot failures, and answer support requests. We discard provider access, refresh, and identity tokens after identity verification rather than storing them in your account.</p>
<p>We do not sell Hosted account information, use it for targeted advertising, or use it to train general-purpose AI models. Signing in does not subscribe you to a newsletter. The account site uses necessary authentication and security cookies and does not load marketing analytics.</p>
<p>We measure use of the service only as aggregate daily counts: sign-ins by method, checkouts and subscriptions by plan, spoken alarms and push notifications sent, and which Dormouse link a visit to the Hosted page came from, which those links name in their address. No count records an account, email address, IP address, or browser information, so there are no per-person analytics, and we keep the daily counts indefinitely.</p>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence says counts are kept only for the listed things, but the PR also counts account.created, enroll.approved, and burrow.enrolled (METRIC_LABELS in hosted/server/metric-labels.ts). None of them identifies anyone, but a privacy page that says "only" should name every kind of count.

Suggested change
<p>We measure use of the service only as aggregate daily counts: sign-ins by method, checkouts and subscriptions by plan, spoken alarms and push notifications sent, and which Dormouse link a visit to the Hosted page came from, which those links name in their address. No count records an account, email address, IP address, or browser information, so there are no per-person analytics, and we keep the daily counts indefinitely.</p>
<p>We measure use of the service only as aggregate daily counts: new accounts, sign-ins by method, computers signed in to Hosted, checkouts and subscriptions by plan, spoken alarms and push notifications sent, and which Dormouse link a visit to the Hosted page came from, which those links name in their address. No count records an account, email address, IP address, or browser information, so there are no per-person analytics, and we keep the daily counts indefinitely.</p>

This branch is waiting to be deployed

1 waiting deployment
hosted-preview — 96441f11 Waiting Oct 4, 2026 by nedtwigg via deploy #1039
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.

2 participants