- perf/run-all.sh: масштабы 1 и 2, шаги reset -> gen -> verify -> stand -> preflight -> scroll -> coauthor -> grow -> modals -> modals_profiling -> memory -> memtrend -> [control4x] -> bench -> aggregate; лог и run-all.status.json, возобновление (PERF_STEPS, PERF_SKIP_RESET), сухой режим с оценкой длительности, PERF_S2_REPEATS/PERF_S2_TARGETS, контроль CPU 4x (PERF_CONTROL_TARGETS). - Основная метрика: main_thread_ms и main_thread_ms_per_step в скролле (CDP TaskDuration), frame_excess_ms; в summary.md первыми строками. - SQL-статистика по шагам: PERF_SQLSTATS_TAG -> sql/<шаг>/ (sqlstats --out). - netlog: тела /auth/ не пишутся, поля password/token/secret вырезаются. - scroll.spec: уникальные имена целей (лист ССП с суффиксом /ssp, год в отчёте проекта) - иначе Playwright не запускал файл из-за дублей. - PERF_TRACE=0 для прогонов с одним повтором. - README: scroll-coauthor, run-all, возобновление, без CPU 4/6 в матрицах. - design.md: уточнена формулировка R-2, решения Task 10a. Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
80 lines
4.3 KiB
TypeScript
80 lines
4.3 KiB
TypeScript
/**
|
||
* Сетевой лог сценариев (Task 9): все запросы браузера к API -> perf/results/<run_id>/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<string, unknown>).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<void> } {
|
||
const pending = new Set<Promise<void>>();
|
||
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]); } };
|
||
}
|