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
0dad71cf6e
commit
d610647d13
15 changed files with 451 additions and 26 deletions
|
|
@ -4,6 +4,7 @@ import logging
|
|||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app import quota
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.state import is_sync_paused
|
||||
|
|
@ -42,14 +43,21 @@ def _rss_job() -> None:
|
|||
|
||||
|
||||
def _enrich_job() -> None:
|
||||
_job("enrich", lambda db: run_enrich(db))
|
||||
def work(db):
|
||||
with quota.attribute(None, "enrich"):
|
||||
return run_enrich(db)
|
||||
|
||||
_job("enrich", work)
|
||||
|
||||
|
||||
def _backfill_job() -> None:
|
||||
# Recent-first for not-yet-synced channels, then deep backfill for the rest.
|
||||
# Recent-first for not-yet-synced channels, then deep backfill for the rest. All
|
||||
# background spend is attributed to the system (no actor), split by action.
|
||||
def work(db):
|
||||
recent = run_recent_backfill(db, max_channels=25)
|
||||
deep = run_deep_backfill(db, max_channels=10)
|
||||
with quota.attribute(None, "backfill_recent"):
|
||||
recent = run_recent_backfill(db, max_channels=25)
|
||||
with quota.attribute(None, "backfill_deep"):
|
||||
deep = run_deep_backfill(db, max_channels=10)
|
||||
return {"recent": recent, "deep": deep}
|
||||
|
||||
_job("backfill", work)
|
||||
|
|
@ -64,7 +72,11 @@ def _shorts_job() -> None:
|
|||
|
||||
|
||||
def _subscriptions_job() -> None:
|
||||
_job("subscriptions", run_subscription_resync)
|
||||
def work(db):
|
||||
with quota.attribute(None, "subscription_resync"):
|
||||
return run_subscription_resync(db)
|
||||
|
||||
_job("subscriptions", work)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue