express-api-stress-tester 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Per-endpoint metrics collector with percentile support.
3
+ */
4
+ const RESERVOIR_SIZE = 5_000;
5
+
6
+ export class ApiMetrics {
7
+ constructor() {
8
+ this.totalRequests = 0;
9
+ this.successCount = 0;
10
+ this.errorCount = 0;
11
+ this.totalResponseTime = 0;
12
+ this.minLatency = Infinity;
13
+ this.maxLatency = -Infinity;
14
+ this.responseTimes = [];
15
+ this._sampleCount = 0;
16
+ }
17
+
18
+ record(responseTimeMs, isError) {
19
+ this.totalRequests++;
20
+ this.totalResponseTime += responseTimeMs;
21
+
22
+ if (responseTimeMs < this.minLatency) this.minLatency = responseTimeMs;
23
+ if (responseTimeMs > this.maxLatency) this.maxLatency = responseTimeMs;
24
+
25
+ if (isError) {
26
+ this.errorCount++;
27
+ } else {
28
+ this.successCount++;
29
+ }
30
+
31
+ this._sampleCount++;
32
+ if (this.responseTimes.length < RESERVOIR_SIZE) {
33
+ this.responseTimes.push(responseTimeMs);
34
+ } else {
35
+ const j = Math.floor(Math.random() * this._sampleCount);
36
+ if (j < RESERVOIR_SIZE) {
37
+ this.responseTimes[j] = responseTimeMs;
38
+ }
39
+ }
40
+ }
41
+
42
+ merge(partial) {
43
+ this.totalRequests += partial.totalRequests || 0;
44
+ this.successCount += partial.successCount || 0;
45
+ this.errorCount += partial.errorCount || 0;
46
+ this.totalResponseTime += partial.totalResponseTime || 0;
47
+
48
+ if (partial.minLatency !== undefined && partial.minLatency < this.minLatency) {
49
+ this.minLatency = partial.minLatency;
50
+ }
51
+ if (partial.maxLatency !== undefined && partial.maxLatency > this.maxLatency) {
52
+ this.maxLatency = partial.maxLatency;
53
+ }
54
+
55
+ if (partial.responseTimes) {
56
+ this.mergeResponseTimes(partial.responseTimes);
57
+ }
58
+ }
59
+
60
+ mergeResponseTimes(times) {
61
+ if (!Array.isArray(times) || times.length === 0) return;
62
+ for (const t of times) {
63
+ this._sampleCount++;
64
+ if (this.responseTimes.length < RESERVOIR_SIZE) {
65
+ this.responseTimes.push(t);
66
+ } else {
67
+ const j = Math.floor(Math.random() * this._sampleCount);
68
+ if (j < RESERVOIR_SIZE) {
69
+ this.responseTimes[j] = t;
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ _percentile(sorted, p) {
76
+ if (sorted.length === 0) return 0;
77
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
78
+ return sorted[Math.max(0, idx)];
79
+ }
80
+
81
+ getSummary(elapsedSeconds = 1) {
82
+ const elapsed = elapsedSeconds || 1;
83
+ const rps = (this.totalRequests / elapsed).toFixed(0);
84
+ const avgResponse =
85
+ this.totalRequests > 0
86
+ ? (this.totalResponseTime / this.totalRequests).toFixed(0)
87
+ : 0;
88
+ const errorRate =
89
+ this.totalRequests > 0
90
+ ? ((this.errorCount / this.totalRequests) * 100).toFixed(1)
91
+ : '0.0';
92
+ const successRate =
93
+ this.totalRequests > 0
94
+ ? ((this.successCount / this.totalRequests) * 100).toFixed(1)
95
+ : '0.0';
96
+
97
+ const sorted = [...this.responseTimes].sort((a, b) => a - b);
98
+ const p95 = this._percentile(sorted, 95);
99
+ const p99 = this._percentile(sorted, 99);
100
+
101
+ const minLat = this.minLatency === Infinity ? 0 : this.minLatency;
102
+ const maxLat = this.maxLatency === -Infinity ? 0 : this.maxLatency;
103
+
104
+ return {
105
+ totalRequests: this.totalRequests,
106
+ requestsPerSec: Number(rps),
107
+ avgResponseTime: Number(avgResponse),
108
+ p95,
109
+ p99,
110
+ minLatency: minLat,
111
+ maxLatency: maxLat,
112
+ errorRate: parseFloat(errorRate),
113
+ successRate: parseFloat(successRate),
114
+ };
115
+ }
116
+ }
@@ -3,6 +3,7 @@
3
3
  * Backward-compatible with v1 API, adds reservoir sampling for latency percentiles.
4
4
  */
5
5
  import { cpus } from 'node:os';
6
+ import { ApiMetrics } from './apiMetrics.js';
6
7
 
7
8
  const RESERVOIR_SIZE = 10_000;
8
9
 
@@ -19,6 +20,7 @@ export class MetricsCollector {
19
20
  // Reservoir sampling for percentile calculation
20
21
  this.responseTimes = [];
21
22
  this._sampleCount = 0;
23
+ this.perEndpoint = new Map();
22
24
  }
23
25
 
24
26
  start() {
@@ -70,6 +72,12 @@ export class MetricsCollector {
70
72
  if (partial.responseTimes) {
71
73
  this.mergeResponseTimes(partial.responseTimes);
72
74
  }
75
+
76
+ if (partial.perEndpoint) {
77
+ for (const [endpoint, metrics] of Object.entries(partial.perEndpoint)) {
78
+ this._getEndpointMetrics(endpoint).merge(metrics);
79
+ }
80
+ }
73
81
  }
74
82
 
75
83
  mergeResponseTimes(times) {
@@ -87,6 +95,13 @@ export class MetricsCollector {
87
95
  }
88
96
  }
89
97
 
98
+ _getEndpointMetrics(endpoint) {
99
+ if (!this.perEndpoint.has(endpoint)) {
100
+ this.perEndpoint.set(endpoint, new ApiMetrics());
101
+ }
102
+ return this.perEndpoint.get(endpoint);
103
+ }
104
+
90
105
  static getResourceUsage() {
91
106
  const mem = process.memoryUsage();
92
107
  const cpuArray = cpus();
@@ -109,7 +124,8 @@ export class MetricsCollector {
109
124
  }
110
125
 
111
126
  getSummary(thresholds) {
112
- const elapsed = (this.endTime - this.startTime) / 1000 || 1;
127
+ const endTime = this.endTime || Date.now();
128
+ const elapsed = (endTime - this.startTime) / 1000 || 1;
113
129
  const rps = (this.totalRequests / elapsed).toFixed(0);
114
130
  const avgResponse =
115
131
  this.totalRequests > 0
@@ -169,6 +185,15 @@ export class MetricsCollector {
169
185
  memoryMB,
170
186
  result: passed ? 'PASSED' : 'FAILED',
171
187
  elapsedSeconds: elapsed.toFixed(1),
188
+ perEndpoint: this._buildEndpointSummaries(elapsed),
172
189
  };
173
190
  }
191
+
192
+ _buildEndpointSummaries(elapsed) {
193
+ const summaries = {};
194
+ for (const [endpoint, metrics] of this.perEndpoint.entries()) {
195
+ summaries[endpoint] = metrics.getSummary(elapsed);
196
+ }
197
+ return summaries;
198
+ }
174
199
  }
@@ -39,6 +39,30 @@ export function generateHtmlReport(config, summary) {
39
39
  ...(config.totalRequests ? [['Total Requests', config.totalRequests]] : []),
40
40
  ];
41
41
 
42
+ const perEndpointRows = Object.entries(summary.perEndpoint || {}).map(
43
+ ([endpoint, metrics]) => [
44
+ endpoint,
45
+ metrics.requestsPerSec ?? 0,
46
+ `${metrics.avgResponseTime ?? 0} ms`,
47
+ `${metrics.p95 ?? 0} ms`,
48
+ `${metrics.errorRate ?? 0}%`,
49
+ ],
50
+ );
51
+
52
+ const successRate = summary.successRate || 0;
53
+ const errorRate = summary.errorRate || 0;
54
+ const maxLatency = Math.max(summary.p99 || 0, summary.maxLatency || 0, 1);
55
+ const maxRps = Math.max(
56
+ summary.requestsPerSec || 0,
57
+ ...perEndpointRows.map(([, rps]) => Number(rps) || 0),
58
+ 1,
59
+ );
60
+ const latencyBars = [
61
+ { label: 'Avg', value: summary.avgResponseTime || 0 },
62
+ { label: 'P95', value: summary.p95 || 0 },
63
+ { label: 'P99', value: summary.p99 || 0 },
64
+ ];
65
+
42
66
  return `<!DOCTYPE html>
43
67
  <html lang="en">
44
68
  <head>
@@ -58,6 +82,14 @@ export function generateHtmlReport(config, summary) {
58
82
  th { font-weight: 600; color: #64748b; width: 40%; }
59
83
  td { color: #1e293b; font-variant-numeric: tabular-nums; }
60
84
  tr:last-child th, tr:last-child td { border-bottom: none; }
85
+ .chart { margin-top: 1rem; }
86
+ .chart h3 { font-size: 1rem; color: #334155; margin: 0.75rem 0; }
87
+ .bar { display: flex; align-items: center; margin-bottom: 0.5rem; gap: 0.5rem; }
88
+ .bar-label { width: 60px; font-size: 0.85rem; color: #64748b; }
89
+ .bar-track { flex: 1; height: 10px; background: #e2e8f0; border-radius: 9999px; overflow: hidden; }
90
+ .bar-fill { height: 100%; background: #38bdf8; }
91
+ .bar-fill.error { background: #f87171; }
92
+ .bar-fill.success { background: #22c55e; }
61
93
  .timestamp { color: #94a3b8; font-size: 0.8rem; margin-top: 1rem; text-align: center; }
62
94
  </style>
63
95
  </head>
@@ -78,8 +110,44 @@ ${configRows.map(([k, v]) => ` <tr><th>${esc(k)}</th><td>${esc(v)}</td></tr
78
110
  <table>
79
111
  ${metricRows.map(([k, v]) => ` <tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join('\n')}
80
112
  </table>
113
+ <div class="chart">
114
+ <h3>Latency Graph</h3>
115
+ ${latencyBars.map((bar) => `
116
+ <div class="bar">
117
+ <div class="bar-label">${esc(bar.label)}</div>
118
+ <div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, (bar.value / maxLatency) * 100)}%"></div></div>
119
+ </div>`).join('')}
120
+ </div>
121
+ <div class="chart">
122
+ <h3>Request Rate</h3>
123
+ <div class="bar">
124
+ <div class="bar-label">RPS</div>
125
+ <div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, (summary.requestsPerSec / maxRps) * 100)}%"></div></div>
126
+ </div>
127
+ </div>
128
+ <div class="chart">
129
+ <h3>Error Distribution</h3>
130
+ <div class="bar">
131
+ <div class="bar-label">Success</div>
132
+ <div class="bar-track"><div class="bar-fill success" style="width:${Math.min(100, successRate)}%"></div></div>
133
+ </div>
134
+ <div class="bar">
135
+ <div class="bar-label">Error</div>
136
+ <div class="bar-track"><div class="bar-fill error" style="width:${Math.min(100, errorRate)}%"></div></div>
137
+ </div>
138
+ </div>
81
139
  </div>
82
140
 
141
+ ${perEndpointRows.length > 0 ? `
142
+ <div class="section">
143
+ <h2>Per-Endpoint Metrics</h2>
144
+ <table>
145
+ <tr><th>Endpoint</th><th>RPS</th><th>Avg Latency</th><th>P95</th><th>Error Rate</th></tr>
146
+ ${perEndpointRows.map(([endpoint, rps, avg, p95, err]) => `
147
+ <tr><td>${esc(endpoint)}</td><td>${esc(rps)}</td><td>${esc(avg)}</td><td>${esc(p95)}</td><td>${esc(err)}</td></tr>`).join('\n')}
148
+ </table>
149
+ </div>` : ''}
150
+
83
151
  <p class="timestamp">Generated at ${new Date().toISOString()}</p>
84
152
  </div>
85
153
  </body>
@@ -47,11 +47,52 @@ function buildTxtReport(config, summary) {
47
47
  `Memory Usage: ${summary.memoryMB}MB`,
48
48
  `Result: ${summary.result}`,
49
49
  divider,
50
+ ...(buildEndpointLines(summary.perEndpoint) || []),
50
51
  '',
51
52
  ];
52
53
  return lines.join('\n');
53
54
  }
54
55
 
56
+ function buildEndpointLines(perEndpoint) {
57
+ if (!perEndpoint || Object.keys(perEndpoint).length === 0) {
58
+ return [];
59
+ }
60
+
61
+ const lines = [];
62
+ lines.push('Per-Endpoint Metrics:');
63
+
64
+ const entries = Object.entries(perEndpoint).sort(
65
+ (a, b) => (b[1].requestsPerSec || 0) - (a[1].requestsPerSec || 0),
66
+ );
67
+ const maxEndpointLength = Math.min(
68
+ 50,
69
+ Math.max(8, ...entries.map(([endpoint]) => endpoint.length)),
70
+ );
71
+ lines.push(
72
+ `${'Endpoint'.padEnd(maxEndpointLength)} RPS Avg(ms) P95(ms) Errors(%)`,
73
+ );
74
+ lines.push('-'.repeat(maxEndpointLength + 38));
75
+ for (const [endpoint, metrics] of entries) {
76
+ const displayEndpoint =
77
+ endpoint.length > maxEndpointLength
78
+ ? `${endpoint.slice(0, maxEndpointLength - 3)}...`
79
+ : endpoint;
80
+ const rps = String(metrics.requestsPerSec ?? 0).padStart(5);
81
+ const avg = String(metrics.avgResponseTime ?? 0).padStart(7);
82
+ const p95 = String(metrics.p95 ?? 0).padStart(7);
83
+ const errValue =
84
+ typeof metrics.errorRate === 'string'
85
+ ? parseFloat(metrics.errorRate)
86
+ : (metrics.errorRate ?? 0);
87
+ const err = `${errValue.toFixed(1)}%`.padStart(8);
88
+ lines.push(
89
+ `${displayEndpoint.padEnd(maxEndpointLength)} ${rps} ${avg} ${p95} ${err}`,
90
+ );
91
+ }
92
+ lines.push('');
93
+ return lines;
94
+ }
95
+
55
96
  export class ReportWriter {
56
97
  constructor(config, summary) {
57
98
  this.config = config;