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
@@ -151,7 +151,7 @@ player input
|
||||
|
||||
```
|
||||
frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
||||
├─ routers/ auth, scenarios, adventures, story cards, scripts, chat, settings, debug
|
||||
├─ routers/ auth, scenarios, adventures, story cards, scripts, chat, settings, analytics, debug
|
||||
├─ models.py SQLAlchemy: User, Scenario, Adventure, Branch, Action, StoryCard, Script, Settings, Memory
|
||||
├─ migrations.py hand-rolled, versioned via PRAGMA user_version (64 and counting)
|
||||
├─ auth.py guest/registered users, sessions, shared demo key
|
||||
@@ -162,6 +162,7 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
||||
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
|
||||
├─ scripting/ quickjs sandbox + AI Dungeon API surface
|
||||
├─ memorybank.py auto-summarization + embedding retrieval
|
||||
├─ analytics.py buffered visit counters + the owner's dashboard query
|
||||
├─ bundle.py the export/import formats, v2 (tree) and a v1 reader
|
||||
├─ providers/ OpenAI-compatible adapter, streaming
|
||||
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
|
||||
@@ -172,7 +173,7 @@ development Vite proxies `/api` to FastAPI.
|
||||
|
||||
## Tests
|
||||
|
||||
440 backend tests — unit plus full HTTP integration through the real quickjs scripting engine,
|
||||
497 backend tests — unit plus full HTTP integration through the real quickjs scripting engine,
|
||||
with the LLM provider mocked. CI runs them on every push, alongside the frontend lint/build and
|
||||
a Docker image build.
|
||||
|
||||
@@ -202,6 +203,21 @@ the most interesting engineering in the repo:
|
||||
stay cheap because the lineage is windowed like the history is, so the number of SQL clauses is
|
||||
bounded by the context window rather than by the number of forks.
|
||||
|
||||
## Visit analytics
|
||||
|
||||
The hosted demo keeps its own analytics: an owner-only dashboard at `/analytics` showing
|
||||
traffic, which shared scenarios get played, turns and demo-key spend, errors, and a funnel
|
||||
from *visited* to *played a turn* to *signed up*. It is visible only to the emails listed in
|
||||
`AIDND_ANALYTICS_EMAILS`, and the route 404s for everyone else.
|
||||
|
||||
Built into the app rather than bolted on with a third-party script, for reasons specific to
|
||||
this one: the CSP allows `script-src 'self'`, adblockers eat the popular trackers, and none
|
||||
of them can see the measurement that actually matters here — a turn. Counts are aggregated
|
||||
in memory and flushed as UPSERTs, so a visit is a write and never a read, and every dashboard
|
||||
query is a `GROUP BY` returning tens of rows however much traffic sits behind it. That last
|
||||
part is not incidental; see the egress note above for what reading rows per request costs on
|
||||
this stack.
|
||||
|
||||
## Deploy (Render)
|
||||
|
||||
The repo ships a [`render.yaml`](render.yaml) blueprint: one Docker web service that
|
||||
@@ -211,7 +227,8 @@ serves the SPA and API same-origin, backed by external [Neon](https://neon.tech)
|
||||
1. Create a **Neon** project and copy its pooled connection string.
|
||||
2. In Render: **New → Blueprint**, point it at this repo. Render reads `render.yaml`.
|
||||
3. Fill the secrets it prompts for (`sync: false` vars): `AIDND_DATABASE_URL` (the Neon
|
||||
string) and, to offer a no-signup demo, `AIDND_DEMO_API_KEY` / `AIDND_DEMO_MODELS`.
|
||||
string); to offer a no-signup demo, `AIDND_DEMO_API_KEY` / `AIDND_DEMO_MODELS`; and to
|
||||
see the Visitors dashboard, `AIDND_ANALYTICS_EMAILS` (your own account's email).
|
||||
`AIDND_SECRET_KEY` is generated automatically and kept stable across deploys.
|
||||
4. Deploy. Pushes to `main` auto-deploy thereafter. Health check: `/api/health`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user