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.
- package/README.md +80 -1
- package/package.json +1 -1
- package/src/cli.js +111 -13
- package/src/core/distributedCoordinator.js +52 -0
- package/src/core/httpEngine.js +100 -1
- package/src/core/runner.js +115 -10
- package/src/core/scheduler.js +14 -0
- package/src/core/worker.js +284 -31
- package/src/dashboard/cliDashboard.js +34 -0
- package/src/index.js +1 -0
- package/src/metrics/apiMetrics.js +116 -0
- package/src/metrics/metricsCollector.js +26 -1
- package/src/reporting/htmlReport.js +68 -0
- package/src/reporting/reportWriter.js +41 -0
package/src/core/runner.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/scheduler.js
CHANGED
|
@@ -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
|
}
|
package/src/core/worker.js
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* worker → main : { type: 'result', metrics: { ... } }
|
|
12
12
|
*/
|
|
13
13
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
14
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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 (
|
|
94
|
-
const resolved = resolvePayload(
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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) =>
|
|
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
|