Files
robotex/src/db/stats.py
T
protokeyandClaude Opus 5.5 1a4bd8d672 feature: API-ключи клиентов и журнал использования в Postgres (#4, #17)
- 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>
2026-10-13 21:48:48 +04:00

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)