- 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>
This commit is contained in:
1 parent
9dc2c0b619
commit
1a4bd8d672
34 files changed
+857
-81
No files matched your search
@@ -0,0 +1,77 @@
|
||||
"""Статистика использования 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)
|
||||
Reference in new issue
Block a user