feat(stats): per-user API quota attribution + admin usage page

Track who burned how much YouTube API quota. A QuotaEvent audit log (migration
0009) records every spend with the triggering user (NULL = background/system) and
an action label, set via a request/job-scoped contextvar (quota.attribute) so no
call signatures change. User-initiated work (sync subscriptions, unsubscribe,
opt-in recent backfill, manual enrich) attributes to the user; scheduler work to
System, split by action.

- backend: QuotaEvent model + migration 0009; quota.attribute() contextvar;
  record_usage logs events; entry points wrapped (routes/sync, routes/channels,
  scheduler); GET /api/quota/my-usage + GET /api/quota/admin
- frontend: admin-only Stats page (header nav, page=stats) with daily bars +
  per-user breakdown by action and range picker; 'Your API usage' in Settings ->
  Sync for every user

Verified: attribution + endpoints compute correctly; events are per-user vs System.
This commit is contained in:
npeter83 2026-06-12 02:47:55 +02:00
parent 80cae63811
commit f6375a097e
15 changed files with 451 additions and 26 deletions

View file

@ -4,22 +4,52 @@ The whole app shares one daily budget (the API resets at midnight US Pacific tim
Steady-state work (RSS detection is free; enrichment of new videos is cheap) should
always be allowed; expensive backfill must yield to the remaining budget.
"""
from datetime import datetime
import contextlib
import contextvars
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.config import settings
from app.models import ApiQuotaUsage
from app.models import ApiQuotaUsage, QuotaEvent
_PACIFIC = ZoneInfo("America/Los_Angeles")
# Request/job-scoped attribution for quota spend: who triggered it and what kind of work.
# Set at entry points (route handlers, scheduler jobs) via attribute(); read by
# record_usage. Default = background/system, generic action.
_actor_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"quota_actor_id", default=None
)
_action: contextvars.ContextVar[str] = contextvars.ContextVar(
"quota_action", default="api"
)
@contextlib.contextmanager
def attribute(actor_id: int | None, action: str):
"""Attribute any quota spent in this block to `actor_id` (None = system) under `action`."""
t1 = _actor_id.set(actor_id)
t2 = _action.set(action)
try:
yield
finally:
_actor_id.reset(t1)
_action.reset(t2)
def pacific_today():
return datetime.now(_PACIFIC).date()
def pacific_day_start_utc() -> datetime:
"""UTC instant of the current Pacific day's midnight — aligns 'today' with the budget reset."""
start = datetime.now(_PACIFIC).replace(hour=0, minute=0, second=0, microsecond=0)
return start.astimezone(timezone.utc)
def units_used_today(db: Session) -> int:
row = db.get(ApiQuotaUsage, pacific_today())
return row.units_used if row else 0
@ -34,7 +64,7 @@ def can_spend(db: Session, units: int) -> bool:
def record_usage(db: Session, units: int) -> None:
"""Atomically add `units` to today's counter (upsert)."""
"""Atomically add `units` to today's counter (upsert) and log an attribution event."""
if units <= 0:
return
day = pacific_today()
@ -47,4 +77,6 @@ def record_usage(db: Session, units: int) -> None:
)
)
db.execute(stmt)
# Audit detail: who/what spent it (per-user attribution; NULL actor = system).
db.add(QuotaEvent(user_id=_actor_id.get(), action=_action.get(), units=units))
db.commit()