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 [--duration 15] [--concurrency 20]'); console.log('Optional: --method POST --header "Authorization: Bearer " --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, };