Admin step A: privacy-respecting first-party event logging

- events table (kind, article_id, visitor_hash, day) with a UNIQUE key that dedups
  to one row per visitor-day — caps volume and makes counts mean distinct
  visitor-days. NO ip/ua/referrer/url. Groupings derived from article_id at query
  time, never stored.
- POST /api/events (public): whitelisted kinds (visit/open/share_ub/copy_source/
  native_share/source_click); visitor token hashed server-side (never raw).
- Frontend analytics.js: random localStorage visitor token; track() via sendBeacon;
  visit once/day; open on article click; share_ub/copy_source/native_share from the
  share menu; /a landing pages fire source_click. 127 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 18:21:49 +00:00
parent ab5caada0b
commit 1a778e1334
7 changed files with 180 additions and 4 deletions
+31
View File
@@ -336,6 +336,23 @@ class PrefsBody(BaseModel):
prefs: dict = {}
class EventBody(BaseModel):
kind: str
article_id: int | None = None
visitor: str | None = None
# The only event kinds we record (per the agreed vocabulary).
_EVENT_KINDS = {"visit", "open", "share_ub", "copy_source", "native_share", "source_click"}
def _visitor_hash(token: str | None) -> str:
token = (token or "").strip()[:200]
if not token:
return ""
return hashlib.sha256(f"{SESSION_SECRET}:{token}".encode()).hexdigest()
# --- App --------------------------------------------------------------------
@@ -666,6 +683,20 @@ def create_app() -> FastAPI:
_kick_summary(aid, background_tasks) # generate for next time; page polls
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary))
# --- Privacy-respecting first-party analytics -------------------------
@app.post("/api/events")
def record_event(body: EventBody) -> dict:
if body.kind in _EVENT_KINDS:
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO events (kind, article_id, visitor_hash, day) "
"VALUES (?, ?, ?, date('now'))",
(body.kind, body.article_id or 0, _visitor_hash(body.visitor)),
)
conn.commit()
return {"ok": True} # always identical; dedup'd by the unique key
@app.get("/api/summary/{article_id}")
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
with get_conn() as conn: