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 bcc4371ac7
commit f255728f75
15 changed files with 451 additions and 26 deletions

View file

@ -0,0 +1,46 @@
"""quota_events: per-spend audit log for per-user quota attribution
Revision ID: 0009_quota_events
Revises: 0008_invites
Create Date: 2026-06-12
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0009_quota_events"
down_revision: Union[str, None] = "0008_invites"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"quota_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("action", sa.String(length=40), nullable=False),
sa.Column("units", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_quota_events_user_id", "quota_events", ["user_id"])
op.create_index("ix_quota_events_action", "quota_events", ["action"])
op.create_index("ix_quota_events_created_at", "quota_events", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_quota_events_created_at", table_name="quota_events")
op.drop_index("ix_quota_events_action", table_name="quota_events")
op.drop_index("ix_quota_events_user_id", table_name="quota_events")
op.drop_table("quota_events")

View file

@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth
from app.config import settings
from app.routes import admin, channels, feed, health, me, sync, tags
from app.routes import admin, channels, feed, health, me, quota, sync, tags
from app.scheduler import shutdown_scheduler, start_scheduler
@ -67,6 +67,7 @@ app.include_router(feed.router)
app.include_router(me.router)
app.include_router(channels.router)
app.include_router(admin.router)
app.include_router(quota.router)
# The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa"

View file

@ -265,6 +265,25 @@ class ApiQuotaUsage(Base):
units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
class QuotaEvent(Base):
"""Per-spend audit log for YouTube API quota attribution. `user_id` is the end-user
whose action caused the spend; NULL means background/system work (scheduler backfill,
enrichment, resync). The aggregate budget still lives in ApiQuotaUsage; this is detail."""
__tablename__ = "quota_events"
id: Mapped[int] = mapped_column(primary_key=True)
# SET NULL on user delete: keep the audit trail (it just becomes system-attributed).
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
action: Mapped[str] = mapped_column(String(40), index=True)
units: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
class AppState(Base):
"""Single-row global app state (admin-controlled)."""

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()

View file

@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from app import quota
from app.auth import current_user, has_write_scope
from app.db import get_db
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
@ -121,6 +122,7 @@ def update_channel(
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
with quota.attribute(user.id, "backfill_recent"):
run_recent_backfill(db, [channel], max_channels=1)
return {
@ -152,7 +154,7 @@ def unsubscribe(
detail="No YouTube subscription id on record — sync subscriptions first.",
)
try:
with YouTubeClient(db, user) as yt:
with quota.attribute(user.id, "unsubscribe"), YouTubeClient(db, user) as yt:
yt.delete_subscription(sub.yt_subscription_id)
except YouTubeError as exc:
raise HTTPException(status_code=502, detail=f"YouTube unsubscribe failed: {exc}")

106
backend/app/routes/quota.py Normal file
View file

@ -0,0 +1,106 @@
"""Quota usage attribution: a user's own consumption + an admin breakdown across users.
The aggregate daily budget lives in ApiQuotaUsage; here we read the per-spend audit log
(QuotaEvent) to attribute units to the user who triggered them (NULL actor = system).
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import quota
from app.auth import current_user
from app.db import get_db
from app.models import QuotaEvent, User
from app.routes.admin import admin_user
router = APIRouter(prefix="/api/quota", tags=["quota"])
def _sum(db: Session, *conds) -> int:
return int(
db.scalar(
select(func.coalesce(func.sum(QuotaEvent.units), 0)).where(*conds)
)
or 0
)
@router.get("/my-usage")
def my_usage(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
now = datetime.now(timezone.utc)
mine = QuotaEvent.user_id == user.id
by_action = {
action: int(units)
for action, units in db.execute(
select(QuotaEvent.action, func.sum(QuotaEvent.units))
.where(mine, QuotaEvent.created_at >= now - timedelta(days=30))
.group_by(QuotaEvent.action)
).all()
}
return {
"today": _sum(db, mine, QuotaEvent.created_at >= quota.pacific_day_start_utc()),
"last_7d": _sum(db, mine, QuotaEvent.created_at >= now - timedelta(days=7)),
"last_30d": _sum(db, mine, QuotaEvent.created_at >= now - timedelta(days=30)),
"all_time": _sum(db, mine),
"by_action": by_action,
}
@router.get("/admin")
def admin_usage(
days: int = 30,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
days = max(1, min(days, 365))
since = datetime.now(timezone.utc) - timedelta(days=days)
totals = db.execute(
select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.group_by(QuotaEvent.user_id)
).all()
per_action: dict[int | None, dict[str, int]] = {}
for uid, action, units in db.execute(
select(QuotaEvent.user_id, QuotaEvent.action, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.group_by(QuotaEvent.user_id, QuotaEvent.action)
).all():
per_action.setdefault(uid, {})[action] = int(units)
uids = [uid for uid, _ in totals if uid is not None]
emails = (
{u.id: u.email for u in db.execute(select(User).where(User.id.in_(uids))).scalars()}
if uids
else {}
)
rows = [
{
"user_id": uid,
"email": emails.get(uid, "(deleted user)") if uid is not None else "System",
"total": int(total),
"by_action": per_action.get(uid, {}),
}
for uid, total in totals
]
rows.sort(key=lambda r: r["total"], reverse=True)
# Instance-wide daily series in Pacific days (matches the budget reset).
day_expr = func.date(func.timezone("America/Los_Angeles", QuotaEvent.created_at))
daily = [
{"day": str(day), "total": int(units)}
for day, units in db.execute(
select(day_expr, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.group_by(day_expr)
.order_by(day_expr)
).all()
]
return {"range_days": days, "rows": rows, "daily": daily}

View file

@ -34,6 +34,7 @@ def sync_subscriptions(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
before = quota.units_used_today(db)
with quota.attribute(user.id, "sync_subscriptions"):
result = import_subscriptions(db, user)
result["quota_used_estimate"] = quota.units_used_today(db) - before
result["quota_remaining_today"] = quota.remaining_today(db)
@ -55,6 +56,7 @@ def sync_backfill(
max_channels: int = 25,
) -> dict:
before = quota.units_used_today(db)
with quota.attribute(user.id, "backfill_recent"):
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
result["quota_used_estimate"] = quota.units_used_today(db) - before
return result
@ -65,6 +67,7 @@ def sync_enrich(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
before = quota.units_used_today(db)
with quota.attribute(user.id, "enrich"):
enriched = run_enrich(db)
return {
"enriched": enriched,

View file

@ -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,13 +43,20 @@ 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):
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}
@ -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:

View file

@ -22,6 +22,7 @@ import Header from "./components/Header";
import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
import Channels from "./components/Channels";
import Stats from "./components/Stats";
import SettingsPanel from "./components/SettingsPanel";
import Toaster from "./components/Toaster";
@ -157,9 +158,7 @@ export default function App() {
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">
{page === "feed" ? (
<Feed filters={filters} setFilters={setFilters} view={view} />
) : (
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
onViewChannel={(id, name) => {
@ -167,6 +166,10 @@ export default function App() {
setPage("feed");
}}
/>
) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats />
) : (
<Feed filters={filters} setFilters={setFilters} view={view} />
)}
</main>
</div>

View file

@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { Home, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import { BarChart3, Home, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus";
@ -48,7 +48,9 @@ export default function Header({
/>
</div>
) : (
<div className="flex-1 text-center text-sm font-semibold">Channel manager</div>
<div className="flex-1 text-center text-sm font-semibold">
{page === "stats" ? "Usage & stats" : "Channel manager"}
</div>
)}
<div className="flex items-center gap-1">
@ -134,17 +136,24 @@ function AccountMenu({
)}
<div className="mt-2 flex flex-col">
{page === "channels" ? (
{page !== "feed" && (
<button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
<Home className="w-4 h-4" />
Feed
</button>
) : (
)}
{page !== "channels" && (
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
<Tv className="w-4 h-4" />
Channels
</button>
)}
{me.role === "admin" && page !== "stats" && (
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
<BarChart3 className="w-4 h-4" />
Stats
</button>
)}
<button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
<Settings className="w-4 h-4" />
Settings

View file

@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Invite, type Me } from "../lib/api";
import { formatEta } from "../lib/format";
import { formatEta, quotaActionLabel } from "../lib/format";
import {
configureNotifications,
getNotifSettings,
@ -322,6 +322,7 @@ function Notifications() {
function Sync({ me }: { me: Me }) {
const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
@ -392,6 +393,36 @@ function Sync({ me }: { me: Me }) {
)}
</Section>
<Section title="Your API usage">
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label="Today" hint="YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.">
{usage.data.today.toLocaleString()}
</Row>
<Row label="Last 7 days">{usage.data.last_7d.toLocaleString()}</Row>
<Row label="Last 30 days">{usage.data.last_30d.toLocaleString()}</Row>
<Row label="All time">{usage.data.all_time.toLocaleString()}</Row>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
<div className="uppercase tracking-wide">By action (30d)</div>
{Object.entries(usage.data.by_action)
.sort((a, b) => b[1] - a[1])
.map(([action, units]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
</div>
))}
</div>
)}
</>
) : (
<div className="text-muted text-sm">No usage yet.</div>
)}
</Section>
<Section title="Actions">
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
<button

View file

@ -0,0 +1,123 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, type AdminQuotaRow } from "../lib/api";
import { quotaActionLabel } from "../lib/format";
const RANGES = [7, 30, 90] as const;
export default function Stats() {
const [days, setDays] = useState<number>(30);
const q = useQuery({
queryKey: ["admin-quota", days],
queryFn: () => api.adminQuota(days),
});
const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
return (
<div className="p-4 max-w-4xl mx-auto">
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">API quota usage</h2>
<div className="flex items-center gap-1">
{RANGES.map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1.5 rounded-full text-sm border transition ${
days === d
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{d}d
</button>
))}
</div>
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
YouTube Data API units attributed by who triggered the spend. <b className="text-fg/80">System</b> is
shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.
</p>
{q.isLoading ? (
<div className="text-muted py-8">Loading</div>
) : !data ? (
<div className="text-muted py-8">No data.</div>
) : (
<>
{/* Daily totals (instance-wide, Pacific days) */}
<div className="glass-card rounded-xl p-3 mb-4">
<div className="text-xs text-muted mb-2">
Daily total ({data.range_days}d · {grandTotal.toLocaleString()} units)
</div>
{data.daily.length === 0 ? (
<div className="text-muted text-sm">No usage in this range.</div>
) : (
<div className="flex items-end gap-0.5 h-24">
{data.daily.map((d) => (
<div
key={d.day}
className="flex-1 bg-accent/70 hover:bg-accent rounded-t transition"
style={{ height: `${Math.max(2, (d.total / maxDaily) * 100)}%` }}
title={`${d.day}: ${d.total.toLocaleString()} units`}
/>
))}
</div>
)}
</div>
{/* Per-user breakdown */}
<div className="flex flex-col gap-1.5">
{data.rows.length === 0 ? (
<div className="text-muted py-4">No usage in this range.</div>
) : (
data.rows.map((r) => (
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
))
)}
</div>
</>
)}
</div>
);
}
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null;
return (
<div className="glass-card rounded-xl p-3">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-3 text-left"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? "System (background)" : row.email}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{row.total.toLocaleString()}
</span>
</button>
{/* Proportion bar */}
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full"
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
/>
</div>
{open && actions.length > 0 && (
<div className="mt-2 space-y-1 text-xs text-muted">
{actions.map(([action, units]) => (
<div key={action} className="flex items-center justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -165,6 +165,27 @@ export interface MyStatus {
paused: boolean;
}
export interface MyUsage {
today: number;
last_7d: number;
last_30d: number;
all_time: number;
by_action: Record<string, number>;
}
export interface AdminQuotaRow {
user_id: number | null;
email: string;
total: number;
by_action: Record<string, number>;
}
export interface AdminQuota {
range_days: number;
rows: AdminQuotaRow[];
daily: { day: string; total: number }[];
}
export interface ManagedChannel {
id: string;
title: string | null;
@ -215,6 +236,10 @@ export const api = {
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
// --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),

View file

@ -34,6 +34,20 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
const QUOTA_ACTION_LABELS: Record<string, string> = {
sync_subscriptions: "Sync subscriptions",
backfill_recent: "Recent backfill",
backfill_deep: "Full-history backfill",
enrich: "Enrichment",
unsubscribe: "Unsubscribe",
subscription_resync: "Auto subscription resync",
api: "Other",
};
export function quotaActionLabel(action: string): string {
return QUOTA_ACTION_LABELS[action] ?? action;
}
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */
export function formatEta(seconds: number): string {
if (seconds <= 0) return "done";

View file

@ -74,12 +74,11 @@ export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k));
}
export type Page = "feed" | "channels";
export type Page = "feed" | "channels" | "stats";
export function readPage(): Page {
return new URLSearchParams(window.location.search).get("page") === "channels"
? "channels"
: "feed";
const p = new URLSearchParams(window.location.search).get("page");
return p === "channels" || p === "stats" ? p : "feed";
}
/** Reflect the current filters + page into the address bar without adding history entries. */