422 أسطر
11 KiB
JavaScript
422 أسطر
11 KiB
JavaScript
const { readFileSync } = require('fs');
|
|
const { performance } = require('perf_hooks');
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
url: 'http://127.0.0.1:4000/api/v1/health',
|
|
method: 'GET',
|
|
duration: 15,
|
|
concurrency: 20,
|
|
timeout: 5000,
|
|
warmup: 5,
|
|
headers: {},
|
|
body: undefined,
|
|
gates: {
|
|
minSuccessRate: undefined,
|
|
minRequestsPerSecond: undefined,
|
|
maxP95Ms: undefined,
|
|
maxP99Ms: undefined,
|
|
},
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const next = argv[index + 1];
|
|
|
|
if (arg === '--url' && next) {
|
|
options.url = next;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--method' && next) {
|
|
options.method = next.toUpperCase();
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--duration' && next) {
|
|
options.duration = Number(next);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--concurrency' && next) {
|
|
options.concurrency = Number(next);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--timeout' && next) {
|
|
options.timeout = Number(next);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--warmup' && next) {
|
|
options.warmup = Number(next);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--min-success-rate') {
|
|
assertOptionValue(arg, next);
|
|
options.gates.minSuccessRate = Number(next);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--min-rps') {
|
|
assertOptionValue(arg, next);
|
|
options.gates.minRequestsPerSecond = Number(next);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--max-p95') {
|
|
assertOptionValue(arg, next);
|
|
options.gates.maxP95Ms = Number(next);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--max-p99') {
|
|
assertOptionValue(arg, next);
|
|
options.gates.maxP99Ms = Number(next);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--header' && next) {
|
|
const separatorIndex = next.indexOf(':');
|
|
if (separatorIndex === -1) {
|
|
throw new Error(`Invalid header format: ${next}`);
|
|
}
|
|
|
|
const key = next.slice(0, separatorIndex).trim();
|
|
const value = next.slice(separatorIndex + 1).trim();
|
|
options.headers[key] = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--body' && next) {
|
|
options.body = next;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--body-file' && next) {
|
|
options.body = readFileSync(next, 'utf8');
|
|
index += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!Number.isFinite(options.duration) || options.duration <= 0) {
|
|
throw new Error('duration must be a positive number of seconds');
|
|
}
|
|
|
|
if (!Number.isInteger(options.concurrency) || options.concurrency <= 0) {
|
|
throw new Error('concurrency must be a positive integer');
|
|
}
|
|
|
|
if (!Number.isFinite(options.timeout) || options.timeout <= 0) {
|
|
throw new Error('timeout must be a positive number of milliseconds');
|
|
}
|
|
|
|
if (!Number.isInteger(options.warmup) || options.warmup < 0) {
|
|
throw new Error('warmup must be zero or a positive integer');
|
|
}
|
|
|
|
validateGate(options.gates.minSuccessRate, 'min-success-rate', {
|
|
min: 0,
|
|
max: 100,
|
|
});
|
|
validateGate(options.gates.minRequestsPerSecond, 'min-rps', { min: 0 });
|
|
validateGate(options.gates.maxP95Ms, 'max-p95', { min: 0 });
|
|
validateGate(options.gates.maxP99Ms, 'max-p99', { min: 0 });
|
|
|
|
if (options.body && !options.headers['Content-Type']) {
|
|
options.headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function assertOptionValue(option, value) {
|
|
if (value === undefined) {
|
|
throw new Error(`${option} requires a value`);
|
|
}
|
|
}
|
|
|
|
function validateGate(value, name, limits) {
|
|
if (value === undefined) {
|
|
return;
|
|
}
|
|
|
|
const aboveMaximum = limits.max !== undefined && value > limits.max;
|
|
if (!Number.isFinite(value) || value < limits.min || aboveMaximum) {
|
|
const range =
|
|
limits.max === undefined
|
|
? `at least ${limits.min}`
|
|
: `between ${limits.min} and ${limits.max}`;
|
|
throw new Error(`${name} must be a finite number ${range}`);
|
|
}
|
|
}
|
|
|
|
function percentile(sortedValues, p) {
|
|
if (!sortedValues.length) {
|
|
return 0;
|
|
}
|
|
|
|
const rank = Math.ceil((p / 100) * sortedValues.length) - 1;
|
|
const index = Math.min(sortedValues.length - 1, Math.max(0, rank));
|
|
return sortedValues[index];
|
|
}
|
|
|
|
function average(values) {
|
|
if (!values.length) {
|
|
return 0;
|
|
}
|
|
|
|
const total = values.reduce((sum, value) => sum + value, 0);
|
|
return total / values.length;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log('Usage: node scripts/load-test.js --url <url> [--duration 15] [--concurrency 20]');
|
|
console.log('Optional: --method POST --header "Authorization: Bearer <token>" --body "{\"key\":\"value\"}"');
|
|
console.log(
|
|
'Performance gates: --min-success-rate 99.9 --min-rps 100 --max-p95 250 --max-p99 500',
|
|
);
|
|
}
|
|
|
|
async function warmup(options) {
|
|
if (options.warmup === 0) {
|
|
return;
|
|
}
|
|
|
|
for (let index = 0; index < options.warmup; index += 1) {
|
|
const response = await fetch(options.url, {
|
|
method: options.method,
|
|
headers: options.headers,
|
|
body: options.body,
|
|
});
|
|
|
|
await response.arrayBuffer();
|
|
}
|
|
}
|
|
|
|
async function runWorker(options, results, endAt) {
|
|
while (Date.now() < endAt) {
|
|
const startedAt = performance.now();
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
|
|
|
|
try {
|
|
const response = await fetch(options.url, {
|
|
method: options.method,
|
|
headers: options.headers,
|
|
body: options.body,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
const payload = await response.arrayBuffer();
|
|
const durationMs = performance.now() - startedAt;
|
|
|
|
results.latencies.push(durationMs);
|
|
results.totalRequests += 1;
|
|
results.totalBytes += payload.byteLength;
|
|
|
|
const statusKey = String(response.status);
|
|
results.statusCounts[statusKey] = (results.statusCounts[statusKey] ?? 0) + 1;
|
|
|
|
if (response.ok) {
|
|
results.successCount += 1;
|
|
} else {
|
|
results.non2xxCount += 1;
|
|
}
|
|
} catch (error) {
|
|
const durationMs = performance.now() - startedAt;
|
|
results.latencies.push(durationMs);
|
|
results.totalRequests += 1;
|
|
|
|
if (error && typeof error === 'object' && error.name === 'AbortError') {
|
|
results.timeoutCount += 1;
|
|
} else {
|
|
results.networkErrorCount += 1;
|
|
}
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildSummary(options, results, totalDurationMs) {
|
|
const latencies = [...results.latencies].sort((left, right) => left - right);
|
|
const requestsPerSecond = results.totalRequests / (totalDurationMs / 1000);
|
|
const successRate = results.totalRequests === 0 ? 0 : (results.successCount / results.totalRequests) * 100;
|
|
|
|
return {
|
|
target: options.url,
|
|
method: options.method,
|
|
durationSeconds: Number((totalDurationMs / 1000).toFixed(2)),
|
|
concurrency: options.concurrency,
|
|
totalRequests: results.totalRequests,
|
|
successCount: results.successCount,
|
|
non2xxCount: results.non2xxCount,
|
|
timeoutCount: results.timeoutCount,
|
|
networkErrorCount: results.networkErrorCount,
|
|
requestsPerSecond: Number(requestsPerSecond.toFixed(2)),
|
|
successRate: Number(successRate.toFixed(2)),
|
|
transferredBytes: results.totalBytes,
|
|
latencyMs: {
|
|
min: Number((latencies[0] ?? 0).toFixed(2)),
|
|
avg: Number(average(latencies).toFixed(2)),
|
|
p50: Number(percentile(latencies, 50).toFixed(2)),
|
|
p90: Number(percentile(latencies, 90).toFixed(2)),
|
|
p95: Number(percentile(latencies, 95).toFixed(2)),
|
|
p99: Number(percentile(latencies, 99).toFixed(2)),
|
|
max: Number((latencies[latencies.length - 1] ?? 0).toFixed(2)),
|
|
},
|
|
statusCounts: results.statusCounts,
|
|
};
|
|
}
|
|
|
|
function evaluatePerformanceGates(summary, gates) {
|
|
const definitions = [
|
|
{
|
|
configuredValue: gates.minSuccessRate,
|
|
metric: 'successRate',
|
|
label: 'Success rate',
|
|
actual: summary.successRate,
|
|
operator: '>=',
|
|
unit: '%',
|
|
passes: (actual, threshold) => actual >= threshold,
|
|
},
|
|
{
|
|
configuredValue: gates.minRequestsPerSecond,
|
|
metric: 'requestsPerSecond',
|
|
label: 'Requests/second',
|
|
actual: summary.requestsPerSecond,
|
|
operator: '>=',
|
|
unit: ' req/s',
|
|
passes: (actual, threshold) => actual >= threshold,
|
|
},
|
|
{
|
|
configuredValue: gates.maxP95Ms,
|
|
metric: 'latencyMs.p95',
|
|
label: 'Latency p95',
|
|
actual: summary.latencyMs.p95,
|
|
operator: '<=',
|
|
unit: ' ms',
|
|
passes: (actual, threshold) => actual <= threshold,
|
|
},
|
|
{
|
|
configuredValue: gates.maxP99Ms,
|
|
metric: 'latencyMs.p99',
|
|
label: 'Latency p99',
|
|
actual: summary.latencyMs.p99,
|
|
operator: '<=',
|
|
unit: ' ms',
|
|
passes: (actual, threshold) => actual <= threshold,
|
|
},
|
|
];
|
|
|
|
const checks = definitions
|
|
.filter((definition) => definition.configuredValue !== undefined)
|
|
.map((definition) => ({
|
|
metric: definition.metric,
|
|
label: definition.label,
|
|
actual: definition.actual,
|
|
operator: definition.operator,
|
|
threshold: definition.configuredValue,
|
|
unit: definition.unit,
|
|
passed: definition.passes(definition.actual, definition.configuredValue),
|
|
}));
|
|
|
|
return {
|
|
configured: checks.length > 0,
|
|
passed: checks.every((check) => check.passed),
|
|
checks,
|
|
};
|
|
}
|
|
|
|
function printGateReport(gateResult) {
|
|
console.log('');
|
|
|
|
if (!gateResult.configured) {
|
|
console.log('Performance gates: not configured');
|
|
return;
|
|
}
|
|
|
|
const passedChecks = gateResult.checks.filter((check) => check.passed).length;
|
|
console.log(
|
|
`Performance gates: ${gateResult.passed ? 'PASSED' : 'FAILED'} (${passedChecks}/${gateResult.checks.length})`,
|
|
);
|
|
|
|
for (const check of gateResult.checks) {
|
|
const status = check.passed ? 'PASS' : 'FAIL';
|
|
console.log(
|
|
`[${status}] ${check.label}: ${check.actual}${check.unit} ${check.operator} ${check.threshold}${check.unit}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
if (process.argv.includes('--help')) {
|
|
printUsage();
|
|
return;
|
|
}
|
|
|
|
const options = parseArgs(process.argv.slice(2));
|
|
|
|
console.log(`Warmup: ${options.warmup} request(s) to ${options.url}`);
|
|
await warmup(options);
|
|
|
|
const results = {
|
|
latencies: [],
|
|
totalRequests: 0,
|
|
successCount: 0,
|
|
non2xxCount: 0,
|
|
timeoutCount: 0,
|
|
networkErrorCount: 0,
|
|
totalBytes: 0,
|
|
statusCounts: {},
|
|
};
|
|
|
|
const startedAt = performance.now();
|
|
const endAt = Date.now() + options.duration * 1000;
|
|
|
|
await Promise.all(
|
|
Array.from({ length: options.concurrency }, () => runWorker(options, results, endAt)),
|
|
);
|
|
|
|
const totalDurationMs = performance.now() - startedAt;
|
|
const summary = buildSummary(options, results, totalDurationMs);
|
|
const performanceGates = evaluatePerformanceGates(summary, options.gates);
|
|
summary.performanceGates = performanceGates;
|
|
|
|
console.log('');
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
printGateReport(performanceGates);
|
|
|
|
if (!performanceGates.passed) {
|
|
process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
buildSummary,
|
|
evaluatePerformanceGates,
|
|
parseArgs,
|
|
};
|