chore: add performance evidence summary tool

This commit is contained in:
Codex
2026-06-30 13:21:17 +08:00
parent 6d5ea62bf5
commit 8e3252081f
5 changed files with 308 additions and 2 deletions

View File

@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { evaluateSummary, launchGateCheck, summarizeBenchmark } from './performance-summary.js';
const repoRoot = process.cwd();
const scriptPath = path.join(repoRoot, 'scripts', 'performance-summary.js');
function sampleReport(overrides = {}) {
return {
startedAt: '2026-06-30T04:45:08.281Z',
finishedAt: '2026-06-30T04:50:08.281Z',
apiBase: 'https://api.example.com',
config: {
durationSeconds: 300,
concurrency: 30,
rampSeconds: 30,
includeWrites: false,
questionLimit: 20,
timeoutMs: 15000,
...(overrides.config || {}),
},
summary: {
requests: 12000,
ok: 12000,
errors: 0,
errorRate: 0,
throughputRps: 40,
okThroughputRps: 40,
latencyOk: {
p50Ms: 35,
p90Ms: 120,
p95Ms: 180,
p99Ms: 430,
maxMs: 700,
...(overrides.latencyOk || {}),
},
...(overrides.summary || {}),
},
};
}
function runScript(report) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-perf-summary-'));
const inputPath = path.join(tempDir, 'report.json');
fs.writeFileSync(inputPath, JSON.stringify(report, null, 2), 'utf8');
const result = spawnSync(process.execPath, [scriptPath, '--input', inputPath, '--json'], {
cwd: repoRoot,
encoding: 'utf8',
});
const payload = JSON.parse(result.stdout || '{}');
fs.rmSync(tempDir, { recursive: true, force: true });
return { ...result, payload };
}
const summary = summarizeBenchmark(sampleReport());
assert.deepEqual(
{
errors: summary.errors,
errorRate: summary.errorRate,
p95Ms: summary.p95Ms,
p99Ms: summary.p99Ms,
concurrency: summary.concurrency,
durationSeconds: summary.durationSeconds,
includeWrites: summary.includeWrites,
},
{
errors: 0,
errorRate: 0,
p95Ms: 180,
p99Ms: 430,
concurrency: 30,
durationSeconds: 300,
includeWrites: false,
},
);
const passingEvaluation = evaluateSummary(summary);
assert.equal(passingEvaluation.status, 'pass');
assert.deepEqual(launchGateCheck(passingEvaluation).summary, {
errors: 0,
errorRate: 0,
p95Ms: 180,
p99Ms: 430,
concurrency: 30,
durationSeconds: 300,
includeWrites: false,
});
const okRun = runScript(sampleReport());
assert.equal(okRun.status, 0, `passing report should exit 0: ${okRun.stdout} ${okRun.stderr}`);
assert.equal(okRun.payload.evaluation?.status, 'pass');
const slowRun = runScript(sampleReport({ latencyOk: { p95Ms: 301 } }));
assert.notEqual(slowRun.status, 0, 'slow report should fail');
assert.equal(slowRun.payload.evaluation?.status, 'fail');
assert.ok(
slowRun.payload.evaluation?.failures?.some(item => item.includes('p95Ms expected <= 300')),
'slow report should explain p95 threshold failure',
);
const writeRun = runScript(sampleReport({ config: { includeWrites: true } }));
assert.notEqual(writeRun.status, 0, 'write benchmark should fail read-path gate');
assert.ok(
writeRun.payload.evaluation?.failures?.some(item => item.includes('includeWrites expected false')),
'write benchmark should explain includeWrites failure',
);
console.log('[PASS] performance summary');

View File

@@ -0,0 +1,185 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const defaultThresholds = {
errors: 0,
errorRate: 0.001,
p95Ms: 300,
p99Ms: 800,
concurrency: 30,
durationSeconds: 120,
};
function parseArgs(argv) {
const options = {
input: '',
output: '',
json: false,
quiet: false,
thresholds: { ...defaultThresholds },
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--json') options.json = true;
else if (arg === '--quiet') options.quiet = true;
else if (arg === '--input') {
options.input = argv[index + 1] || '';
index += 1;
} else if (arg.startsWith('--input=')) {
options.input = arg.slice('--input='.length);
} else if (arg === '--output') {
options.output = argv[index + 1] || '';
index += 1;
} else if (arg.startsWith('--output=')) {
options.output = arg.slice('--output='.length);
} else if (arg === '--max-p95-ms') {
options.thresholds.p95Ms = numberArg(argv[index + 1], options.thresholds.p95Ms);
index += 1;
} else if (arg.startsWith('--max-p95-ms=')) {
options.thresholds.p95Ms = numberArg(arg.slice('--max-p95-ms='.length), options.thresholds.p95Ms);
} else if (arg === '--max-p99-ms') {
options.thresholds.p99Ms = numberArg(argv[index + 1], options.thresholds.p99Ms);
index += 1;
} else if (arg.startsWith('--max-p99-ms=')) {
options.thresholds.p99Ms = numberArg(arg.slice('--max-p99-ms='.length), options.thresholds.p99Ms);
} else if (arg === '--max-error-rate') {
options.thresholds.errorRate = numberArg(argv[index + 1], options.thresholds.errorRate);
index += 1;
} else if (arg.startsWith('--max-error-rate=')) {
options.thresholds.errorRate = numberArg(arg.slice('--max-error-rate='.length), options.thresholds.errorRate);
} else if (arg === '--min-concurrency') {
options.thresholds.concurrency = numberArg(argv[index + 1], options.thresholds.concurrency);
index += 1;
} else if (arg.startsWith('--min-concurrency=')) {
options.thresholds.concurrency = numberArg(arg.slice('--min-concurrency='.length), options.thresholds.concurrency);
} else if (arg === '--min-duration-seconds') {
options.thresholds.durationSeconds = numberArg(argv[index + 1], options.thresholds.durationSeconds);
index += 1;
} else if (arg.startsWith('--min-duration-seconds=')) {
options.thresholds.durationSeconds = numberArg(arg.slice('--min-duration-seconds='.length), options.thresholds.durationSeconds);
} else if (!arg.startsWith('-') && !options.input) {
options.input = arg;
}
}
return options;
}
function numberArg(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function numberValue(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function booleanValue(value) {
return value === true || value === 'true';
}
function summarizeBenchmark(report) {
const summary = report.summary || {};
const latencyOk = summary.latencyOk || {};
const config = report.config || {};
return {
errors: numberValue(summary.errors),
errorRate: numberValue(summary.errorRate),
p95Ms: numberValue(latencyOk.p95Ms),
p99Ms: numberValue(latencyOk.p99Ms),
concurrency: numberValue(config.concurrency),
durationSeconds: numberValue(config.durationSeconds),
includeWrites: booleanValue(config.includeWrites),
requests: numberValue(summary.requests),
ok: numberValue(summary.ok),
throughputRps: numberValue(summary.throughputRps),
apiBase: report.apiBase || '',
startedAt: report.startedAt || '',
finishedAt: report.finishedAt || '',
};
}
function evaluateSummary(summary, thresholds = defaultThresholds) {
const failures = [];
if (summary.errors !== thresholds.errors) failures.push(`errors expected ${thresholds.errors} but got ${summary.errors}`);
if (summary.errorRate > thresholds.errorRate) failures.push(`errorRate expected <= ${thresholds.errorRate} but got ${summary.errorRate}`);
if (summary.p95Ms > thresholds.p95Ms) failures.push(`p95Ms expected <= ${thresholds.p95Ms} but got ${summary.p95Ms}`);
if (summary.p99Ms > thresholds.p99Ms) failures.push(`p99Ms expected <= ${thresholds.p99Ms} but got ${summary.p99Ms}`);
if (summary.concurrency < thresholds.concurrency) failures.push(`concurrency expected >= ${thresholds.concurrency} but got ${summary.concurrency}`);
if (summary.durationSeconds < thresholds.durationSeconds) failures.push(`durationSeconds expected >= ${thresholds.durationSeconds} but got ${summary.durationSeconds}`);
if (summary.includeWrites !== false) failures.push('includeWrites expected false');
return {
status: failures.length ? 'fail' : 'pass',
failures,
summary,
thresholds,
};
}
function launchGateCheck(evaluation) {
return {
id: 'performance.api-real-data-read',
status: evaluation.status,
command: 'npm run perf:api:local',
completedAt: new Date().toISOString(),
artifact: '',
summary: {
errors: evaluation.summary.errors,
errorRate: evaluation.summary.errorRate,
p95Ms: evaluation.summary.p95Ms,
p99Ms: evaluation.summary.p99Ms,
concurrency: evaluation.summary.concurrency,
durationSeconds: evaluation.summary.durationSeconds,
includeWrites: evaluation.summary.includeWrites,
},
};
}
async function main() {
const options = parseArgs(process.argv);
if (!options.input) {
console.error('Usage: node scripts/performance-summary.js --input docs/refactor/performance-reports/api-benchmark-xxx.json [--json]');
process.exitCode = 1;
return;
}
const inputPath = path.resolve(process.cwd(), options.input);
const report = readJson(inputPath);
const summary = summarizeBenchmark(report);
const evaluation = evaluateSummary(summary, options.thresholds);
const payload = {
input: inputPath,
evaluation,
launchGateCheck: {
...launchGateCheck(evaluation),
artifact: options.input,
},
};
if (options.output) {
const outputPath = path.resolve(process.cwd(), options.output);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
} else if (!options.quiet) {
console.log(`[${evaluation.status.toUpperCase()}] ${path.basename(inputPath)} p95=${summary.p95Ms}ms p99=${summary.p99Ms}ms errors=${summary.errors} errorRate=${summary.errorRate} concurrency=${summary.concurrency} duration=${summary.durationSeconds}s`);
for (const failure of evaluation.failures) console.log(`- ${failure}`);
}
if (evaluation.status !== 'pass') process.exitCode = 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
export { defaultThresholds, evaluateSummary, launchGateCheck, summarizeBenchmark };