Port eb8f039 Activity-stream logging: notifier, auth, delivery, lifecycle, 500s, slow requests from monorepo (drift reconciliation)

Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
This commit is contained in:
Emi Griffith 2026-07-22 12:07:35 -07:00
parent 787461656e
commit cd760f7e44
8 changed files with 87 additions and 4 deletions

View file

@ -12,6 +12,7 @@ from fastapi.concurrency import run_in_threadpool
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core import audit
from data import climate from data import climate
from data import grid from data import grid
from notifications import push from notifications import push
@ -110,6 +111,8 @@ async def create_subscription(
session.add(sub) session.add(sub)
await session.commit() await session.commit()
await session.refresh(sub) await session.refresh(sub)
audit.log_activity("sub.create", {"user_id": str(user.id), "sub_id": sub.id,
"cell_id": cell_id, "kind": body.kind})
return sub return sub
@ -121,10 +124,13 @@ async def update_subscription(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
sub = await _owned_subscription(session, sub_id, user) sub = await _owned_subscription(session, sub_id, user)
for key, value in body.model_dump(exclude_unset=True).items(): changed = body.model_dump(exclude_unset=True)
for key, value in changed.items():
setattr(sub, key, value) setattr(sub, key, value)
await session.commit() await session.commit()
await session.refresh(sub) await session.refresh(sub)
audit.log_activity("sub.update", {"user_id": str(user.id), "sub_id": sub.id,
"changed": list(changed.keys())})
return sub return sub
@ -135,8 +141,10 @@ async def delete_subscription(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
sub = await _owned_subscription(session, sub_id, user) sub = await _owned_subscription(session, sub_id, user)
kind = sub.kind
await session.delete(sub) await session.delete(sub)
await session.commit() await session.commit()
audit.log_activity("sub.delete", {"user_id": str(user.id), "sub_id": sub_id, "kind": kind})
# --- notifications ----------------------------------------------------------- # --- notifications -----------------------------------------------------------
@ -254,6 +262,8 @@ async def push_subscribe(
) )
) )
await session.commit() await session.commit()
audit.log_activity("push.subscribe", {"user_id": str(user.id),
"refreshed": existing is not None})
return {"ok": True} return {"ok": True}
@ -275,6 +285,7 @@ async def push_unsubscribe(
if row is not None: if row is not None:
await session.delete(row) await session.delete(row)
await session.commit() await session.commit()
audit.log_activity("push.unsubscribe", {"user_id": str(user.id)})
@router.post("/push/test", status_code=202) @router.post("/push/test", status_code=202)

View file

@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from accounts.db import get_async_session from accounts.db import get_async_session
from accounts.models import AccessToken, User from accounts.models import AccessToken, User
from core import audit
from notifications import mailer from notifications import mailer
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
@ -77,11 +78,19 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
raise InvalidPasswordException(reason="Password is too long.") raise InvalidPasswordException(reason="Password is too long.")
async def on_after_register(self, user: User, request: "Request | None" = None) -> None: async def on_after_register(self, user: User, request: "Request | None" = None) -> None:
audit.log_activity("auth.register", {"user_id": str(user.id)})
# Kick off the same token-generation path a manual "resend verification" # Kick off the same token-generation path a manual "resend verification"
# request would use, so there is exactly one place that builds the email # request would use, so there is exactly one place that builds the email
# (on_after_request_verify below) rather than two copies drifting apart. # (on_after_request_verify below) rather than two copies drifting apart.
await self.request_verify(user, request) await self.request_verify(user, request)
async def on_after_login(self, user: User, request: "Request | None" = None,
response=None) -> None:
audit.log_activity("auth.login", {"user_id": str(user.id), "verified": user.is_verified})
async def on_after_verify(self, user: User, request: "Request | None" = None) -> None:
audit.log_activity("auth.verify", {"user_id": str(user.id)})
async def on_after_request_verify( async def on_after_request_verify(
self, user: User, token: str, request: "Request | None" = None self, user: User, token: str, request: "Request | None" = None
) -> None: ) -> None:

View file

@ -30,6 +30,7 @@ AUDIT_DIR = os.path.join(LOG_ROOT, "audit")
ERROR_DIR = os.path.join(LOG_ROOT, "errors") ERROR_DIR = os.path.join(LOG_ROOT, "errors")
ACCESS_DIR = os.path.join(LOG_ROOT, "access") ACCESS_DIR = os.path.join(LOG_ROOT, "access")
HEARTBEAT_DIR = os.path.join(LOG_ROOT, "heartbeat") HEARTBEAT_DIR = os.path.join(LOG_ROOT, "heartbeat")
ACTIVITY_DIR = os.path.join(LOG_ROOT, "activity")
_lock = threading.Lock() _lock = threading.Lock()
@ -69,6 +70,17 @@ def log_event(tag: str, record: dict) -> dict:
return rec return rec
def log_activity(tag: str, record: dict) -> dict:
"""Record a non-error application event — auth, subscriptions, notification
delivery, app lifecycle, slow requests to a dedicated *activity* stream,
kept separate from the errors folder so these never pollute error dashboards.
``tag`` names the event (e.g. ``"auth.login"``, ``"notify.pass"``). Carries no
PII: user ids are UUIDs, and no emails/request bodies/push endpoints. Best-effort."""
rec = {"ts": _now_iso(), "tag": tag, "run_id": current_run_id.get(), **record}
_append(os.path.join(ACTIVITY_DIR, f"activity-{_today()}.jsonl"), rec)
return rec
def log_access(record: dict) -> None: def log_access(record: dict) -> None:
"""Append one request line (client IP, method, path, status) to the daily access """Append one request line (client IP, method, path, status) to the daily access
log, so request/IP data is retained for later analysis. Best-effort; never raises log, so request/IP data is retained for later analysis. Best-effort; never raises

View file

@ -27,6 +27,8 @@ import ssl
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid, parseaddr from email.utils import formataddr, formatdate, make_msgid, parseaddr
from core import audit
log = logging.getLogger("thermograph.mail") log = logging.getLogger("thermograph.mail")
# console -> log the message, send nothing (default; dev + tests) # console -> log the message, send nothing (default; dev + tests)
@ -91,6 +93,7 @@ def send(to: str, subject: str, text: str, html: "str | None" = None) -> bool:
if BACKEND != "smtp": if BACKEND != "smtp":
# console (the default): prove the flow end-to-end without sending. # console (the default): prove the flow end-to-end without sending.
log.info("mail[console] to=%s subject=%s\n%s", to, subject, text) log.info("mail[console] to=%s subject=%s\n%s", to, subject, text)
audit.log_activity("mail.send", {"ok": True, "backend": "console"})
return True return True
try: try:
@ -100,7 +103,9 @@ def send(to: str, subject: str, text: str, html: "str | None" = None) -> bool:
if SMTP_USER: if SMTP_USER:
smtp.login(SMTP_USER, SMTP_PASSWORD) smtp.login(SMTP_USER, SMTP_PASSWORD)
smtp.send_message(msg) smtp.send_message(msg)
audit.log_activity("mail.send", {"ok": True, "backend": "smtp"})
return True return True
except Exception: # noqa: BLE001 - deliverability is never worth a 500 except Exception: # noqa: BLE001 - deliverability is never worth a 500
log.exception("mail: send failed to=%s host=%s:%s", to, SMTP_HOST, SMTP_PORT) log.exception("mail: send failed to=%s host=%s:%s", to, SMTP_HOST, SMTP_PORT)
audit.log_activity("mail.send", {"ok": False, "backend": "smtp"})
return False return False

View file

@ -161,7 +161,8 @@ def _send_discord_job(job: tuple[str, str, str, str]) -> None:
"""Best-effort Discord DM of one gathered job. Never raises (send_dm itself """Best-effort Discord DM of one gathered job. Never raises (send_dm itself
doesn't). Runs off the notifier thread's send pool no DB session here.""" doesn't). Runs off the notifier thread's send pool no DB session here."""
discord_id, title, body, link = job discord_id, title, body, link = job
discord.send_dm(discord_id, title, body, link) ok = discord.send_dm(discord_id, title, body, link)
audit.log_activity("notif.discord", {"ok": bool(ok)})
# How many push/Discord sends the notifier fans out at once. These are pure # How many push/Discord sends the notifier fans out at once. These are pure
@ -302,6 +303,9 @@ def _process_cell(session, cell_id, subs, today, now, archive_budget):
continue continue
sub.last_notified_at = now sub.last_notified_at = now
created += 1 created += 1
audit.log_activity("notif.emit", {"user_id": str(sub.user_id), "sub_id": sub.id,
"metric": metric, "direction": direction,
"kind": sub.kind, "percentile": graded["percentile"]})
# Gather who to reach over Web Push and Discord DM (the in-app row above # Gather who to reach over Web Push and Discord DM (the in-app row above
# is the record of truth for the bell; these are side-channels that must # is the record of truth for the bell; these are side-channels that must
# never break it) — the actual sends happen after commit, see below. # never break it) — the actual sends happen after commit, see below.
@ -345,9 +349,12 @@ def _cleanup_sessions(session) -> None:
def run_pass() -> int: def run_pass() -> int:
"""One full evaluation sweep over all active subscriptions. Returns the number """One full evaluation sweep over all active subscriptions. Returns the number
of notifications created.""" of notifications created."""
t0 = time.perf_counter()
now = time.time() now = time.time()
today = datetime.date.today() today = datetime.date.today()
created = 0 created = 0
subs: list = []
by_cell: dict[str, list] = {}
archive_budget = [MAX_ARCHIVE_FETCHES_PER_PASS] # missing archives to fetch this pass archive_budget = [MAX_ARCHIVE_FETCHES_PER_PASS] # missing archives to fetch this pass
with sync_session_maker() as session: with sync_session_maker() as session:
subs = session.execute( subs = session.execute(
@ -365,6 +372,10 @@ def run_pass() -> int:
_cleanup_sessions(session) # opportunistic expired-token sweep _cleanup_sessions(session) # opportunistic expired-token sweep
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
session.rollback() session.rollback()
audit.log_activity("notify.pass", {
"subs_evaluated": len(subs), "cells": len(by_cell), "created": created,
"archives_fetched": MAX_ARCHIVE_FETCHES_PER_PASS - archive_budget[0],
"duration_ms": round((time.perf_counter() - t0) * 1000.0, 1)})
return created return created

View file

@ -48,6 +48,8 @@ audit.ERROR_DIR = os.path.join(_TMP, "logs", "errors")
# The request-logging middleware runs under TestClient too; keep its access log # The request-logging middleware runs under TestClient too; keep its access log
# out of the repo's logs/ so self-test traffic never pollutes the live monitor. # out of the repo's logs/ so self-test traffic never pollutes the live monitor.
audit.ACCESS_DIR = os.path.join(_TMP, "logs", "access") audit.ACCESS_DIR = os.path.join(_TMP, "logs", "access")
audit.ACTIVITY_DIR = os.path.join(_TMP, "logs", "activity")
audit.HEARTBEAT_DIR = os.path.join(_TMP, "logs", "heartbeat")
def _years_before(d: datetime.date, years: int) -> datetime.date: def _years_before(d: datetime.date, years: int) -> datetime.date:

View file

@ -85,7 +85,7 @@ def _fake_session(user):
def _sub(): def _sub():
return types.SimpleNamespace(user_id="u1", lat=47.6, lon=-122.3) return types.SimpleNamespace(id="s1", user_id="u1", lat=47.6, lon=-122.3)
def test_discord_job_gathers_linked_opted_in_user(monkeypatch): def test_discord_job_gathers_linked_opted_in_user(monkeypatch):

View file

@ -15,7 +15,7 @@ from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from accounts import api_accounts from accounts import api_accounts
from api import content_routes from api import content_routes
@ -167,7 +167,11 @@ async def _lifespan(app):
# Same leader gate as the notifier above — the recurring worker jobs # Same leader gate as the notifier above — the recurring worker jobs
# (city warming, IndexNow) must likewise run in exactly one process. # (city warming, IndexNow) must likewise run in exactly one process.
scheduler.start() scheduler.start()
# Deploy/restart marker: one line correlates a metric shift with a boot.
audit.log_activity("app.start", {"version": app.version, "role": ROLE, "base": BASE,
"notifier": _should_run_notifier(), "pid": os.getpid()})
yield yield
audit.log_activity("app.stop", {"role": ROLE, "pid": os.getpid()})
notify.stop() notify.stop()
scheduler.stop() scheduler.stop()
await _frontend_client.aclose() await _frontend_client.aclose()
@ -202,6 +206,25 @@ if _cors_origins:
) )
# Log a structured line for every unhandled 500. Without an exception handler the
# route exception propagates out past the request middleware (call_next re-raises),
# so a 500 is otherwise invisible in both the access log and metrics — this makes
# it visible and countable (tag="http.error" in the errors stream).
SLOW_REQUEST_MS = 2000.0 # http.slow: log any request slower than this
@app.exception_handler(Exception)
async def _log_unhandled_exception(request: Request, exc: Exception) -> Response:
try:
audit.log_event("http.error", {
"phase": "http", "path": request.url.path, "method": request.method,
"cat": metrics.classify_inbound(request.url.path, BASE),
"exc_type": type(exc).__name__, "error": repr(exc)[:500]})
except Exception: # noqa: BLE001 - logging must never mask the original error
pass
return JSONResponse({"detail": "Internal server error."}, status_code=500)
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
"""Liveness probe: the process is up and serving. Deliberately does no I/O (no """Liveness probe: the process is up and serving. Deliberately does no I/O (no
@ -246,7 +269,9 @@ async def revalidate_static(request, call_next):
request answered with an empty 304 instead of a full re-download page-to-page request answered with an empty 304 instead of a full re-download page-to-page
navigation stops re-transferring the JS/CSS/HTML while still picking up every navigation stops re-transferring the JS/CSS/HTML while still picking up every
deploy immediately (no "my change isn't showing" bugs).""" deploy immediately (no "my change isn't showing" bugs)."""
t0 = time.perf_counter()
response = await call_next(request) response = await call_next(request)
dur_ms = (time.perf_counter() - t0) * 1000.0
path = request.url.path path = request.url.path
try: try:
cat = metrics.classify_inbound(path, BASE) cat = metrics.classify_inbound(path, BASE)
@ -260,6 +285,14 @@ async def revalidate_static(request, call_next):
await run_in_threadpool(audit.log_access, { await run_in_threadpool(audit.log_access, {
"ip": _client_ip(request), "method": request.method, "ip": _client_ip(request), "method": request.method,
"path": path, "status": response.status_code, "cat": cat}) "path": path, "status": response.status_code, "cat": cat})
# Threshold-gated slow-request line: RunAudit only times the 7 graded
# endpoints, so this is the only latency signal for auth/account/content
# routes. Kept rare (>2s) so it never becomes per-request hot-path noise.
# Also a blocking file write, so keep it off the event loop.
if dur_ms > SLOW_REQUEST_MS and cat != "static":
await run_in_threadpool(audit.log_activity, "http.slow", {
"path": path, "method": request.method, "cat": cat,
"status": response.status_code, "duration_ms": round(dur_ms, 1)})
except Exception: # noqa: BLE001 - never let instrumentation break a response except Exception: # noqa: BLE001 - never let instrumentation break a response
pass pass
pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/score", pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/score",