"""Бенч запросов бэка: python -m perfkit.bench [--repeats 20] [--api URL] [--only REGEX] [--max-logged-s 60]. Берёт сетевой лог сценариев /network.jsonl (пишет e2e/lib/netlog.ts), выбирает уникальные безопасные запросы и повторяет их через httpx под той же ролью (логин по манифесту). Строка network.jsonl (JSON): run_id, ts, scenario, target, role, method, url (полный, с query), path, query, status, resource_type, post_data (тело не-GET или null), duration_ms, ttfb_ms, body_size. Что повторяется: * GET со статусом < 400, кроме /auth/ и не-API; один раз на (role, method, url); * PATCH ячеек (.../cell, .../cells) - только если строка лога помечена "idempotent": true (значение в теле заведомо равно текущему). Сейчас сценарии такую пометку не ставят (правки идут по WebSocket), поэтому PATCH не повторяется: идемпотентность доказать нечем; * POST/PUT/DELETE и всё остальное - никогда (POST .../line создаёт строку). Запросы, которые в логе длились дольше --max-logged-s (по умолчанию 60 с, свод 2 грузится минуты), пропускаются и печатаются в stdout: x20 повторов такого ответа (31-47 МБ) заняли бы часы; единичный замер уже есть в логе. Запись в /api.jsonl: scenario "api", target " <шаблон пути>" (числовые сегменты -> {id}), role, metrics {ms: полное время ответа с чтением тела, bytes: размер тела после распаковки}, плюс поле url. """ from __future__ import annotations import argparse import json import re import sys import time from pathlib import Path import httpx from . import RESULTS_DIR from .manifest import load as load_manifest, manifest_path from .results import append SAFE_PATCH = re.compile(r"/sheet/[^/]+/cells?$|/report/[^/]+/[^/]+/cells?$") NUM_SEG = re.compile(r"(?<=/)\d+(?=/|$)") def path_template(path: str) -> str: return NUM_SEG.sub("{id}", path.split("?", 1)[0]) def _path(url: str) -> str: return httpx.URL(url).path def parse_target(target: str) -> tuple[str, str]: m, p = target.split(" ", 1) return m, p def select_requests(entries, max_logged_ms: float | None = None) -> list[dict]: seen: set[tuple] = set() out = [] for e in entries: method, url = e["method"], e["url"] p = _path(url) if not p.startswith("/api/") or "/auth/" in p: continue if not (0 < e.get("status", 0) < 400): continue if method == "GET": pass elif method == "PATCH" and SAFE_PATCH.search(p) and e.get("idempotent") is True and e.get("post_data"): pass else: continue if max_logged_ms is not None and (e.get("duration_ms") or 0) > max_logged_ms: continue key = (e["role"], method, url, e.get("post_data")) if key in seen: continue seen.add(key) out.append(e) return out def record_for(e: dict, repeat: int, ms: float, nbytes: int, run_id: str, scale: int, cpu_rate=1, incomplete: bool = False, skip_reason: str | None = None) -> dict: r = { "run_id": run_id, "scenario": "api", "target": f"{e['method']} {path_template(_path(e['url']))}", "role": e["role"], "scale": scale, "cpu_rate": cpu_rate, "repeat": repeat, "metrics": {"ms": ms, "bytes": nbytes} if not incomplete else {}, "artifacts": [], "incomplete": incomplete, "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "url": e["url"], } if skip_reason: r["skip_reason"] = skip_reason return r def read_netlog(run_dir) -> list[dict]: f = Path(run_dir) / "network.jsonl" if not f.exists(): return [] return [json.loads(l) for l in f.read_text(encoding="utf-8").splitlines() if l.strip()] def _login(client: httpx.Client, api: str, user) -> str: r = client.post(f"{api}/api/v1/auth/login", json={"username": user.username, "password": user.password}) r.raise_for_status() return r.json()["access_token"] def run(run_dir, repeats: int = 20, api: str = "http://localhost:8000", only: str | None = None, max_logged_s: float = 60.0, scale: int = 1) -> int: run_dir = Path(run_dir) m = load_manifest(manifest_path(scale)) reqs = select_requests(read_netlog(run_dir), max_logged_s * 1000) if only: rx = re.compile(only) reqs = [e for e in reqs if rx.search(f"{e['method']} {e['url']}")] skipped = [e for e in read_netlog(run_dir) if e["method"] == "GET" and (e.get("duration_ms") or 0) > max_logged_s * 1000] for e in skipped: print(f"SKIP slow ({e['duration_ms']:.0f} ms in log): {e['role']} GET {e['url']}") (run_dir / "api.jsonl").unlink(missing_ok=True) # повторный бенч заменяет прошлый, а не дублирует tokens: dict[str, str] = {} n = 0 with httpx.Client(timeout=300.0) as c: for e in reqs: role = e["role"] if role not in tokens: tokens[role] = _login(c, api, m.users[role]) hdr = {"Authorization": f"Bearer {tokens[role]}"} url = api + httpx.URL(e["url"]).raw_path.decode() for i in range(repeats): t0 = time.perf_counter() try: r = c.request(e["method"], url, headers=hdr, content=e.get("post_data") or None) ms = (time.perf_counter() - t0) * 1000 if r.status_code >= 400: append(run_dir, record_for(e, i, 0, 0, run_dir.name, m.scale, incomplete=True, skip_reason=f"HTTP {r.status_code}")) continue append(run_dir, record_for(e, i, round(ms, 2), len(r.content), run_dir.name, m.scale)) n += 1 except httpx.HTTPError as ex: append(run_dir, record_for(e, i, 0, 0, run_dir.name, m.scale, incomplete=True, skip_reason=str(ex)[:200])) print(f"{e['role']:5} {e['method']} {path_template(_path(e['url']))} x{repeats}") print(f"api records: {n} in {run_dir / 'api.jsonl'}") return 0 def main(argv=None) -> int: ap = argparse.ArgumentParser(prog="perfkit.bench") ap.add_argument("run_dir") ap.add_argument("--repeats", type=int, default=20) ap.add_argument("--api", default="http://localhost:8000") ap.add_argument("--only", default=None, help="regex по 'METHOD url'") ap.add_argument("--max-logged-s", type=float, default=60.0) ap.add_argument("--scale", type=int, default=1) a = ap.parse_args(argv) rd = Path(a.run_dir) if not rd.is_absolute() and not rd.exists(): rd = RESULTS_DIR / a.run_dir return run(rd, a.repeats, a.api, a.only, a.max_logged_s, a.scale) if __name__ == "__main__": raise SystemExit(main())