* Rewrite comments in Google developer documentation style Rewrite the comments and docstrings across the backend core modules so they read plainly. The previous prose was accurate but dense and figurative, which made it slow to skim. Applies the Google developer documentation style guide: short sentences, active voice, present tense, American spelling, and no metaphors, idioms, or rhetorical asides. Replaces em-dash chains with separate sentences.
141 lines
5.0 KiB
Python
141 lines
5.0 KiB
Python
"""Visit analytics: one endpoint the browser writes to, one the owner reads.
|
|
|
|
The split matters. `/collect` is public and accepts one fact, which page was
|
|
viewed, because anything a stranger can POST is a number a stranger can invent.
|
|
Everything the dashboard relies on, meaning turns, adventures, sign-ups, demo
|
|
spend, and errors, is recorded on the server by the code that performs 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:
|
|
"""Gates the reading half. It returns 404 rather than 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
|
|
facts that describe a visit rather than a view, which are where it came from,
|
|
on what kind of device, and from which country, are recorded only on a page
|
|
load, so a visitor who clicks through 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 clicks are not traffic. This applies only in
|
|
# multi-user mode. Locally every user is the owner, and excluding them would
|
|
# leave the dashboard empty on the machine the app 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:
|
|
"""Returns the whole dashboard in one aggregate response.
|
|
|
|
The response is a few kilobytes however much traffic is 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 what it is for.
|
|
It is therefore behind the same owner gate, it is paged rather than returned
|
|
in full, and the people it describes have no endpoint that reaches it.
|
|
"""
|
|
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"],
|
|
}
|