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
@@ -0,0 +1,215 @@
|
||||
"""The access log — app/accesslog.py and GET /api/analytics/access.
|
||||
|
||||
This is the half of the analytics work that identifies people on purpose, so
|
||||
the things worth pinning are the ones that would quietly make it wrong: that
|
||||
the address recorded is the hardened one and not a header a client chose, that
|
||||
session rows are thinned instead of written per page load, and that a row
|
||||
outlives the account it describes — guest cleanup runs on a schedule, and a log
|
||||
that deletes itself is not a log.
|
||||
|
||||
python -m pytest tests/test_accesslog.py -v
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp.close()
|
||||
os.environ["AIDND_DB_PATH"] = _tmp.name
|
||||
os.environ.pop("AIDND_DATABASE_URL", None)
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import accesslog, auth, limits, models, security
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
|
||||
EDGE = "198.51.100.77" # what the trusted proxy appended
|
||||
SPOOF = "10.0.0.1" # what a client put in front of it
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state():
|
||||
accesslog._last_session.clear()
|
||||
yield
|
||||
accesslog._last_session.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
owner = models.User(is_guest=False, email="owner@example.com")
|
||||
member = models.User(
|
||||
is_guest=False, email="player@example.com",
|
||||
password_hash=security.hash_password("hunter2long"),
|
||||
)
|
||||
setup.add_all([owner, member])
|
||||
setup.commit()
|
||||
ids = {"owner": owner.id, "member": member.id}
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_login_allowed", lambda *a, **k: None)
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(auth, "ANALYTICS_EMAILS", {"owner@example.com"})
|
||||
|
||||
# /auth/me resolves its own session, so the cookie flow below is the real
|
||||
# one; every other endpoint goes through get_current_user, and `act_as`
|
||||
# decides who that is.
|
||||
acting = {"id": ids["owner"]}
|
||||
|
||||
def _current(db=Depends(get_db)):
|
||||
return db.get(models.User, acting["id"])
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current
|
||||
try:
|
||||
test_client = TestClient(app)
|
||||
test_client.ids = ids
|
||||
test_client.act_as = lambda user_id: acting.update(id=user_id)
|
||||
yield test_client
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def visit(client, ip=EDGE, ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64)"):
|
||||
return client.get(
|
||||
"/api/auth/me",
|
||||
headers={"x-forwarded-for": f"{SPOOF}, {ip}", "user-agent": ua},
|
||||
)
|
||||
|
||||
|
||||
def rows(kind=None):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = db.query(models.AccessEvent).order_by(models.AccessEvent.id)
|
||||
if kind:
|
||||
query = query.filter_by(kind=kind)
|
||||
return query.all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def read_log(client, **params):
|
||||
return client.get("/api/analytics/access", params=params)
|
||||
|
||||
|
||||
# ---------- Writing ----------
|
||||
|
||||
def test_a_new_session_is_logged(client):
|
||||
visit(client)
|
||||
logged = rows()
|
||||
assert len(logged) == 1
|
||||
entry = logged[0]
|
||||
assert entry.kind == accesslog.SESSION
|
||||
assert entry.is_guest and entry.who.startswith("Guest #")
|
||||
assert entry.device == "desktop"
|
||||
|
||||
|
||||
def test_the_address_is_the_hardened_one_not_the_clients(client):
|
||||
visit(client)
|
||||
# The client prepended its own value; only the hop the edge appended counts.
|
||||
# Recording the leftmost would make every row forgeable, which for a log is
|
||||
# worse than having no log.
|
||||
assert rows()[0].ip == EDGE
|
||||
|
||||
|
||||
def test_session_rows_are_thinned_to_one_per_day_per_address(client):
|
||||
for _ in range(4):
|
||||
visit(client)
|
||||
assert len(rows(accesslog.SESSION)) == 1
|
||||
|
||||
|
||||
def test_a_changed_address_writes_a_new_row(client):
|
||||
visit(client)
|
||||
visit(client, ip="203.0.113.9")
|
||||
logged = rows(accesslog.SESSION)
|
||||
assert [entry.ip for entry in logged] == [EDGE, "203.0.113.9"]
|
||||
# Same session throughout, so both rows name the same visitor.
|
||||
assert logged[0].who == logged[1].who
|
||||
|
||||
|
||||
def test_sign_in_and_failure_are_both_logged(client):
|
||||
client.post("/api/auth/login", json={"email": "player@example.com", "password": "wrong"},
|
||||
headers={"x-forwarded-for": EDGE})
|
||||
client.post("/api/auth/login", json={"email": "player@example.com", "password": "hunter2long"},
|
||||
headers={"x-forwarded-for": EDGE})
|
||||
kinds = [entry.kind for entry in rows()]
|
||||
assert accesslog.LOGIN_FAILED in kinds and accesslog.LOGIN in kinds
|
||||
|
||||
failure = rows(accesslog.LOGIN_FAILED)[0]
|
||||
# The address tried, not the account that owns it: a run against an address
|
||||
# with no account behind it is exactly what this row is for.
|
||||
assert failure.who == "player@example.com"
|
||||
assert failure.user_id is None
|
||||
assert rows(accesslog.LOGIN)[0].user_id == client.ids["member"]
|
||||
|
||||
|
||||
def test_registering_is_logged_against_the_upgraded_account(client):
|
||||
visit(client) # mints the guest whose session registers
|
||||
client.act_as(rows()[0].user_id)
|
||||
client.post("/api/auth/register", json={"email": "new@example.com", "password": "hunter2long"})
|
||||
entry = rows(accesslog.REGISTER)[0]
|
||||
assert entry.who == "new@example.com" and not entry.is_guest
|
||||
|
||||
|
||||
def test_a_row_outlives_the_account_it_describes(client):
|
||||
visit(client)
|
||||
entry = rows()[0]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.delete(db.get(models.User, entry.user_id))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
# No foreign key, and `who` is a snapshot — guest cleanup deletes accounts
|
||||
# on a schedule, and a log that vanishes with them is not a log.
|
||||
survivor = rows()[0]
|
||||
assert survivor.who == entry.who and survivor.ip == EDGE
|
||||
|
||||
|
||||
def test_a_long_user_agent_is_truncated(client):
|
||||
visit(client, ua="Mozilla/" + "x" * 500)
|
||||
assert len(rows()[0].user_agent) == accesslog.MAX_UA
|
||||
|
||||
|
||||
def test_a_logging_failure_does_not_break_the_request(client, monkeypatch):
|
||||
monkeypatch.setattr(accesslog, "_client_ip", lambda request: 1 / 0)
|
||||
# The log watches sign-in; it must not be able to stand in its way.
|
||||
assert visit(client).status_code == 200
|
||||
|
||||
|
||||
# ---------- Reading ----------
|
||||
|
||||
def test_the_log_is_invisible_to_everyone_but_the_owner(client):
|
||||
visit(client)
|
||||
assert read_log(client).status_code == 200
|
||||
client.act_as(client.ids["member"])
|
||||
assert read_log(client).status_code == 404
|
||||
|
||||
|
||||
def test_the_log_reads_newest_first_and_pages_backwards(client):
|
||||
for index in range(5):
|
||||
visit(client, ip=f"203.0.113.{index}")
|
||||
first = read_log(client, limit=2).json()
|
||||
assert [event["ip"] for event in first["events"]] == ["203.0.113.4", "203.0.113.3"]
|
||||
assert first["has_more"]
|
||||
|
||||
older = read_log(client, limit=2, before_id=first["events"][-1]["id"]).json()
|
||||
assert [event["ip"] for event in older["events"]] == ["203.0.113.2", "203.0.113.1"]
|
||||
|
||||
|
||||
def test_the_log_filters_by_kind_and_searches(client):
|
||||
visit(client)
|
||||
client.post("/api/auth/login", json={"email": "player@example.com", "password": "hunter2long"},
|
||||
headers={"x-forwarded-for": "203.0.113.44"})
|
||||
|
||||
assert len(read_log(client, kind="login").json()["events"]) == 1
|
||||
by_email = read_log(client, q="player@example.com").json()["events"]
|
||||
assert len(by_email) == 1 and by_email[0]["kind"] == "login"
|
||||
by_ip = read_log(client, q="203.0.113.44").json()["events"]
|
||||
assert len(by_ip) == 1
|
||||
assert read_log(client, q="nobody@example.com").json()["events"] == []
|
||||
Reference in New Issue
Block a user