Count the visits, and say whether anyone got anywhere
A hosted demo raises a question a local app never does: is anyone using it, and do they reach the part that matters? `/analytics` answers it — visitors, pages, referrers, countries, devices, which shared scenarios get played, turns and demo-key spend, API and turn errors, and a funnel from visited to played a turn to signed up. Not a third-party script, for reasons specific to this one. The CSP allows `script-src 'self'`, so a tracker means loosening it; adblockers eat the popular ones, which silently biases exactly the technical audience this project gets shown to; and none of them can see the measurement that actually matters here, which is a turn, not a pageview. **A visit is a write and never a read.** After the 189x egress fix it would be perverse to add a feature that reads rows per request, so counts accumulate in a process-local dict and flush every 60s as UPSERTs. Storage is a generic `(day, metric, label) -> hits` counter, so measuring something new later costs a constant rather than a migration, plus one row per visitor per day for the funnel flags. Every dashboard query is a GROUP BY returning tens of rows however much traffic sits behind it; a month reads back in a few kilobytes. The buffer's cost is that a hard restart can lose up to a minute — the flusher also runs on shutdown, and a tier that sleeps when idle sleeps on an empty buffer anyway. **The counters are anonymous; the access log beside them is not, on purpose.** A visitor is `HMAC(secret, "visitor:<user id>")` truncated to 32 chars — one-way, so `analytics_daily` and `analytics_visitor_days` cannot be joined back to `users`, and keyed, so no client can compute one. Story content never reaches that module, and the only content it ever names is a seeded public scenario's title; a player's own titles are theirs. `accesslog.py` is the identifying half and is a separate module writing a separate table so that separation is a property of the code rather than a convention: `access_events` records sessions, sign-ins, registrations and failed attempts with address, email and device, read on a second tab of the same page behind the same gate. Both halves are gated on `AIDND_ANALYTICS_EMAILS`, not `POWER_USERS`. An unmetered tester is not automatically someone who should see the traffic. The route 404s and the nav link is absent for everyone else, the same treatment AI Chat gets; unset in a hosted deploy means nobody sees it, including me. Three things came out of building it that a test would not have suggested. **A failed turn is an HTTP 200 with a bad ending.** The status-code middleware cannot see one, so a demo whose model had started refusing every request would look perfectly healthy from outside. All five SSE error paths in `_generate_turn` now go through a `turn_error()` helper that counts on the way out. Error buckets elsewhere are labelled by the matched route template rather than the requested path — one bucket per endpoint instead of one per adventure id, and, the reason it isn't merely tidier, an unmatched path is entirely attacker-chosen, so labelling by it would let anyone mint rows. **The funnel counts people, not clicks.** A player who starts six adventures is one person who started an adventure. That is the whole reason the per-visitor-day table exists; its flags only ever turn on, and `is_new` is settled by the first write of a visitor's first day. **The tests run on SQLite and production is Neon.** A flush that raises is caught and logged, so a dialect mistake in the UPSERTs would have stayed invisible until the dashboard quietly never filled. `test_the_upserts_compile_for_postgres` compiles both statements against the Postgres dialect without connecting to one. Two things this leans on elsewhere. `limits._client_ip` is now public `client_ip`: the access log needs the same answer, and two functions both deciding which hop is the caller's is how one of them ends up trusting a header it shouldn't. And the cleanup sweeper now starts if *either* job has work — a deployment can keep every guest forever and still want its visitor-day rows aged out. No migration. Both tables are new and `bootstrap()` calls `create_all` on existing databases too, the route `branches` took in Phase 14, so `LATEST_VERSION` is still 64. 497 tests green, frontend lint and build clean, driven by hand against a synthetic 90-day fixture at 1568px. The narrow-screen layout follows the existing 720px block but is unverified: `resize_window` is ignored on a maximized Chrome and `frame-ancestors 'none'` rules out checking it in a sized iframe. Also repaired here: a rename in test_ratelimit_hardening.py had run through the test names themselves, leaving `testclient_ip_*` — still collected by pytest, which is why it passed unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b9e6b3d50
commit
041f9e25f3
+69
-1
@@ -3,7 +3,7 @@
|
||||
Read this first when picking the project back up. Updated at the end of a working
|
||||
session; the per-phase plan files hold the detail, this holds the thread.
|
||||
|
||||
**Last updated: 2026-08-20.**
|
||||
**Last updated: 2026-08-21.**
|
||||
|
||||
---
|
||||
|
||||
@@ -195,6 +195,74 @@ an automated version; see SP7's entry in `plan/14`.
|
||||
|
||||
---
|
||||
|
||||
## What happened on 2026-08-21 — visit analytics
|
||||
|
||||
The hosted demo can now answer whether anyone is using it. `/analytics` is a dashboard —
|
||||
visitors, pages, referrers, countries, devices, which shared scenarios get played, turns and
|
||||
demo-key spend, API and turn errors, and a funnel from *visited* to *played a turn* to
|
||||
*signed up*. **Built, green (497 tests), driven by hand against a synthetic 90-day fixture.
|
||||
Not committed, not deployed.**
|
||||
|
||||
**It is gated on its own allowlist, `AIDND_ANALYTICS_EMAILS`** — not `AIDND_POWER_USERS`.
|
||||
An unmetered tester is not automatically someone who sees the traffic numbers. The route
|
||||
404s and the nav link is absent for everyone else, same treatment as AI Chat. Set the var in
|
||||
the Render dashboard or the page is invisible to everybody, including you.
|
||||
|
||||
**The counters are anonymous; the access log beside them is not, on purpose.** A visitor in
|
||||
`analytics_daily`/`analytics_visitor_days` is `HMAC(secret, "visitor:<user id>")` truncated
|
||||
to 32 chars — one-way, so those two tables cannot be joined back to `users`, and keyed, so no
|
||||
client can compute one. Story content never reaches that module. The operator's own visits
|
||||
are not counted there (multi-user only — excluding them locally would leave the page
|
||||
permanently empty on the machine it is developed on).
|
||||
|
||||
**`accesslog.py` is the identifying half, added the same week.** `access_events` records
|
||||
sessions, sign-ins, registrations and failed attempts with address, email (or `Guest #n`),
|
||||
country and device, read on an "Access log" tab of the same page and behind the same owner
|
||||
gate. It is a separate module and a separate table so the anonymity of the counters stays a
|
||||
property of the code rather than a convention. Three things it leans on: the address comes
|
||||
from `limits.client_ip` — now public, and the only place that decides which hop to trust, so
|
||||
a spoofed `X-Forwarded-For` cannot forge a row; `user_id` carries **no foreign key** and
|
||||
`who`/`is_guest` are snapshots, so a row outlives the guest cleanup that deletes the account;
|
||||
and session rows are thinned to one per day per address, since `/auth/me` runs on every page
|
||||
load. Nothing is purged — that was the deliberate choice. The published docs describe the
|
||||
analytics generally and do not enumerate this.
|
||||
|
||||
**A visit is a write and never a read.** Counts accumulate in a process-local dict and flush
|
||||
every 60s as UPSERTs into `analytics_daily` — a generic `(day, metric, label) -> hits`
|
||||
counter — plus one row per visitor per day in `analytics_visitor_days` for the funnel flags.
|
||||
Every dashboard query is a `GROUP BY` returning tens of rows however much traffic sits
|
||||
behind it. Deliberate, given §2.5: adding a feature that reads rows per request would have
|
||||
undone the egress work.
|
||||
|
||||
**No migration was needed.** Both tables are new, and `bootstrap()` calls `create_all` on
|
||||
existing databases too — the same route `branches` took in Phase 14. Nothing was appended to
|
||||
`MIGRATIONS`, so `LATEST_VERSION` is still 64.
|
||||
|
||||
Three things worth remembering out of building it:
|
||||
|
||||
- **The funnel counts people, not clicks.** A player who starts six adventures is one person
|
||||
who started an adventure. That is the entire reason the per-visitor-day table exists; its
|
||||
flags only ever turn on, and `is_new` is settled by the first write of a visitor's first
|
||||
day and never updated after.
|
||||
- **A failed turn is an HTTP 200 with a bad ending.** The status-code middleware cannot see
|
||||
one, so a demo whose model started refusing every request would look perfectly healthy.
|
||||
`turn_error` is counted in a new `turn_error()` helper that all five SSE error paths in
|
||||
`_generate_turn` now go through.
|
||||
- **The tests run on SQLite; production is Neon.** A flush that raises is caught and logged,
|
||||
so a dialect mistake in the UPSERTs would have been invisible until the dashboard quietly
|
||||
stayed empty. `test_the_upserts_compile_for_postgres` compiles both statements against the
|
||||
Postgres dialect without connecting to one.
|
||||
|
||||
**Still not verified: the narrow-screen layout.** The CSS follows the existing `max-width:
|
||||
720px` block (single-column grids, funnel label above its bar) but `resize_window` is
|
||||
ignored on a maximized Chrome, and the app sends `frame-ancestors 'none'` so it cannot be
|
||||
checked in a sized iframe either. Desktop was driven by hand at 1568px.
|
||||
|
||||
**`docs/guide.html` is now behind `docs/GUIDE.md`,** which gained §3.6. Nothing in the repo
|
||||
regenerates it.
|
||||
|
||||
---
|
||||
|
||||
## What happened on 2026-08-20 — the branch map
|
||||
|
||||
The Branches panel gained a **⌗ See the tree** button opening a full-screen map: one
|
||||
|
||||
Reference in New Issue
Block a user