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.
@@ -13,9 +13,11 @@ import { MetricsCollector } from '../metrics/metricsCollector.js';
13
13
  import { ReportWriter, log } from '../reporting/reportWriter.js';
14
14
  import { WorkerManager } from './workerManager.js';
15
15
  import { Scheduler } from './scheduler.js';
16
+ import { CliDashboard } from '../dashboard/cliDashboard.js';
16
17
 
17
18
  const BATCH_SIZE = 200;
18
19
  const DEFAULT_NUM_WORKERS = Math.max(1, cpus().length - 1);
20
+ const DEFAULT_ADAPTIVE_STEP_PERCENT = 0.05;
19
21
 
20
22
  /**
21
23
  * Run a stress test.
@@ -54,7 +56,8 @@ export async function runStressTest(config, options = {}) {
54
56
  }
55
57
  }
56
58
 
57
- const concurrency = config.concurrency || 1;
59
+ const maxUsers = config.concurrency || config.maxUsers || 1;
60
+ const concurrency = maxUsers;
58
61
  const duration = config.duration || 10;
59
62
 
60
63
  const target = config.url || (hasRoutes ? `${config.routes.length} routes` : 'scenarios');
@@ -63,6 +66,18 @@ export async function runStressTest(config, options = {}) {
63
66
 
64
67
  // ── Setup ─────────────────────────────────────────────────────────
65
68
  const numWorkers = Math.min(DEFAULT_NUM_WORKERS, concurrency);
69
+ const startConcurrency = config.startConcurrency || 1;
70
+ const rampUp = config.rampUp || 0;
71
+ const rampDown = config.rampDown || 0;
72
+ const targetRPS = config.targetRPS;
73
+ const burst = config.burst || null;
74
+ const adaptiveStep = Math.max(
75
+ 1,
76
+ Math.floor((config.adaptiveStep || maxUsers * DEFAULT_ADAPTIVE_STEP_PERCENT)),
77
+ );
78
+ const adaptiveIntervalMs = config.adaptiveIntervalMs || 1000;
79
+ let currentConcurrency = Math.min(maxUsers, startConcurrency);
80
+ let lastAdjustAt = 0;
66
81
  const scheduler = new Scheduler(config);
67
82
  const metrics = new MetricsCollector();
68
83
 
@@ -75,33 +90,78 @@ export async function runStressTest(config, options = {}) {
75
90
 
76
91
  await manager.start();
77
92
 
78
- // ── Live dashboard (placeholder) ──────────────────────────────────
93
+ // ── Live dashboard ────────────────────────────────────────────────
79
94
  let dashboardInterval = null;
95
+ let dashboard = null;
80
96
  if (options.dashboard) {
97
+ dashboard = new CliDashboard();
98
+ dashboard.start();
81
99
  dashboardInterval = setInterval(() => {
82
100
  const snap = metrics.getSummary();
83
- process.stdout.write(
84
- `\r RPS: ${snap.requestsPerSec} | Avg: ${snap.avgResponseTime}ms | Errors: ${snap.errorRate}%`,
85
- );
101
+ dashboard.update({
102
+ activeUsers: currentConcurrency,
103
+ requestsPerSec: snap.requestsPerSec,
104
+ avgLatency: snap.avgResponseTime,
105
+ errorRate: snap.errorRate,
106
+ cpuPercent: snap.cpuPercent,
107
+ memoryMB: snap.memoryMB,
108
+ totalRequests: snap.totalRequests,
109
+ p95: snap.p95,
110
+ p99: snap.p99,
111
+ perEndpoint: snap.perEndpoint,
112
+ });
86
113
  }, 1000);
87
114
  }
88
115
 
89
116
  // ── Dispatch loop ─────────────────────────────────────────────────
90
117
  metrics.start();
91
118
  const endAt = Date.now() + duration * 1000;
92
- const batchLimit = Math.min(BATCH_SIZE, Math.ceil(concurrency / numWorkers));
93
119
 
94
120
  while (Date.now() < endAt) {
95
121
  const batchPromises = [];
96
122
 
123
+ const now = Date.now();
124
+ const elapsedSeconds = (now - metrics.startTime) / 1000;
125
+ const { current, maxAllowed } = calculateConcurrency({
126
+ elapsedSeconds,
127
+ duration,
128
+ startConcurrency,
129
+ maxUsers,
130
+ rampUp,
131
+ rampDown,
132
+ burst,
133
+ });
134
+ currentConcurrency = current;
135
+
136
+ if (targetRPS && now - lastAdjustAt >= adaptiveIntervalMs) {
137
+ const elapsed = (Date.now() - metrics.startTime) / 1000 || 1;
138
+ const currentRps = Math.floor(metrics.totalRequests / elapsed);
139
+ if (currentRps < targetRPS * 0.98) {
140
+ currentConcurrency = Math.min(maxAllowed, currentConcurrency + adaptiveStep);
141
+ } else if (currentRps > targetRPS * 1.02) {
142
+ currentConcurrency = Math.max(1, currentConcurrency - adaptiveStep);
143
+ }
144
+ lastAdjustAt = now;
145
+ }
146
+
147
+ const batchLimit = Math.min(BATCH_SIZE, Math.ceil(currentConcurrency / numWorkers));
148
+
97
149
  for (let w = 0; w < numWorkers; w++) {
98
150
  // Build route assignments for this batch
99
151
  const routes = [];
100
152
  const tasks = [];
101
153
  for (let j = 0; j < batchLimit; j++) {
102
- const route = scheduler.getNextRoute();
103
- routes.push(route);
104
- tasks.push(j);
154
+ if (hasScenarios) {
155
+ const scenario = scheduler.getNextScenario();
156
+ tasks.push({
157
+ steps: scenario.steps || [],
158
+ scenarioName: scenario.name,
159
+ });
160
+ } else {
161
+ const route = scheduler.getNextRoute();
162
+ routes.push(route);
163
+ tasks.push(j);
164
+ }
105
165
  }
106
166
 
107
167
  batchPromises.push(
@@ -116,7 +176,9 @@ export async function runStressTest(config, options = {}) {
116
176
 
117
177
  if (dashboardInterval) {
118
178
  clearInterval(dashboardInterval);
119
- process.stdout.write('\n');
179
+ }
180
+ if (dashboard) {
181
+ dashboard.stop();
120
182
  }
121
183
 
122
184
  // ── Teardown ──────────────────────────────────────────────────────
@@ -170,3 +232,46 @@ function applyThresholds(summary, thresholds) {
170
232
 
171
233
  return 'PASSED';
172
234
  }
235
+
236
+ function calculateConcurrency({
237
+ elapsedSeconds,
238
+ duration,
239
+ startConcurrency,
240
+ maxUsers,
241
+ rampUp,
242
+ rampDown,
243
+ burst,
244
+ }) {
245
+ let desired = maxUsers;
246
+ const isBurstConfig = burst && typeof burst === 'object';
247
+ const burstStart = isBurstConfig ? (burst.start || 0) : 0;
248
+ const burstDuration = isBurstConfig ? (burst.duration || 0) : 0;
249
+ const burstMultiplier = isBurstConfig ? (burst.multiplier || 1) : 1;
250
+ const burstMax = isBurstConfig
251
+ ? burst.maxUsers || Math.round(maxUsers * burstMultiplier)
252
+ : maxUsers;
253
+ const inBurst =
254
+ isBurstConfig &&
255
+ elapsedSeconds >= burstStart &&
256
+ elapsedSeconds <= burstStart + burstDuration;
257
+ const maxAllowed = inBurst ? burstMax : maxUsers;
258
+ if (rampUp && elapsedSeconds < rampUp) {
259
+ const progress = elapsedSeconds / rampUp;
260
+ desired = Math.max(
261
+ 1,
262
+ Math.round(startConcurrency + (maxUsers - startConcurrency) * progress),
263
+ );
264
+ }
265
+
266
+ if (rampDown && elapsedSeconds > duration - rampDown) {
267
+ const remaining = Math.max(0, duration - elapsedSeconds);
268
+ const progress = remaining / rampDown;
269
+ desired = Math.max(1, Math.round(startConcurrency + (maxUsers - startConcurrency) * progress));
270
+ }
271
+
272
+ if (inBurst) {
273
+ desired = Math.min(burstMax, Math.round(desired * burstMultiplier));
274
+ }
275
+
276
+ return { current: Math.min(maxAllowed, Math.max(1, desired)), maxAllowed };
277
+ }
@@ -18,6 +18,7 @@ export class Scheduler {
18
18
  this.scenarios = config.scenarios || [];
19
19
  this.weights = this._buildWeights();
20
20
  this.roundRobinIndex = 0;
21
+ this.scenarioIndex = 0;
21
22
  }
22
23
 
23
24
  // ── Route resolution ───────────────────────────────────────────────
@@ -155,4 +156,17 @@ export class Scheduler {
155
156
  if (this.scenarios.length === 0) return [];
156
157
  return this.scenarios[0].steps || [];
157
158
  }
159
+
160
+ /**
161
+ * Return the next scenario in round-robin order.
162
+ * @returns {object} scenario
163
+ */
164
+ getNextScenario() {
165
+ if (this.scenarios.length === 0) {
166
+ return { name: 'default', steps: [] };
167
+ }
168
+ const scenario = this.scenarios[this.scenarioIndex % this.scenarios.length];
169
+ this.scenarioIndex = (this.scenarioIndex + 1) % this.scenarios.length;
170
+ return scenario;
171
+ }
158
172
  }
@@ -11,7 +11,11 @@
11
11
  * worker → main : { type: 'result', metrics: { ... } }
12
12
  */
13
13
  import { parentPort, workerData } from 'node:worker_threads';
14
- import { request } from 'undici';
14
+ import { resolve } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { PluginManager } from '../plugins/pluginManager.js';
17
+ import { HttpEngine } from './httpEngine.js';
18
+ import { DatasetLoader } from '../payload/datasetLoader.js';
15
19
 
16
20
  // Dynamic payload generation (best-effort import; fall back to identity)
17
21
  let resolvePayload = (v) => v;
@@ -25,6 +29,42 @@ try {
25
29
  }
26
30
 
27
31
  const config = workerData || {};
32
+ const pluginManager = new PluginManager();
33
+ const engineCache = new Map();
34
+ let datasetLoader = null;
35
+ let datasetIndex = 0;
36
+
37
+ async function loadPlugins() {
38
+ const plugins = Array.isArray(config.plugins) ? config.plugins : [];
39
+ for (const entry of plugins) {
40
+ let moduleId = null;
41
+ try {
42
+ moduleId = typeof entry === 'string' ? entry : null;
43
+ if (!moduleId) continue;
44
+ const isPath = moduleId.startsWith('.') || moduleId.startsWith('/');
45
+ const pluginPath = resolve(process.cwd(), moduleId);
46
+ const resolved = isPath ? pathToFileURL(pluginPath).href : moduleId;
47
+ const mod = await import(resolved);
48
+ const pluginExport = mod.default || mod.plugin || mod.plugins;
49
+ if (Array.isArray(pluginExport)) {
50
+ for (const plugin of pluginExport) {
51
+ pluginManager.registerPlugin(plugin);
52
+ }
53
+ } else if (pluginExport) {
54
+ pluginManager.registerPlugin(pluginExport);
55
+ }
56
+ } catch (err) {
57
+ process.stderr.write(`[Worker] Failed to load plugin ${moduleId}: ${err.message}\n`);
58
+ }
59
+ }
60
+ }
61
+
62
+ await loadPlugins();
63
+
64
+ if (config.payloadFile) {
65
+ datasetLoader = new DatasetLoader(config.payloadFile);
66
+ await datasetLoader.load();
67
+ }
28
68
 
29
69
  // ── Reservoir sampling ─────────────────────────────────────────────
30
70
  const MAX_SAMPLE_SIZE = 1000;
@@ -49,29 +89,185 @@ let totalRequests = 0;
49
89
  let successCount = 0;
50
90
  let errorCount = 0;
51
91
  let totalResponseTime = 0;
92
+ let minLatency = Infinity;
93
+ let maxLatency = -Infinity;
52
94
  const statusCodes = {};
95
+ const perEndpoint = {};
53
96
 
54
97
  /**
55
98
  * Resolve the full URL for a route.
56
99
  */
57
100
  function resolveUrl(route) {
58
101
  const base = route.baseUrl || config.baseUrl || config.url || '';
59
- const path = route.path || '';
60
-
61
- // If path is already a full URL, use it directly
102
+ const path = route.path || route.url || '';
103
+ try {
104
+ if (path) {
105
+ return new URL(path, base).toString();
106
+ }
107
+ if (base) {
108
+ return new URL(base).toString();
109
+ }
110
+ } catch {
111
+ // ignore
112
+ }
62
113
  if (path.startsWith('http://') || path.startsWith('https://')) {
63
114
  return path;
64
115
  }
116
+ const cleanBase = base.replace(/\/+$/, '');
117
+ const cleanPath = path.startsWith('/') ? path : `/${path}`;
118
+ return cleanBase ? `${cleanBase}${cleanPath}` : path;
119
+ }
120
+
121
+ function getEngine(baseUrl) {
122
+ if (!engineCache.has(baseUrl)) {
123
+ engineCache.set(
124
+ baseUrl,
125
+ new HttpEngine({
126
+ baseUrl,
127
+ connections: config.connections,
128
+ pipelining: config.pipelining,
129
+ timeout: config.timeout,
130
+ headers: config.headers || {},
131
+ }),
132
+ );
133
+ }
134
+ return engineCache.get(baseUrl);
135
+ }
136
+
137
+ function resolveEndpointKey(method, url, route) {
138
+ const path = route?.path || route?.url;
139
+ if (path && !path.startsWith('http://') && !path.startsWith('https://')) {
140
+ const cleanPath = path.split('?')[0];
141
+ return `${method} ${cleanPath}`;
142
+ }
143
+ try {
144
+ const parsed = new URL(url);
145
+ return `${method} ${parsed.pathname}`;
146
+ } catch {
147
+ return `${method} ${url}`;
148
+ }
149
+ }
65
150
 
66
- // If base already contains a path component and route.path is relative
67
- if (base && path) {
68
- // Strip trailing slash from base, ensure leading slash on path
69
- const cleanBase = base.replace(/\/+$/, '');
70
- const cleanPath = path.startsWith('/') ? path : `/${path}`;
71
- return `${cleanBase}${cleanPath}`;
151
+ function getEndpointMetrics(endpoint) {
152
+ if (!perEndpoint[endpoint]) {
153
+ perEndpoint[endpoint] = {
154
+ totalRequests: 0,
155
+ successCount: 0,
156
+ errorCount: 0,
157
+ totalResponseTime: 0,
158
+ minLatency: Infinity,
159
+ maxLatency: -Infinity,
160
+ responseTimes: [],
161
+ sampleCount: 0,
162
+ };
72
163
  }
164
+ return perEndpoint[endpoint];
165
+ }
73
166
 
74
- return base || path;
167
+ function recordEndpoint(endpoint, elapsedMs, isError) {
168
+ const metrics = getEndpointMetrics(endpoint);
169
+ metrics.totalRequests++;
170
+ metrics.totalResponseTime += elapsedMs;
171
+ if (elapsedMs < metrics.minLatency) metrics.minLatency = elapsedMs;
172
+ if (elapsedMs > metrics.maxLatency) metrics.maxLatency = elapsedMs;
173
+ if (isError) {
174
+ metrics.errorCount++;
175
+ } else {
176
+ metrics.successCount++;
177
+ }
178
+ metrics.sampleCount++;
179
+ if (metrics.responseTimes.length < MAX_SAMPLE_SIZE) {
180
+ metrics.responseTimes.push(elapsedMs);
181
+ } else {
182
+ const idx = Math.floor(Math.random() * metrics.sampleCount);
183
+ if (idx < MAX_SAMPLE_SIZE) {
184
+ metrics.responseTimes[idx] = elapsedMs;
185
+ }
186
+ }
187
+ }
188
+
189
+ function recordRequestMetrics({ endpointKey, elapsedMs, isError, status }) {
190
+ totalRequests++;
191
+ totalResponseTime += elapsedMs;
192
+ reservoirSample(elapsedMs);
193
+ if (elapsedMs < minLatency) minLatency = elapsedMs;
194
+ if (elapsedMs > maxLatency) maxLatency = elapsedMs;
195
+
196
+ if (status) {
197
+ statusCodes[status] = (statusCodes[status] || 0) + 1;
198
+ }
199
+
200
+ if (isError) {
201
+ errorCount++;
202
+ } else {
203
+ successCount++;
204
+ }
205
+
206
+ recordEndpoint(endpointKey, elapsedMs, isError);
207
+ }
208
+
209
+ async function applyHeaderPlugins(headers) {
210
+ const plugins = [
211
+ ...pluginManager.getPlugins('authProvider'),
212
+ ...pluginManager.getPlugins('headerProvider'),
213
+ ];
214
+ for (const plugin of plugins) {
215
+ try {
216
+ const extra = await plugin.handler();
217
+ if (extra && typeof extra === 'object') {
218
+ Object.assign(headers, extra);
219
+ }
220
+ } catch (err) {
221
+ process.stderr.write(`[Worker] Header plugin error: ${err.message}\n`);
222
+ }
223
+ }
224
+ }
225
+
226
+ async function applyPayloadPlugins(payload) {
227
+ let merged = payload;
228
+ const plugins = pluginManager.getPlugins('payloadGenerator');
229
+ for (const plugin of plugins) {
230
+ try {
231
+ const generated = await plugin.handler();
232
+ if (generated !== undefined && generated !== null) {
233
+ if (merged && typeof merged === 'object' && typeof generated === 'object') {
234
+ merged = { ...merged, ...generated };
235
+ } else if (merged === null || merged === undefined) {
236
+ merged = generated;
237
+ }
238
+ }
239
+ } catch (err) {
240
+ process.stderr.write(`[Worker] Payload plugin error: ${err.message}\n`);
241
+ }
242
+ }
243
+ return merged;
244
+ }
245
+
246
+ async function applyRequestInterceptors(context) {
247
+ let ctx = { ...context };
248
+ const plugins = pluginManager.getPlugins('requestInterceptor');
249
+ for (const plugin of plugins) {
250
+ try {
251
+ const result = await plugin.handler(ctx);
252
+ if (result && typeof result === 'object') {
253
+ ctx = { ...ctx, ...result };
254
+ }
255
+ } catch (err) {
256
+ process.stderr.write(`[Worker] Request interceptor error: ${err.message}\n`);
257
+ }
258
+ }
259
+ return ctx;
260
+ }
261
+
262
+ async function applyMetricsPlugins(data) {
263
+ const plugins = pluginManager.getPlugins('metricsCollector');
264
+ for (const plugin of plugins) {
265
+ try {
266
+ await plugin.handler(data);
267
+ } catch (err) {
268
+ process.stderr.write(`[Worker] Metrics plugin error: ${err.message}\n`);
269
+ }
270
+ }
75
271
  }
76
272
 
77
273
  /**
@@ -89,26 +285,59 @@ async function executeRequest(task) {
89
285
  const method = (route.method || config.method || 'GET').toUpperCase();
90
286
  const headers = { ...(config.headers || {}), ...(route.headers || {}) };
91
287
 
288
+ await applyHeaderPlugins(headers);
289
+
290
+ let payload = route.payload ?? null;
291
+ if (payload == null && datasetLoader) {
292
+ payload = datasetLoader.getRecord(datasetIndex++);
293
+ }
294
+ payload = await applyPayloadPlugins(payload);
295
+
92
296
  let body = null;
93
- if (route.payload != null && method !== 'GET' && method !== 'HEAD') {
94
- const resolved = resolvePayload(route.payload);
297
+ if (payload != null && method !== 'GET' && method !== 'HEAD') {
298
+ const resolved = resolvePayload(payload);
95
299
  body = typeof resolved === 'string' ? resolved : JSON.stringify(resolved);
96
300
  }
97
301
 
302
+ const context = await applyRequestInterceptors({
303
+ url,
304
+ method,
305
+ headers,
306
+ body,
307
+ route,
308
+ });
309
+
310
+ const targetUrl = context.url || url;
311
+ const targetMethod = (context.method || method).toUpperCase();
312
+ const targetHeaders = context.headers || headers;
313
+ const targetBody = context.body ?? body;
314
+
98
315
  const startNs = process.hrtime.bigint();
99
316
  let isError = false;
100
317
  let status = 0;
101
318
 
102
319
  try {
103
- const res = await request(url, {
104
- method,
105
- headers,
106
- body,
320
+ let parsed;
321
+ try {
322
+ parsed = new URL(targetUrl);
323
+ } catch {
324
+ try {
325
+ parsed = new URL(targetUrl, config.baseUrl || config.url);
326
+ } catch (err) {
327
+ throw new Error(
328
+ `Failed to resolve URL "${targetUrl}" with base "${config.baseUrl || config.url}": ${err.message}`,
329
+ );
330
+ }
331
+ }
332
+ const engine = getEngine(parsed.origin);
333
+ const res = await engine.request({
334
+ method: targetMethod,
335
+ path: `${parsed.pathname}${parsed.search}`,
336
+ headers: targetHeaders,
337
+ body: targetBody,
107
338
  });
108
339
 
109
340
  status = res.statusCode;
110
- // Consume body to free the socket (undici requirement)
111
- await res.body.text();
112
341
 
113
342
  if (status >= 400) {
114
343
  isError = true;
@@ -119,18 +348,22 @@ async function executeRequest(task) {
119
348
 
120
349
  const elapsedMs = Number(process.hrtime.bigint() - startNs) / 1e6;
121
350
 
122
- totalRequests++;
123
- totalResponseTime += elapsedMs;
124
- reservoirSample(elapsedMs);
125
-
126
- if (status) {
127
- statusCodes[status] = (statusCodes[status] || 0) + 1;
128
- }
351
+ const endpointKey = resolveEndpointKey(targetMethod, targetUrl, route);
352
+ recordRequestMetrics({ endpointKey, elapsedMs, isError, status });
353
+ await applyMetricsPlugins({
354
+ responseTime: elapsedMs,
355
+ statusCode: status,
356
+ isError,
357
+ route,
358
+ url: targetUrl,
359
+ method: targetMethod,
360
+ });
361
+ }
129
362
 
130
- if (isError) {
131
- errorCount++;
132
- } else {
133
- successCount++;
363
+ async function executeScenario(task) {
364
+ const steps = task.steps || [];
365
+ for (const step of steps) {
366
+ await executeRequest({ route: step });
134
367
  }
135
368
  }
136
369
 
@@ -142,8 +375,11 @@ parentPort.on('message', async (msg) => {
142
375
  successCount = 0;
143
376
  errorCount = 0;
144
377
  totalResponseTime = 0;
378
+ minLatency = Infinity;
379
+ maxLatency = -Infinity;
145
380
  // Keep the reservoir across batches for better sampling
146
381
  Object.keys(statusCodes).forEach((k) => { statusCodes[k] = 0; });
382
+ Object.keys(perEndpoint).forEach((k) => { delete perEndpoint[k]; });
147
383
 
148
384
  // Build task objects from the incoming message
149
385
  const tasks = (msg.tasks || []).map((t, i) => {
@@ -161,7 +397,12 @@ parentPort.on('message', async (msg) => {
161
397
  });
162
398
 
163
399
  // Execute all tasks concurrently
164
- await Promise.all(tasks.map((task) => executeRequest(task)));
400
+ await Promise.all(tasks.map((task) => {
401
+ if (Array.isArray(task.steps)) {
402
+ return executeScenario(task);
403
+ }
404
+ return executeRequest(task);
405
+ }));
165
406
 
166
407
  // Report metrics back to the main thread
167
408
  parentPort.postMessage({
@@ -172,7 +413,19 @@ parentPort.on('message', async (msg) => {
172
413
  errorCount,
173
414
  totalResponseTime,
174
415
  responseTimes: [...reservoir],
416
+ minLatency: minLatency === Infinity ? 0 : minLatency,
417
+ maxLatency: maxLatency === -Infinity ? 0 : maxLatency,
175
418
  statusCodes: { ...statusCodes },
419
+ perEndpoint: Object.fromEntries(
420
+ Object.entries(perEndpoint).map(([key, metrics]) => [
421
+ key,
422
+ {
423
+ ...metrics,
424
+ minLatency: metrics.minLatency === Infinity ? 0 : metrics.minLatency,
425
+ maxLatency: metrics.maxLatency === -Infinity ? 0 : metrics.maxLatency,
426
+ },
427
+ ]),
428
+ ),
176
429
  },
177
430
  });
178
431
  }
@@ -28,6 +28,7 @@ export class CliDashboard {
28
28
  totalRequests: 0,
29
29
  p95: 0,
30
30
  p99: 0,
31
+ perEndpoint: {},
31
32
  };
32
33
  this.rpsHistory = [];
33
34
  this.startTime = null;
@@ -84,6 +85,7 @@ export class CliDashboard {
84
85
  output += `${BOLD}${CYAN}═══════════════════════════════════════════════${RESET}\n`;
85
86
  output += `${DIM} Elapsed: ${elapsed}s${RESET}\n\n`;
86
87
  output += table.toString() + '\n\n';
88
+ output += this._renderEndpointTable();
87
89
  output += this._renderBarChart();
88
90
  output += `\n ${BOLD}Total Requests:${RESET} ${m.totalRequests}\n`;
89
91
 
@@ -111,6 +113,38 @@ export class CliDashboard {
111
113
  chart += ` ${DIM} 0${RESET} └${'─'.repeat(history.length)}\n`;
112
114
  return chart;
113
115
  }
116
+
117
+ _renderEndpointTable() {
118
+ const endpoints = this.metrics.perEndpoint || {};
119
+ const entries = Object.entries(endpoints);
120
+ if (entries.length === 0) return '';
121
+
122
+ const table = new Table({
123
+ head: [
124
+ `${CYAN}Endpoint${RESET}`,
125
+ `${CYAN}RPS${RESET}`,
126
+ `${CYAN}Avg Lat${RESET}`,
127
+ `${CYAN}Errors${RESET}`,
128
+ ],
129
+ colWidths: [30, 8, 12, 10],
130
+ style: { head: [], border: [] },
131
+ });
132
+
133
+ const sorted = entries.sort(
134
+ (a, b) => (b[1].requestsPerSec || 0) - (a[1].requestsPerSec || 0),
135
+ );
136
+
137
+ for (const [endpoint, metrics] of sorted.slice(0, 10)) {
138
+ table.push([
139
+ endpoint,
140
+ metrics.requestsPerSec || 0,
141
+ `${metrics.avgResponseTime || 0}ms`,
142
+ `${metrics.errorRate || 0}%`,
143
+ ]);
144
+ }
145
+
146
+ return `${BOLD}${CYAN}Per-Endpoint Metrics${RESET}\n${table.toString()}\n\n`;
147
+ }
114
148
  }
115
149
 
116
150
  function formatLatency(val) {
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ export { DatasetLoader } from './payload/datasetLoader.js';
21
21
 
22
22
  // Metrics
23
23
  export { MetricsCollector } from './metrics/metricsCollector.js';
24
+ export { ApiMetrics } from './metrics/apiMetrics.js';
24
25
  export { SystemMetrics } from './metrics/systemMetrics.js';
25
26
 
26
27
  // Reporting