- Postgres в compose; пакет db (SQLAlchemy async): clients, windows, requests. - Все эндпоинты, кроме /health, — только с ключом в X-API-Key, иначе 401; без выпущенных ключей доступа нет. В базе — только sha256 ключа. - Задача и окно сессии привязаны к клиенту: чужой ключ их не видит. - Воркер пишет журнал: окно (открыто, последнее обращение, закрыто) и задачу (в очереди, начало, конец, итог, данные без секретов). Сбой записи не роняет задачу. - python -m db.cli create|list|revoke|stats; make key-create/key-list/key-revoke/stats. - smoke и loadtest с ключом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
2.6 KiB
Python
78 lines
2.6 KiB
Python
"""Статистика использования API (#17): число запросов, их время и время, которое был открыт браузер."""
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
|
|
from sqlalchemy import select
|
|
|
|
from db.engine import Database
|
|
from db.models import Client, Request, Window, utcnow
|
|
|
|
|
|
@dataclass
|
|
class ClientUsage:
|
|
client_id: int | None
|
|
name: str
|
|
requests: int = 0
|
|
ok: int = 0
|
|
# по code: challenge_failed, action_failed, request_failed; failed — задачу не выполнили
|
|
errors: dict[str, int] = field(default_factory=dict)
|
|
durations_ms: list[int] = field(default_factory=list)
|
|
windows: int = 0
|
|
browser_seconds: float = 0.0
|
|
|
|
@property
|
|
def avg_ms(self) -> int | None:
|
|
return int(sum(self.durations_ms) / len(self.durations_ms)) if self.durations_ms else None
|
|
|
|
@property
|
|
def p95_ms(self) -> int | None:
|
|
if not self.durations_ms:
|
|
return None
|
|
ordered = sorted(self.durations_ms)
|
|
return ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))]
|
|
|
|
|
|
def _aware(dt: datetime) -> datetime:
|
|
# sqlite отдаёт время без зоны
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=utcnow().tzinfo)
|
|
|
|
|
|
async def usage(db: Database, since: datetime) -> list[ClientUsage]:
|
|
"""По клиентам за период: запросы, начатые после since, и время окон в пределах периода."""
|
|
now = utcnow()
|
|
async with db.session() as session:
|
|
names = {c.id: c.name for c in await session.scalars(select(Client))}
|
|
requests = list(await session.scalars(select(Request).where(Request.started_at >= since)))
|
|
windows = list(await session.scalars(
|
|
select(Window).where((Window.closed_at.is_(None)) | (Window.closed_at >= since))
|
|
))
|
|
|
|
result: dict[int | None, ClientUsage] = {}
|
|
|
|
def entry(client_id):
|
|
return result.setdefault(client_id, ClientUsage(client_id, names.get(client_id, "—")))
|
|
|
|
for r in requests:
|
|
u = entry(r.client_id)
|
|
u.requests += 1
|
|
if r.status == "ok":
|
|
u.ok += 1
|
|
elif r.finished_at is not None:
|
|
key = r.code or r.status or "unknown"
|
|
u.errors[key] = u.errors.get(key, 0) + 1
|
|
if r.duration_ms is not None:
|
|
u.durations_ms.append(r.duration_ms)
|
|
|
|
for w in windows:
|
|
u = entry(w.client_id)
|
|
u.windows += 1
|
|
start = max(_aware(w.started_at), since)
|
|
end = _aware(w.closed_at) if w.closed_at else now
|
|
u.browser_seconds += max(0.0, (end - start).total_seconds())
|
|
|
|
return sorted(result.values(), key=lambda u: -u.requests)
|
|
|
|
|
|
def since_days(days: float) -> datetime:
|
|
return utcnow() - timedelta(days=days)
|