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:
parent
bcc4371ac7
commit
f255728f75
15 changed files with 451 additions and 26 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue