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
69 lines
2.8 KiB
YAML
69 lines
2.8 KiB
YAML
# Render Blueprint — https://render.com/docs/blueprint-spec
|
|
#
|
|
# One web service: the multi-stage Dockerfile builds the React SPA and the
|
|
# FastAPI backend into a single image that serves both same-origin. Database
|
|
# is external Neon Postgres (set AIDND_DATABASE_URL in the dashboard); the
|
|
# free tier has no persistent disk, which is why the DB lives off-box.
|
|
#
|
|
# First deploy: create a Blueprint from this repo in the Render dashboard,
|
|
# then fill the `sync: false` secrets (Neon URL + demo key). Pushes to `main`
|
|
# auto-deploy thereafter.
|
|
|
|
services:
|
|
- type: web
|
|
name: ai-dnd
|
|
runtime: docker
|
|
dockerfilePath: ./Dockerfile
|
|
dockerContext: .
|
|
plan: free # sleeps after ~15 min idle; first wake takes ~30-60s
|
|
region: virginia # us-east, closest to the Neon us-east-1 database
|
|
healthCheckPath: /api/health
|
|
autoDeploy: true
|
|
envVars:
|
|
# Multi-user hardening on (guest sessions, per-user data, rate limits).
|
|
- key: AIDND_MULTI_USER
|
|
value: "1"
|
|
|
|
# Signs session cookies + encrypts stored API keys. Render generates a
|
|
# strong value once and keeps it stable across deploys (a regenerated
|
|
# secret would log out every user on each deploy).
|
|
- key: AIDND_SECRET_KEY
|
|
generateValue: true
|
|
|
|
# Neon Postgres connection string (pooled, sslmode=require). Secret —
|
|
# set it in the dashboard; never commit it.
|
|
- key: AIDND_DATABASE_URL
|
|
sync: false
|
|
|
|
# --- Shared demo key (optional): lets first-time visitors play without
|
|
# bringing their own API key. Set these in the dashboard to enable;
|
|
# leave unset to require BYOK. ---
|
|
- key: AIDND_DEMO_API_KEY
|
|
sync: false
|
|
- key: AIDND_DEMO_MODELS
|
|
sync: false
|
|
- key: AIDND_DEMO_TURNS_PER_DAY
|
|
value: "20"
|
|
|
|
# Comma-separated emails of trusted testers: unmetered demo turns, plus
|
|
# the AI Chat page (hidden from everyone else). Set in the dashboard;
|
|
# leave unset and nobody gets it. Registered accounts only.
|
|
- key: AIDND_POWER_USERS
|
|
sync: false
|
|
|
|
# Comma-separated emails allowed to see the Visitors dashboard
|
|
# (/analytics). Separate from AIDND_POWER_USERS on purpose — an unmetered
|
|
# tester is not automatically someone who sees the traffic. Leave unset
|
|
# and the page is invisible to everyone. Set it in the dashboard.
|
|
- key: AIDND_ANALYTICS_EMAILS
|
|
sync: false
|
|
|
|
# Guest retention: one account is minted per first-time visitor, so idle
|
|
# ones are collected (with their adventures) to keep the free-tier
|
|
# Postgres from filling with abandoned demo data. Registered accounts are
|
|
# never touched. 0 would disable it.
|
|
- key: AIDND_GUEST_RETENTION_DAYS
|
|
value: "5"
|
|
|
|
# CORS is unset on purpose: the SPA is served same-origin by FastAPI.
|