Files
interactive-story/backend/app/analytics.py
T
parththakkar106andClaude Opus 5 041f9e25f3 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
2026-08-22 16:24:42 +05:30

557 lines
21 KiB
Python

"""Visit analytics for the hosted demo.
A small self-hosted counter answering "did anyone visit, and did they play?",
built into the app rather than bolted on with a third-party script: the CSP in
main.py allows scripts from 'self' only, adblockers eat the popular trackers,
and none of them can see the things actually worth knowing here (turns taken,
demo-key spend, which seeded scenario people pick).
Three rules shaped it:
1. **Nothing personal is stored.** No IP addresses, no user agents, no user
ids, no title of anything a player wrote. A visitor appears only as an HMAC
of their user id — one-way and salted with the app's secret key, so these
tables cannot be joined back to an account even by someone holding the
database. Story content never reaches this module at all. What a *specific*
person did is deliberately unanswerable; only totals are.
2. **Egress is the budget.** Neon bills for bytes leaving the database and this
project has already paid for forgetting that once. So counts are aggregated
in memory and flushed as UPSERTs — a visit is a write, never a read — and
every dashboard query is a GROUP BY returning tens of rows, never per-visit
rows. A month of traffic costs a few kilobytes to read back.
3. **The numbers are the server's, not the browser's.** The client reports one
thing: which page was viewed. Everything that *means* something ("a turn
happened", "an account was created") is recorded by the code that does it,
where it can be neither faked by a stranger nor blocked by an extension.
Storage is two tables, both bounded. `analytics_daily` is one row per (day,
metric, label) counter — a few dozen a day. `analytics_visitor_days` is one row
per visitor per day carrying the funnel flags, which is what makes the funnel
count *people* rather than clicks; it is the only table that grows with traffic
and cleanup ages it out.
"""
import hmac
import logging
import os
import re
import threading
from datetime import timedelta
from hashlib import sha256
from urllib.parse import urlsplit
from sqlalchemy import case, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Session
from . import models, security
logger = logging.getLogger(__name__)
# ---------- Metrics ----------
# `metric` is the family, `label` the bucket within it. One generic counter
# table beats a column per thing measured: adding a new question later is a
# constant, not a migration.
M_PAGE = "pageview"
M_EVENT = "event"
M_REFERRER = "referrer"
M_DEVICE = "device"
M_COUNTRY = "country"
M_SCENARIO = "scenario" # which seeded/public scenario got played
M_ERROR = "error" # "<status> <route>" for 4xx/5xx on /api
EV_SCENARIO_OPEN = "scenario_opened"
EV_ADVENTURE = "adventure_created"
EV_IMPORT = "adventure_imported"
EV_TURN = "turn"
EV_DEMO_TURN = "demo_turn" # a turn billed to the shared demo key
EV_TURN_ERROR = "turn_error"
EV_SIGNUP = "signup"
EV_LOGIN = "login"
# Events that are also funnel steps: recording one flips a flag on the
# visitor's row for the day, so the funnel counts distinct people-days instead
# of repeat clicks. The name -> column map is the whole definition of the
# funnel; the dashboard reads it back in this order.
FUNNEL_FLAGS = {
EV_SCENARIO_OPEN: "opened",
EV_ADVENTURE: "created",
EV_TURN: "played",
EV_SIGNUP: "signed_up",
}
OTHER = "(other)"
NONE_LABEL = "(direct)"
UNKNOWN = "(unknown)"
# ---------- Bounds ----------
# Everything below exists so a hostile visitor can add rows to these tables no
# faster than an honest one. The only label a client can influence is the
# referrer, and these caps together mean the worst it can do is fill one day's
# referrer list with junk and then be folded into "(other)".
MAX_LABEL_LEN = 80
MAX_LABELS_PER_METRIC = 200 # distinct labels per metric per day, then OTHER
MAX_PENDING = 4000 # buffered entries before an inline flush
FLUSH_INTERVAL_SECONDS = 60
# How long the per-visitor-day rows are kept. The daily counters are tiny and
# kept forever; these are the ones that scale with traffic. A visitor whose
# last visit falls off the end counts as new again — a fair trade at this
# horizon, and it keeps the table from being a permanent record of anyone.
RETENTION_DAYS = int(os.environ.get("AIDND_ANALYTICS_RETENTION_DAYS", "400") or 400)
_HOST_OK = re.compile(r"^[a-z0-9.-]+$")
_COUNTRY_OK = re.compile(r"^[A-Z]{2}$")
_NUMERIC_SEGMENT = re.compile(r"^\d+$")
# SPA routes, in the shape the dashboard should show them. Anything else a
# client claims to have viewed becomes OTHER, so the page list can neither be
# polluted nor accidentally record which adventure someone is reading.
KNOWN_ROUTES = {
"/", "/adventures", "/scenarios", "/scenarios/:id", "/play/:id",
"/scripts", "/scripts/:id", "/settings", "/chat", "/analytics",
}
# ---------- In-process buffer ----------
# Single-process deployment (same assumption as limits.py), so a plain dict
# under a lock is the whole design. Losing up to a minute of counts to a hard
# restart is acceptable for traffic numbers, and the flusher also runs on
# shutdown; on Render's free tier the service is idle when it sleeps, so the
# buffer it sleeps on is empty anyway.
_counts: dict[tuple[str, str, str], int] = {}
_visits: dict[tuple[str, str], set[str]] = {} # (day, visitor) -> flags
_labels_seen: dict[tuple[str, str], set[str]] = {} # (day, metric) -> labels
_guard = threading.Lock()
def _today() -> str:
return models.utcnow().date().isoformat()
def record(metric: str, label: str = "", *, n: int = 1) -> None:
"""Add `n` to one counter. Never raises: analytics must not be able to
fail a request it is only watching."""
try:
day = _today()
label = (label or "").strip()[:MAX_LABEL_LEN]
with _guard:
seen = _labels_seen.setdefault((day, metric), set())
if label not in seen:
if len(seen) >= MAX_LABELS_PER_METRIC:
label = OTHER
else:
seen.add(label)
key = (day, metric, label)
_counts[key] = _counts.get(key, 0) + n
pending = len(_counts) + len(_visits)
except Exception: # pragma: no cover - defensive
logger.exception("Analytics counter failed; continuing.")
return
if pending >= MAX_PENDING:
flush()
def visitor_id(user: models.User) -> str:
"""A stable but one-way handle for one visitor.
HMAC of the user id under the app's secret key. Stable, so a returning
visitor can be told from a new one; one-way, so nothing in the analytics
tables points back at an account; keyed, so a client cannot compute one and
claim to be somebody else. One consequence worth knowing: rotating
AIDND_SECRET_KEY makes every returning visitor look new again.
"""
digest = hmac.new(security.SECRET_KEY, f"visitor:{user.id}".encode(), sha256)
return digest.hexdigest()[:32]
def record_visit(user: models.User | None, *, flag: str | None = None) -> None:
"""Note that this visitor was here today, optionally flipping one funnel
flag. A no-op without a user: a page loaded before a session exists still
counts as a pageview, just not as a person."""
if user is None:
return
try:
with _guard:
flags = _visits.setdefault((_today(), visitor_id(user)), set())
if flag:
flags.add(flag)
except Exception: # pragma: no cover - defensive
logger.exception("Analytics visit failed; continuing.")
def record_event(name: str, user: models.User | None = None) -> None:
"""One thing that happened: counted, and — if it is a funnel step —
credited to the visitor's day. This is the call sites' whole interface."""
record(M_EVENT, name)
record_visit(user, flag=FUNNEL_FLAGS.get(name))
# ---------- Normalizing what the browser reports ----------
def normalize_route(path: str) -> str:
"""A client-reported path, reduced to one of KNOWN_ROUTES.
Numeric segments become ":id" — both to bound the label count and because
*which* adventure someone opened is their business, not a statistic.
"""
path = (path or "/").split("?")[0].split("#")[0]
if not path.startswith("/"):
path = "/" + path
if len(path) > 1:
path = path.rstrip("/")
parts = [":id" if _NUMERIC_SEGMENT.match(p) else p for p in path.split("/")]
route = "/".join(parts) or "/"
return route if route in KNOWN_ROUTES else OTHER
def normalize_referrer(referrer: str, own_host: str = "") -> str:
"""The sending site as a bare host. Our own host means an internal
navigation, which is not a referral — "" tells the caller to skip it."""
if not referrer:
return NONE_LABEL
host = (urlsplit(referrer).hostname or "").lower().lstrip(".")
if not host or not _HOST_OK.match(host) or len(host) > MAX_LABEL_LEN:
return OTHER
if host == (own_host or "").lower() or host in ("localhost", "127.0.0.1"):
return ""
return host[4:] if host.startswith("www.") else host
def api_route_label(scope: dict, status: int) -> str:
"""An error bucket like "500 /api/adventures/{adventure_id}".
The route *template* is used, never the request path: it keeps one bucket
per endpoint instead of one per adventure id, and — the reason it is not
merely tidier — an unmatched path is entirely attacker-chosen, so labelling
by it would let anyone mint rows by requesting nonsense.
"""
template = getattr(scope.get("route"), "path", None)
return f"{status} {template}" if template else f"{status} (unmatched)"
def device_of(user_agent: str) -> str:
"""Mobile / tablet / desktop, and nothing finer. The UA string itself is
never stored — it is a fingerprint, and the answer worth having is one
word."""
ua = (user_agent or "").lower()
if not ua:
return UNKNOWN
if any(bot in ua for bot in ("bot", "crawler", "spider", "headless", "preview")):
return "bot"
if "ipad" in ua or "tablet" in ua or ("android" in ua and "mobile" not in ua):
return "tablet"
if any(m in ua for m in ("mobi", "iphone", "ipod", "android", "phone")):
return "mobile"
return "desktop"
# Geo headers an edge may add. Render fronts services with a CDN that can set
# cf-ipcountry; the others cost nothing to look for. A value is trusted only if
# it looks like an ISO code, since a client can send any header it likes — the
# worst case is therefore a wrong country, never an unbounded label.
_GEO_HEADERS = ("cf-ipcountry", "x-vercel-ip-country", "x-geo-country", "x-country-code")
def country_of(headers) -> str:
for name in _GEO_HEADERS:
value = (headers.get(name) or "").strip().upper()
if _COUNTRY_OK.match(value) and value != "XX":
return value
return UNKNOWN
# ---------- Flushing ----------
def _insert(db: Session):
return sqlite_insert if db.get_bind().dialect.name == "sqlite" else pg_insert
def _drain() -> tuple[dict, dict]:
with _guard:
counts, visits = _counts.copy(), _visits.copy()
_counts.clear()
_visits.clear()
# The label sets only bound cardinality within a day, so let yesterday's
# go rather than growing a map that never shrinks.
today = _today()
for key in [k for k in _labels_seen if k[0] != today]:
del _labels_seen[key]
return counts, visits
def _restore(counts: dict, visits: dict) -> None:
"""Put a failed flush's work back so the next one retries it."""
with _guard:
for key, n in counts.items():
_counts[key] = _counts.get(key, 0) + n
for key, flags in visits.items():
_visits.setdefault(key, set()).update(flags)
def flush(db: Session | None = None) -> None:
"""Write the buffer out. Safe to call from anywhere; never raises."""
counts, visits = _drain()
if not counts and not visits:
return
own_session = db is None
if own_session:
from .database import SessionLocal
db = SessionLocal()
try:
_write_counts(db, counts)
_write_visits(db, visits)
db.commit()
except Exception:
db.rollback()
_restore(counts, visits)
logger.exception("Analytics flush failed; counts held for the next one.")
finally:
if own_session:
db.close()
def _write_counts(db: Session, counts: dict) -> None:
if not counts:
return
table = models.AnalyticsDaily.__table__
rows = [
{"day": day, "metric": metric, "label": label, "hits": hits}
for (day, metric, label), hits in counts.items()
]
stmt = _insert(db)(table).values(rows)
db.execute(stmt.on_conflict_do_update(
index_elements=["day", "metric", "label"],
set_={"hits": table.c.hits + stmt.excluded.hits},
))
def _write_visits(db: Session, visits: dict) -> None:
if not visits:
return
table = models.AnalyticsVisitorDay.__table__
ids = {visitor for _, visitor in visits}
# One indexed lookup settles new-vs-returning for the whole batch. It is
# the only read this module does off the dashboard, and it returns short
# hashes for visitors who are active right now — bounded by the batch.
known = set(db.scalars(
select(models.AnalyticsVisitorDay.visitor)
.where(models.AnalyticsVisitorDay.visitor.in_(ids))
.distinct()
))
rows = [
{
"day": day,
"visitor": visitor,
"is_new": visitor not in known,
**{column: column in flags for column in FUNNEL_FLAGS.values()},
}
for (day, visitor), flags in visits.items()
]
stmt = _insert(db)(table).values(rows)
db.execute(stmt.on_conflict_do_update(
index_elements=["day", "visitor"],
# Flags only ever turn on, and `is_new` is deliberately absent: the
# first write of a visitor's first day is the one that decided it.
set_={
column: or_(table.c[column], stmt.excluded[column])
for column in FUNNEL_FLAGS.values()
},
))
def purge_old_visitor_days(db: Session) -> int:
"""Drop visitor-day rows past the retention horizon. Called by the cleanup
sweeper; the daily counters are never purged — they are aggregate, tiny,
and a portfolio project wants to keep its history."""
if RETENTION_DAYS <= 0:
return 0
cutoff = (models.utcnow().date() - timedelta(days=RETENTION_DAYS)).isoformat()
removed = db.query(models.AnalyticsVisitorDay).filter(
models.AnalyticsVisitorDay.day < cutoff
).delete(synchronize_session=False)
db.commit()
return removed or 0
# ---------- Reading it back ----------
# Every query below is an aggregate: the database does the counting and ships
# back tens of rows, whatever the traffic behind them. Nothing here can return
# a row that belongs to one visitor.
TOP_N = 12
def _top(rows: list[dict], limit: int = TOP_N) -> list[dict]:
return rows[:limit]
def summary(db: Session, days: int = 30) -> dict:
"""Everything the dashboard shows, for the last `days` days (today
included). Flushes first so the numbers include the last minute."""
flush(db)
today = models.utcnow().date()
since = (today - timedelta(days=days - 1)).isoformat()
daily = models.AnalyticsDaily
visitor = models.AnalyticsVisitorDay
# 1. Every counter in the window, folded to (metric, label) totals: the
# page/referrer/country/device/scenario/error tables all come from this
# one pass rather than a query each.
by_metric: dict[str, list[dict]] = {}
for metric, label, hits in db.execute(
select(daily.metric, daily.label, func.sum(daily.hits))
.where(daily.day >= since)
.group_by(daily.metric, daily.label)
):
by_metric.setdefault(metric, []).append({"label": label, "hits": int(hits)})
for rows in by_metric.values():
rows.sort(key=lambda row: -row["hits"])
events = {row["label"]: row["hits"] for row in by_metric.get(M_EVENT, [])}
# 2. Two per-day series worth drawing.
pageviews_by_day = {
day: int(hits)
for day, hits in db.execute(
select(daily.day, func.sum(daily.hits))
.where(daily.day >= since, daily.metric == M_PAGE)
.group_by(daily.day)
)
}
turns_by_day = {
day: int(hits)
for day, hits in db.execute(
select(daily.day, func.sum(daily.hits))
.where(daily.day >= since, daily.metric == M_EVENT, daily.label == EV_TURN)
.group_by(daily.day)
)
}
# 3. People, per day. One row per visitor per day means COUNT(*) is already
# the day's unique visitors — no DISTINCT needed here.
visitors_by_day: dict[str, dict] = {}
for day, total, fresh in db.execute(
select(
visitor.day,
func.count(),
func.sum(case((visitor.is_new, 1), else_=0)),
)
.where(visitor.day >= since)
.group_by(visitor.day)
):
visitors_by_day[day] = {"visitors": int(total), "new": int(fresh or 0)}
# 4. The funnel, over the whole window, counting *people* once each:
# COUNT(DISTINCT CASE WHEN flag THEN visitor END) ignores the NULLs the
# CASE leaves for everyone who didn't reach that step.
unique, unique_new, *reached = db.execute(
select(
func.count(func.distinct(visitor.visitor)),
func.count(func.distinct(case((visitor.is_new, visitor.visitor)))),
*[
func.count(func.distinct(case((visitor.__table__.c[column], visitor.visitor))))
for column in FUNNEL_FLAGS.values()
],
).where(visitor.day >= since)
).one()
series = []
for offset in range(days):
day = (today - timedelta(days=days - 1 - offset)).isoformat()
counted = visitors_by_day.get(day, {})
series.append({
"day": day,
"visitors": counted.get("visitors", 0),
"new": counted.get("new", 0),
"pageviews": pageviews_by_day.get(day, 0),
"turns": turns_by_day.get(day, 0),
})
visits = sum(row["visitors"] for row in series)
pageviews = sum(pageviews_by_day.values())
turns = events.get(EV_TURN, 0)
errors = by_metric.get(M_ERROR, [])
return {
"days": days,
"since": since,
"until": today.isoformat(),
"generated_at": models.utcnow().isoformat(),
"totals": {
# `visitors` counts each person once for the window; `visits` counts
# them once per day they came back, which is the closest honest
# thing to "sessions" without tracking sessions.
"visitors": int(unique),
"new_visitors": int(unique_new),
"visits": visits,
"pageviews": pageviews,
"turns": turns,
"demo_turns": events.get(EV_DEMO_TURN, 0),
"adventures": events.get(EV_ADVENTURE, 0),
"signups": events.get(EV_SIGNUP, 0),
"logins": events.get(EV_LOGIN, 0),
"turn_errors": events.get(EV_TURN_ERROR, 0),
"errors": sum(row["hits"] for row in errors),
"turns_per_visit": round(turns / visits, 1) if visits else 0,
"pages_per_visit": round(pageviews / visits, 1) if visits else 0,
},
"series": series,
# Step 0 is everyone who showed up, so the drop-off between it and
# "Opened a scenario" is visible as a step like any other.
"funnel": [{"step": "Visited", "count": int(unique)}] + [
{"step": step, "count": int(count)}
for step, count in zip(
["Opened a scenario", "Started an adventure", "Played a turn", "Signed up"],
reached,
)
],
"pages": _top(by_metric.get(M_PAGE, [])),
"referrers": _top(by_metric.get(M_REFERRER, [])),
"countries": _top(by_metric.get(M_COUNTRY, [])),
"devices": by_metric.get(M_DEVICE, []),
"scenarios": _top(by_metric.get(M_SCENARIO, [])),
"errors": _top(errors),
"events": by_metric.get(M_EVENT, []),
}
# ---------- Background flusher ----------
# Mirrors cleanup's start/stop pair so main.py's lifespan reads the same way
# for both. The interval is what bounds how much a hard restart can lose.
async def _flush_loop() -> None:
import asyncio
from starlette.concurrency import run_in_threadpool
while True:
await asyncio.sleep(FLUSH_INTERVAL_SECONDS)
# Blocking DB work: keep it off the event loop, which is also serving
# SSE turn streams.
await run_in_threadpool(flush)
def start_flusher():
import asyncio
return asyncio.create_task(_flush_loop())
async def stop_flusher(task) -> None:
"""Cancel the loop and write out whatever it was holding — a deploy is the
one restart that is both frequent and predictable, so it should not be the
thing that loses a minute of counts."""
import asyncio
from starlette.concurrency import run_in_threadpool
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await run_in_threadpool(flush)