/** * Сетевой лог сценариев (Task 9): все запросы браузера к API -> perf/results//network.jsonl. * Формат строки: perfkit/bench.py (docstring). Подключается во всех сценариях одной строкой после newContext: * const nl = attachNetlog(ctx, { scenario, target, role }); ... finally { await nl.flush(); await ctx.close(); } * Размер тела - request.sizes().responseBodySize (по CDP, работает и для ответов 30-47 МБ, где response.body() нет). * Тело запроса пишется для не-GET (для повтора в bench); ограничено 64 КБ. Секреты не пишутся (sanitizePost): * для путей с /auth/ тело = null (логин, refresh), из остальных тел вырезаются поля password/token/secret. */ import type { BrowserContext, Request } from '@playwright/test'; import { appendFileSync, mkdirSync } from 'node:fs'; import path from 'node:path'; import { API, WEB } from './auth'; import { runDir, runId } from './results'; export interface NetMeta { scenario: string; target: string; role: string } const MAX_POST = 64 * 1024; const SECRET_KEY = /password|passwd|^pass$|token|secret/i; function stripSecrets(v: unknown): unknown { if (Array.isArray(v)) return v.map(stripSecrets); if (v && typeof v === 'object') { return Object.fromEntries(Object.entries(v as Record).filter(([k]) => !SECRET_KEY.test(k)).map(([k, x]) => [k, stripSecrets(x)])); } return v; } /** * Тело не-GET запроса для лога: null для /auth/ и пустых тел; JSON и form-urlencoded - без полей password/token/secret; * неразбираемое тело, в котором встречается такое слово, - null (лучше потерять тело, чем записать пароль). */ export function sanitizePost(pathname: string, post: string | null): string | null { if (!post || pathname.includes('/auth/')) return null; try { const parsed = JSON.parse(post); const clean = stripSecrets(parsed); return JSON.stringify(clean) === JSON.stringify(parsed) ? post : JSON.stringify(clean); } catch { /* не JSON */ } if (/^[^\s=&]+=[^&]*(&[^\s=&]+=[^&]*)*$/.test(post)) { const q = new URLSearchParams(post); let changed = false; for (const k of [...q.keys()]) if (SECRET_KEY.test(k)) { q.delete(k); changed = true; } return changed ? q.toString() : post; } return SECRET_KEY.test(post) ? null : post; } export function netlogFile(): string { return path.join(runDir(), 'network.jsonl'); } export function attachNetlog(ctx: BrowserContext, meta: NetMeta): { flush: () => Promise } { const pending = new Set>(); const handle = async (req: Request) => { const url = req.url(); // фронт ходит в API через proxy своего origin (:8080/api), прямые вызовы - на PERF_API if (!(url.startsWith(WEB + '/api/') || url.startsWith(API + '/api/'))) return; let status = 0, body_size = -1; try { status = (await req.response())?.status() ?? 0; } catch { /* контекст закрыт */ } try { body_size = (await req.sizes()).responseBodySize; } catch { /* контекст закрыт */ } const t = req.timing(); const u = new URL(url); const post = req.method() === 'GET' ? null : sanitizePost(u.pathname, req.postData()); const rec = { run_id: runId(), ts: new Date().toISOString(), ...meta, method: req.method(), url, path: u.pathname, query: u.search.replace(/^\?/, ''), status, resource_type: req.resourceType(), post_data: post && post.length <= MAX_POST ? post : null, duration_ms: t.responseEnd >= 0 ? Math.round(t.responseEnd * 10) / 10 : null, ttfb_ms: t.responseStart >= 0 ? Math.round(t.responseStart * 10) / 10 : null, body_size, }; mkdirSync(runDir(), { recursive: true }); appendFileSync(netlogFile(), JSON.stringify(rec) + '\n', 'utf-8'); }; ctx.on('requestfinished', (req) => { const p = handle(req).catch((e) => { if (process.env.PERF_NETLOG_DEBUG) console.log("netlog err", e); }).finally(() => pending.delete(p)); pending.add(p); }); return { flush: async () => { await Promise.all([...pending]); } }; }