"""Visit analytics: one endpoint the browser writes to, one the owner reads. The split matters. `/collect` is public and takes exactly one fact — which page was viewed — because anything a stranger can POST is a number a stranger can invent. Everything the dashboard actually relies on (turns, adventures, sign-ups, demo spend, errors) is recorded server-side by the code performing it, so those counts are as trustworthy as the app itself. The two reading endpoints are owner-only and 404 for everyone else, the same way the AI Chat router does: a feature nobody else can use is better off not appearing to exist. `/summary` serves the anonymous counters (analytics.py, which stores nothing that points at a person) and `/access` serves the access log (accesslog.py, which identifies people on purpose). """ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from pydantic import BaseModel, Field from sqlalchemy.orm import Session from .. import accesslog, analytics, auth, limits, models from ..database import get_db router = APIRouter(prefix="/api/analytics", tags=["analytics"]) def owner( db: Session = Depends(get_db), user: models.User = Depends(auth.get_current_user), ) -> models.User: """Gate for the reading half. 404, not 403 — see the module docstring.""" if not auth.is_owner(user): raise HTTPException(404, "Not found") return user Owner = Depends(owner) class Pageview(BaseModel): """What the SPA reports on a page load or a route change. `first` marks a real page load rather than a client-side navigation: the things that describe a *visit* rather than a *view* — where it came from, on what kind of device, from which country — are recorded only then, so a visitor who clicks around five pages is still one referral. """ path: str = Field("", max_length=300) referrer: str = Field("", max_length=500) first: bool = False @router.post("/collect", status_code=204) def collect( payload: Pageview, request: Request, db: Session = Depends(get_db), ) -> Response: """Record one pageview. Always 204, even when nothing was counted: the browser has no business knowing whether it was.""" limits.rate_limit("analytics", request) # Resolved by hand rather than through get_current_user: a pageview that # arrives before /auth/me has minted a session should still be counted as a # view, not turned into a 401 the SPA has to handle. user = ( auth.resolve_session_user(request, db) if auth.MULTI_USER else auth.local_user(db) ) # The operator's own clicking is not traffic. Only in multi-user mode — # locally everyone is the owner, and excluding them would leave the # dashboard permanently empty on the machine it is developed on. if auth.MULTI_USER and user is not None and auth.is_owner(user): return Response(status_code=204) analytics.record(analytics.M_PAGE, analytics.normalize_route(payload.path)) analytics.record_visit(user) if payload.first: referrer = analytics.normalize_referrer( payload.referrer, request.url.hostname or "" ) if referrer: # "" means same-origin, which is not a referral analytics.record(analytics.M_REFERRER, referrer) analytics.record( analytics.M_DEVICE, analytics.device_of(request.headers.get("user-agent", "")), ) analytics.record(analytics.M_COUNTRY, analytics.country_of(request.headers)) return Response(status_code=204) @router.get("/summary") def summary( days: int = Query(30, ge=1, le=365), db: Session = Depends(get_db), _user: models.User = Owner, ) -> dict: """The whole dashboard in one aggregate response — a few kilobytes however much traffic sits behind it.""" return analytics.summary(db, days) @router.get("/access") def access_log( limit: int = Query(50, ge=1, le=200), before_id: int | None = Query(None), kind: str | None = Query(None), q: str | None = Query(None, max_length=120), db: Session = Depends(get_db), _user: models.User = Owner, ) -> dict: """A page of the access log, newest first. Unlike /summary this returns rows about people, which is the whole point of it — so it is behind the same owner gate, paged rather than dumped, and has no counterpart the people it describes can reach. """ page = accesslog.recent( db, limit=limit, before_id=before_id, kind=kind, query=q ) return { "events": [ { "id": event.id, "at": event.at.isoformat(), "kind": event.kind, "who": event.who, "is_guest": event.is_guest, "ip": event.ip, "country": event.country, "device": event.device, "user_agent": event.user_agent, } for event in page["events"] ], "has_more": page["has_more"], }