loadscope 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +292 -3
- package/dist/chunk-7L4SENK7.js +269 -0
- package/dist/chunk-7L4SENK7.js.map +1 -0
- package/dist/chunk-OFND2NUO.js +3007 -0
- package/dist/chunk-OFND2NUO.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +361 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +720 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.js +199 -0
- package/dist/worker.js.map +1 -0
- package/package.json +67 -6
|
@@ -0,0 +1,3007 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrivalScheduler,
|
|
3
|
+
Histogram,
|
|
4
|
+
computeQueueDelay,
|
|
5
|
+
extractDistribution,
|
|
6
|
+
recordWithCorrection
|
|
7
|
+
} from "./chunk-7L4SENK7.js";
|
|
8
|
+
|
|
9
|
+
// src/config/schema.ts
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import os from "os";
|
|
12
|
+
function parseDurationToSeconds(val) {
|
|
13
|
+
if (typeof val === "number") {
|
|
14
|
+
if (val <= 0) throw new Error("Duration must be greater than 0");
|
|
15
|
+
return val;
|
|
16
|
+
}
|
|
17
|
+
const trimmed = val.trim().toLowerCase();
|
|
18
|
+
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/);
|
|
19
|
+
if (!match) {
|
|
20
|
+
throw new Error(`Invalid duration format: "${val}". Examples: "30s", "1m", "500ms"`);
|
|
21
|
+
}
|
|
22
|
+
const num = parseFloat(match[1]);
|
|
23
|
+
const unit = match[2] || "s";
|
|
24
|
+
switch (unit) {
|
|
25
|
+
case "ms":
|
|
26
|
+
return num / 1e3;
|
|
27
|
+
case "s":
|
|
28
|
+
return num;
|
|
29
|
+
case "m":
|
|
30
|
+
return num * 60;
|
|
31
|
+
case "h":
|
|
32
|
+
return num * 3600;
|
|
33
|
+
default:
|
|
34
|
+
return num;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function parseHeaders(headersInput) {
|
|
38
|
+
if (!headersInput) return {};
|
|
39
|
+
if (!Array.isArray(headersInput)) return headersInput;
|
|
40
|
+
const result = {};
|
|
41
|
+
for (const item of headersInput) {
|
|
42
|
+
const colonIdx = item.indexOf(":");
|
|
43
|
+
if (colonIdx > 0) {
|
|
44
|
+
const key = item.substring(0, colonIdx).trim();
|
|
45
|
+
const val = item.substring(colonIdx + 1).trim();
|
|
46
|
+
result[key] = val;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
function resolveWorkerCount(workers, concurrency) {
|
|
52
|
+
if (workers === "auto") {
|
|
53
|
+
const cpus = os.cpus()?.length || 1;
|
|
54
|
+
return Math.max(1, Math.min(concurrency, cpus));
|
|
55
|
+
}
|
|
56
|
+
const count = typeof workers === "number" ? workers : 1;
|
|
57
|
+
return Math.max(1, Math.min(concurrency, count));
|
|
58
|
+
}
|
|
59
|
+
var RunOptionsSchema = z.object({
|
|
60
|
+
url: z.string().url("A valid URL starting with http:// or https:// is required"),
|
|
61
|
+
duration: z.union([z.string(), z.number()]).default("10s").transform(parseDurationToSeconds),
|
|
62
|
+
concurrency: z.coerce.number().int().min(1, "Concurrency must be at least 1").default(10),
|
|
63
|
+
rate: z.coerce.number().positive().optional(),
|
|
64
|
+
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]).default("GET"),
|
|
65
|
+
headers: z.record(z.string()).default({}),
|
|
66
|
+
body: z.string().optional(),
|
|
67
|
+
timeout: z.union([z.string(), z.number()]).default("10s").transform(parseDurationToSeconds),
|
|
68
|
+
warmup: z.union([z.string(), z.number()]).optional().transform((v) => v ? parseDurationToSeconds(v) : 0),
|
|
69
|
+
rampUp: z.union([z.string(), z.number()]).optional().transform((v) => v ? parseDurationToSeconds(v) : 0),
|
|
70
|
+
mode: z.enum(["closed", "open"]).optional(),
|
|
71
|
+
distribution: z.enum(["uniform", "poisson"]).default("uniform"),
|
|
72
|
+
honest: z.boolean().default(false),
|
|
73
|
+
ttfb: z.boolean().default(false),
|
|
74
|
+
workers: z.union([z.coerce.number().int().min(1), z.literal("auto")]).default(1),
|
|
75
|
+
dashboard: z.boolean().default(true),
|
|
76
|
+
json: z.union([z.string(), z.boolean()]).optional(),
|
|
77
|
+
csv: z.union([z.string(), z.boolean()]).optional(),
|
|
78
|
+
pdf: z.string().optional(),
|
|
79
|
+
aiReport: z.string().optional(),
|
|
80
|
+
aiDryRun: z.boolean().default(false)
|
|
81
|
+
});
|
|
82
|
+
function validateRunOptions(raw) {
|
|
83
|
+
let headers = raw.headers;
|
|
84
|
+
if (Array.isArray(headers)) {
|
|
85
|
+
headers = parseHeaders(headers);
|
|
86
|
+
}
|
|
87
|
+
return RunOptionsSchema.parse({
|
|
88
|
+
...raw,
|
|
89
|
+
headers: headers ?? {}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/core/runner.ts
|
|
94
|
+
import { Pool } from "undici";
|
|
95
|
+
import { monitorEventLoopDelay } from "perf_hooks";
|
|
96
|
+
async function executeClosedModel(options) {
|
|
97
|
+
const urlObj = new URL(options.url);
|
|
98
|
+
const pool = new Pool(urlObj.origin, {
|
|
99
|
+
connections: options.concurrency,
|
|
100
|
+
pipelining: 1,
|
|
101
|
+
keepAliveTimeout: 3e4,
|
|
102
|
+
keepAliveMaxTimeout: 6e4
|
|
103
|
+
});
|
|
104
|
+
const histogram = new Histogram();
|
|
105
|
+
const ttfbHistogram = new Histogram();
|
|
106
|
+
let currentSecondHistogram = new Histogram();
|
|
107
|
+
const statusCodes = {};
|
|
108
|
+
const errorsByType = {};
|
|
109
|
+
const selfCheckWarnings = [];
|
|
110
|
+
const series = [];
|
|
111
|
+
let networkErrors = 0;
|
|
112
|
+
let httpErrors = 0;
|
|
113
|
+
let totalDispatched = 0;
|
|
114
|
+
const timeoutMs = Math.max(1, Math.floor(options.timeout * 1e3));
|
|
115
|
+
const reqPath = urlObj.pathname + urlObj.search;
|
|
116
|
+
const reqMethod = options.method;
|
|
117
|
+
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
118
|
+
loopDelay.enable();
|
|
119
|
+
let stopped = false;
|
|
120
|
+
const abortController = new AbortController();
|
|
121
|
+
async function dispatchRequest(recordStats) {
|
|
122
|
+
if (recordStats) totalDispatched++;
|
|
123
|
+
const reqStartNs = process.hrtime.bigint();
|
|
124
|
+
try {
|
|
125
|
+
const response = await pool.request({
|
|
126
|
+
path: reqPath,
|
|
127
|
+
method: reqMethod,
|
|
128
|
+
headers: options.headers,
|
|
129
|
+
body: options.body,
|
|
130
|
+
headersTimeout: timeoutMs,
|
|
131
|
+
bodyTimeout: timeoutMs,
|
|
132
|
+
signal: abortController.signal
|
|
133
|
+
});
|
|
134
|
+
const headersNs = process.hrtime.bigint();
|
|
135
|
+
const ttfbUs = Math.max(0, Number(headersNs - reqStartNs) / 1e3);
|
|
136
|
+
await response.body.dump({ limit: 10 * 1024 * 1024 });
|
|
137
|
+
const elapsedUs = Number(process.hrtime.bigint() - reqStartNs) / 1e3;
|
|
138
|
+
const code = response.statusCode;
|
|
139
|
+
if (recordStats) {
|
|
140
|
+
if (options.honest) {
|
|
141
|
+
const expectedIntervalUs = 10 / options.concurrency * 1e3;
|
|
142
|
+
recordWithCorrection(histogram, elapsedUs, expectedIntervalUs);
|
|
143
|
+
} else {
|
|
144
|
+
histogram.record(elapsedUs);
|
|
145
|
+
}
|
|
146
|
+
ttfbHistogram.record(ttfbUs);
|
|
147
|
+
currentSecondHistogram.record(elapsedUs);
|
|
148
|
+
statusCodes[code] = (statusCodes[code] || 0) + 1;
|
|
149
|
+
if (code >= 400) {
|
|
150
|
+
httpErrors++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const elapsedUs = Number(process.hrtime.bigint() - reqStartNs) / 1e3;
|
|
155
|
+
if (recordStats) {
|
|
156
|
+
networkErrors++;
|
|
157
|
+
histogram.record(elapsedUs);
|
|
158
|
+
currentSecondHistogram.record(elapsedUs);
|
|
159
|
+
const errorObj = err;
|
|
160
|
+
const codeKey = errorObj.code || errorObj.name || "NETWORK_ERROR";
|
|
161
|
+
errorsByType[codeKey] = (errorsByType[codeKey] || 0) + 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (options.warmup && options.warmup > 0) {
|
|
166
|
+
const warmupEndNs = process.hrtime.bigint() + BigInt(Math.floor(options.warmup * 1e9));
|
|
167
|
+
let warmupStopped = false;
|
|
168
|
+
const warmupWorkers = Array.from({ length: options.concurrency }, async () => {
|
|
169
|
+
while (!warmupStopped && !stopped) {
|
|
170
|
+
if (process.hrtime.bigint() >= warmupEndNs) {
|
|
171
|
+
warmupStopped = true;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
await dispatchRequest(false);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
await Promise.all(warmupWorkers);
|
|
178
|
+
}
|
|
179
|
+
const durationNs = BigInt(Math.floor(options.duration * 1e9));
|
|
180
|
+
const startNs = process.hrtime.bigint();
|
|
181
|
+
const endNs = startNs + durationNs;
|
|
182
|
+
const hardDeadlineTimer = setTimeout(() => {
|
|
183
|
+
stopped = true;
|
|
184
|
+
abortController.abort();
|
|
185
|
+
}, Math.ceil(options.duration * 1e3) + 200);
|
|
186
|
+
let lastSecond = 0;
|
|
187
|
+
const snapshotTimer = setInterval(() => {
|
|
188
|
+
const elapsedSec = Math.floor(Number(process.hrtime.bigint() - startNs) / 1e9);
|
|
189
|
+
if (elapsedSec > lastSecond) {
|
|
190
|
+
lastSecond = elapsedSec;
|
|
191
|
+
series.push({
|
|
192
|
+
t: elapsedSec,
|
|
193
|
+
p95: currentSecondHistogram.total > 0 ? currentSecondHistogram.percentileMs(95) : histogram.percentileMs(95)
|
|
194
|
+
});
|
|
195
|
+
currentSecondHistogram = new Histogram();
|
|
196
|
+
}
|
|
197
|
+
}, 1e3);
|
|
198
|
+
async function workerLoop(workerIndex) {
|
|
199
|
+
if (options.rampUp && options.rampUp > 0) {
|
|
200
|
+
const delayMs = workerIndex / options.concurrency * options.rampUp * 1e3;
|
|
201
|
+
if (delayMs > 0) {
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
while (!stopped) {
|
|
206
|
+
if (process.hrtime.bigint() >= endNs) {
|
|
207
|
+
stopped = true;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
await dispatchRequest(true);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const workers = Array.from({ length: options.concurrency }, (_, i) => workerLoop(i));
|
|
215
|
+
await Promise.all(workers);
|
|
216
|
+
} finally {
|
|
217
|
+
clearTimeout(hardDeadlineTimer);
|
|
218
|
+
clearInterval(snapshotTimer);
|
|
219
|
+
loopDelay.disable();
|
|
220
|
+
await pool.close();
|
|
221
|
+
}
|
|
222
|
+
const maxLoopLagMs = loopDelay.max / 1e6;
|
|
223
|
+
const p99LoopLagMs = loopDelay.percentile(99) / 1e6;
|
|
224
|
+
if (p99LoopLagMs > 20 || maxLoopLagMs > 50) {
|
|
225
|
+
selfCheckWarnings.push(
|
|
226
|
+
`Event loop lag detected (max: ${maxLoopLagMs.toFixed(1)}ms, p99: ${p99LoopLagMs.toFixed(1)}ms). The runner's event loop may be saturated, adding client-side queue delay.`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const totalNs = process.hrtime.bigint() - startNs;
|
|
230
|
+
const totalDurationSec = Math.max(1e-3, Number(totalNs) / 1e9);
|
|
231
|
+
const rps = totalDurationSec > 0 ? totalDispatched / totalDurationSec : 0;
|
|
232
|
+
const totalFailures = networkErrors + httpErrors;
|
|
233
|
+
const errorRate = totalDispatched > 0 ? totalFailures / totalDispatched : 0;
|
|
234
|
+
if (series.length === 0) {
|
|
235
|
+
series.push({
|
|
236
|
+
t: Math.max(1, Math.round(totalDurationSec)),
|
|
237
|
+
p95: histogram.percentileMs(95)
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
target: options.url,
|
|
242
|
+
rps,
|
|
243
|
+
totalRequests: totalDispatched,
|
|
244
|
+
durationSec: totalDurationSec,
|
|
245
|
+
p50: histogram.percentileMs(50),
|
|
246
|
+
p90: histogram.percentileMs(90),
|
|
247
|
+
p95: histogram.percentileMs(95),
|
|
248
|
+
p99: histogram.percentileMs(99),
|
|
249
|
+
errorRate,
|
|
250
|
+
networkErrors,
|
|
251
|
+
httpErrors,
|
|
252
|
+
statusCodes,
|
|
253
|
+
errorsByType,
|
|
254
|
+
series,
|
|
255
|
+
selfCheckWarnings,
|
|
256
|
+
ttfb: options.ttfb ? extractDistribution(ttfbHistogram) : void 0
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/core/open-model.ts
|
|
261
|
+
import { Pool as Pool2 } from "undici";
|
|
262
|
+
import { monitorEventLoopDelay as monitorEventLoopDelay2 } from "perf_hooks";
|
|
263
|
+
async function executeOpenModel(options) {
|
|
264
|
+
const targetRps = options.rate && options.rate > 0 ? options.rate : 50;
|
|
265
|
+
const distribution = options.distribution ?? "uniform";
|
|
266
|
+
const expectedIntervalUs = 1 / targetRps * 1e6;
|
|
267
|
+
const maxConnections = Math.max(options.concurrency, Math.min(500, Math.ceil(targetRps * 2)));
|
|
268
|
+
const urlObj = new URL(options.url);
|
|
269
|
+
const pool = new Pool2(urlObj.origin, {
|
|
270
|
+
connections: maxConnections,
|
|
271
|
+
pipelining: 1,
|
|
272
|
+
keepAliveTimeout: 3e4,
|
|
273
|
+
keepAliveMaxTimeout: 6e4
|
|
274
|
+
});
|
|
275
|
+
const responseHistogram = new Histogram();
|
|
276
|
+
const serviceHistogram = new Histogram();
|
|
277
|
+
const ttfbHistogram = new Histogram();
|
|
278
|
+
let currentSecondHistogram = new Histogram();
|
|
279
|
+
const statusCodes = {};
|
|
280
|
+
const errorsByType = {};
|
|
281
|
+
const selfCheckWarnings = [];
|
|
282
|
+
const series = [];
|
|
283
|
+
let networkErrors = 0;
|
|
284
|
+
let httpErrors = 0;
|
|
285
|
+
let totalDispatched = 0;
|
|
286
|
+
const timeoutMs = Math.max(1, Math.floor(options.timeout * 1e3));
|
|
287
|
+
const reqPath = urlObj.pathname + urlObj.search;
|
|
288
|
+
const reqMethod = options.method;
|
|
289
|
+
const loopDelay = monitorEventLoopDelay2({ resolution: 20 });
|
|
290
|
+
loopDelay.enable();
|
|
291
|
+
let stopped = false;
|
|
292
|
+
const abortController = new AbortController();
|
|
293
|
+
async function dispatchRequest(intendedNs, recordStats) {
|
|
294
|
+
if (recordStats) totalDispatched++;
|
|
295
|
+
const dispatchNs = process.hrtime.bigint();
|
|
296
|
+
try {
|
|
297
|
+
const response = await pool.request({
|
|
298
|
+
path: reqPath,
|
|
299
|
+
method: reqMethod,
|
|
300
|
+
headers: options.headers,
|
|
301
|
+
body: options.body,
|
|
302
|
+
headersTimeout: timeoutMs,
|
|
303
|
+
bodyTimeout: timeoutMs,
|
|
304
|
+
signal: abortController.signal
|
|
305
|
+
});
|
|
306
|
+
const headersNs = process.hrtime.bigint();
|
|
307
|
+
const ttfbUs = Math.max(0, Number(headersNs - dispatchNs) / 1e3);
|
|
308
|
+
await response.body.dump({ limit: 10 * 1024 * 1024 });
|
|
309
|
+
const completionNs = process.hrtime.bigint();
|
|
310
|
+
const serviceUs = Math.max(0, Number(completionNs - dispatchNs) / 1e3);
|
|
311
|
+
const honestResponseUs = Math.max(0, Number(completionNs - intendedNs) / 1e3);
|
|
312
|
+
const code = response.statusCode;
|
|
313
|
+
if (recordStats) {
|
|
314
|
+
if (options.honest) {
|
|
315
|
+
recordWithCorrection(responseHistogram, honestResponseUs, expectedIntervalUs);
|
|
316
|
+
recordWithCorrection(serviceHistogram, serviceUs, expectedIntervalUs);
|
|
317
|
+
} else {
|
|
318
|
+
responseHistogram.record(honestResponseUs);
|
|
319
|
+
serviceHistogram.record(serviceUs);
|
|
320
|
+
}
|
|
321
|
+
ttfbHistogram.record(ttfbUs);
|
|
322
|
+
currentSecondHistogram.record(honestResponseUs);
|
|
323
|
+
statusCodes[code] = (statusCodes[code] || 0) + 1;
|
|
324
|
+
if (code >= 400) {
|
|
325
|
+
httpErrors++;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
const completionNs = process.hrtime.bigint();
|
|
330
|
+
const serviceUs = Math.max(0, Number(completionNs - dispatchNs) / 1e3);
|
|
331
|
+
const honestResponseUs = Math.max(0, Number(completionNs - intendedNs) / 1e3);
|
|
332
|
+
if (recordStats) {
|
|
333
|
+
networkErrors++;
|
|
334
|
+
responseHistogram.record(honestResponseUs);
|
|
335
|
+
serviceHistogram.record(serviceUs);
|
|
336
|
+
currentSecondHistogram.record(honestResponseUs);
|
|
337
|
+
const errorObj = err;
|
|
338
|
+
const codeKey = errorObj.code || errorObj.name || "NETWORK_ERROR";
|
|
339
|
+
errorsByType[codeKey] = (errorsByType[codeKey] || 0) + 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (options.warmup && options.warmup > 0) {
|
|
344
|
+
const warmupScheduler = new ArrivalScheduler(targetRps, distribution);
|
|
345
|
+
const warmupDurationNs = BigInt(Math.floor(options.warmup * 1e9));
|
|
346
|
+
const warmupStartNs = process.hrtime.bigint();
|
|
347
|
+
const warmupEndNs = warmupStartNs + warmupDurationNs;
|
|
348
|
+
while (process.hrtime.bigint() < warmupEndNs && !stopped) {
|
|
349
|
+
const targetNs = warmupScheduler.nextArrivalNs();
|
|
350
|
+
await warmupScheduler.waitUntil(targetNs);
|
|
351
|
+
void dispatchRequest(targetNs, false);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const durationNs = BigInt(Math.floor(options.duration * 1e9));
|
|
355
|
+
const startNs = process.hrtime.bigint();
|
|
356
|
+
const endNs = startNs + durationNs;
|
|
357
|
+
const scheduler = new ArrivalScheduler(targetRps, distribution, startNs);
|
|
358
|
+
const hardDeadlineTimer = setTimeout(() => {
|
|
359
|
+
stopped = true;
|
|
360
|
+
abortController.abort();
|
|
361
|
+
}, Math.ceil(options.duration * 1e3) + 300);
|
|
362
|
+
let lastSecond = 0;
|
|
363
|
+
const snapshotTimer = setInterval(() => {
|
|
364
|
+
const elapsedSec = Math.floor(Number(process.hrtime.bigint() - startNs) / 1e9);
|
|
365
|
+
if (elapsedSec > lastSecond) {
|
|
366
|
+
lastSecond = elapsedSec;
|
|
367
|
+
series.push({
|
|
368
|
+
t: elapsedSec,
|
|
369
|
+
p95: currentSecondHistogram.total > 0 ? currentSecondHistogram.percentileMs(95) : responseHistogram.percentileMs(95)
|
|
370
|
+
});
|
|
371
|
+
currentSecondHistogram = new Histogram();
|
|
372
|
+
}
|
|
373
|
+
}, 1e3);
|
|
374
|
+
const pendingPromises = /* @__PURE__ */ new Set();
|
|
375
|
+
while (!stopped) {
|
|
376
|
+
const nowNs = process.hrtime.bigint();
|
|
377
|
+
if (nowNs >= endNs) {
|
|
378
|
+
stopped = true;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
const targetArrivalNs = scheduler.nextArrivalNs();
|
|
382
|
+
await scheduler.waitUntil(targetArrivalNs);
|
|
383
|
+
const p = dispatchRequest(targetArrivalNs, true);
|
|
384
|
+
pendingPromises.add(p);
|
|
385
|
+
p.finally(() => pendingPromises.delete(p));
|
|
386
|
+
if (pendingPromises.size > maxConnections * 2) {
|
|
387
|
+
await Promise.race(pendingPromises);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
await Promise.allSettled(Array.from(pendingPromises));
|
|
391
|
+
clearTimeout(hardDeadlineTimer);
|
|
392
|
+
clearInterval(snapshotTimer);
|
|
393
|
+
loopDelay.disable();
|
|
394
|
+
await pool.close();
|
|
395
|
+
const maxLoopLagMs = loopDelay.max / 1e6;
|
|
396
|
+
const p99LoopLagMs = loopDelay.percentile(99) / 1e6;
|
|
397
|
+
if (p99LoopLagMs > 20 || maxLoopLagMs > 50) {
|
|
398
|
+
selfCheckWarnings.push(
|
|
399
|
+
`Event loop lag detected (max: ${maxLoopLagMs.toFixed(1)}ms, p99: ${p99LoopLagMs.toFixed(1)}ms). The runner's event loop may be saturated, adding client-side queue delay.`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (scheduler.averageDriftMs > 10) {
|
|
403
|
+
selfCheckWarnings.push(
|
|
404
|
+
`High schedule drift detected (avg drift: ${scheduler.averageDriftMs.toFixed(1)}ms). The client could not maintain the target ${targetRps} req/s arrival rate.`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const totalNs = process.hrtime.bigint() - startNs;
|
|
408
|
+
const totalDurationSec = Math.max(1e-3, Number(totalNs) / 1e9);
|
|
409
|
+
const rps = totalDurationSec > 0 ? totalDispatched / totalDurationSec : 0;
|
|
410
|
+
const totalFailures = networkErrors + httpErrors;
|
|
411
|
+
const errorRate = totalDispatched > 0 ? totalFailures / totalDispatched : 0;
|
|
412
|
+
if (series.length === 0) {
|
|
413
|
+
series.push({
|
|
414
|
+
t: Math.max(1, Math.round(totalDurationSec)),
|
|
415
|
+
p95: responseHistogram.percentileMs(95)
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const serviceDist = extractDistribution(serviceHistogram);
|
|
419
|
+
const responseDist = extractDistribution(responseHistogram);
|
|
420
|
+
const queueDelayDist = computeQueueDelay(responseDist, serviceDist);
|
|
421
|
+
const ttfbDist = extractDistribution(ttfbHistogram);
|
|
422
|
+
return {
|
|
423
|
+
target: options.url,
|
|
424
|
+
rps,
|
|
425
|
+
totalRequests: totalDispatched,
|
|
426
|
+
durationSec: totalDurationSec,
|
|
427
|
+
p50: responseDist.p50,
|
|
428
|
+
p90: responseDist.p90,
|
|
429
|
+
p95: responseDist.p95,
|
|
430
|
+
p99: responseDist.p99,
|
|
431
|
+
errorRate,
|
|
432
|
+
networkErrors,
|
|
433
|
+
httpErrors,
|
|
434
|
+
statusCodes,
|
|
435
|
+
errorsByType,
|
|
436
|
+
series,
|
|
437
|
+
selfCheckWarnings,
|
|
438
|
+
distribution,
|
|
439
|
+
serviceTime: serviceDist,
|
|
440
|
+
queueDelay: queueDelayDist,
|
|
441
|
+
ttfb: options.ttfb ? ttfbDist : void 0,
|
|
442
|
+
scheduleDriftMs: scheduler.averageDriftMs
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/core/multi-runner.ts
|
|
447
|
+
import { Worker } from "worker_threads";
|
|
448
|
+
import path from "path";
|
|
449
|
+
import fs from "fs";
|
|
450
|
+
import { fileURLToPath } from "url";
|
|
451
|
+
|
|
452
|
+
// src/report/dashboard.ts
|
|
453
|
+
import logUpdate from "log-update";
|
|
454
|
+
import picocolors from "picocolors";
|
|
455
|
+
var LiveDashboard = class {
|
|
456
|
+
enabled;
|
|
457
|
+
constructor(enabled = true) {
|
|
458
|
+
this.enabled = enabled && Boolean(process.stdout.isTTY);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Format a progress bar like [████████░░░░░░] 60%
|
|
462
|
+
*/
|
|
463
|
+
renderProgressBar(percent, width = 24) {
|
|
464
|
+
const clamped = Math.max(0, Math.min(1, percent));
|
|
465
|
+
const filled = Math.round(clamped * width);
|
|
466
|
+
const empty = width - filled;
|
|
467
|
+
const bar = picocolors.cyan("\u2588".repeat(filled)) + picocolors.dim("\u2591".repeat(empty));
|
|
468
|
+
const pctStr = (clamped * 100).toFixed(0).padStart(3) + "%";
|
|
469
|
+
return `[${bar}] ${picocolors.bold(pctStr)}`;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Generates the dashboard text representation.
|
|
473
|
+
*/
|
|
474
|
+
formatDashboard(stats) {
|
|
475
|
+
const lines = [];
|
|
476
|
+
const divider = picocolors.dim("\u2500".repeat(58));
|
|
477
|
+
const progressPct = stats.totalDurationSec > 0 ? stats.elapsedSec / stats.totalDurationSec : 0;
|
|
478
|
+
lines.push("");
|
|
479
|
+
lines.push(divider);
|
|
480
|
+
lines.push(
|
|
481
|
+
picocolors.bold(picocolors.cyan(" LoadScope Live Monitor ")) + picocolors.dim(`\u2022 ${stats.target}`)
|
|
482
|
+
);
|
|
483
|
+
lines.push(divider);
|
|
484
|
+
const timeStr = `${stats.elapsedSec.toFixed(1)}s / ${stats.totalDurationSec.toFixed(1)}s`;
|
|
485
|
+
lines.push(` ${this.renderProgressBar(progressPct)} ${picocolors.dim(timeStr)}`);
|
|
486
|
+
lines.push(
|
|
487
|
+
` ${picocolors.dim("Throughput:")} ${picocolors.green(picocolors.bold(stats.currentRps.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })))} req/s ${picocolors.dim("Total Requests:")} ${picocolors.bold(stats.totalRequests.toLocaleString("en-US"))}`
|
|
488
|
+
);
|
|
489
|
+
const p50Str = picocolors.cyan(`${stats.p50Ms.toFixed(2)}ms`);
|
|
490
|
+
const p90Str = picocolors.cyan(`${stats.p90Ms.toFixed(2)}ms`);
|
|
491
|
+
const p95Str = stats.p95Ms > 300 ? picocolors.yellow(`${stats.p95Ms.toFixed(2)}ms`) : picocolors.cyan(`${stats.p95Ms.toFixed(2)}ms`);
|
|
492
|
+
lines.push(
|
|
493
|
+
` ${picocolors.dim("Latency:")} p50: ${p50Str} p90: ${p90Str} p95: ${p95Str}`
|
|
494
|
+
);
|
|
495
|
+
const totalFailures = stats.networkErrors + stats.httpErrors;
|
|
496
|
+
const errorRate = stats.totalRequests > 0 ? totalFailures / stats.totalRequests * 100 : 0;
|
|
497
|
+
const errorColor = totalFailures > 0 ? picocolors.red : picocolors.green;
|
|
498
|
+
const errorStr = errorColor(`${errorRate.toFixed(2)}% (${totalFailures.toLocaleString("en-US")} fails)`);
|
|
499
|
+
lines.push(
|
|
500
|
+
` ${picocolors.dim("Error Rate:")} ${errorStr} ${picocolors.dim("Workers:")} ${picocolors.bold(stats.workerCount.toString())} active`
|
|
501
|
+
);
|
|
502
|
+
if (stats.warnings && stats.warnings.length > 0) {
|
|
503
|
+
lines.push("");
|
|
504
|
+
for (const w of stats.warnings) {
|
|
505
|
+
lines.push(` ${picocolors.yellow("\u26A0\uFE0F ")} ${w}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
lines.push(divider);
|
|
509
|
+
return lines.join("\n");
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Update the live terminal display.
|
|
513
|
+
*/
|
|
514
|
+
update(stats) {
|
|
515
|
+
if (!this.enabled) return;
|
|
516
|
+
logUpdate(this.formatDashboard(stats));
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Stop the live dashboard and clean up terminal state.
|
|
520
|
+
*/
|
|
521
|
+
stop() {
|
|
522
|
+
if (!this.enabled) return;
|
|
523
|
+
logUpdate.clear();
|
|
524
|
+
logUpdate.done();
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// src/core/multi-runner.ts
|
|
529
|
+
function getWorkerPath() {
|
|
530
|
+
const distCandidate = path.resolve(process.cwd(), "dist/worker.js");
|
|
531
|
+
if (fs.existsSync(distCandidate)) {
|
|
532
|
+
return { path: distCandidate };
|
|
533
|
+
}
|
|
534
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
535
|
+
const dir = path.dirname(currentFile);
|
|
536
|
+
const candidate1 = path.resolve(dir, "worker.js");
|
|
537
|
+
if (fs.existsSync(candidate1)) return { path: candidate1 };
|
|
538
|
+
const candidate2 = path.resolve(dir, "../worker.js");
|
|
539
|
+
if (fs.existsSync(candidate2)) return { path: candidate2 };
|
|
540
|
+
if (currentFile.endsWith(".ts")) {
|
|
541
|
+
const tsWorker = path.resolve(dir, "worker.ts");
|
|
542
|
+
return { path: tsWorker, execArgv: ["--import", "tsx"] };
|
|
543
|
+
}
|
|
544
|
+
return { path: distCandidate };
|
|
545
|
+
}
|
|
546
|
+
async function executeMultiThreaded(options) {
|
|
547
|
+
const workerCount = resolveWorkerCount(options.workers, options.concurrency);
|
|
548
|
+
const targetMode = options.rate && options.rate > 0 ? "open" : options.mode ?? "closed";
|
|
549
|
+
const baseConcurrency = Math.floor(options.concurrency / workerCount);
|
|
550
|
+
const extraConcurrency = options.concurrency % workerCount;
|
|
551
|
+
const ratePerWorker = options.rate && options.rate > 0 ? options.rate / workerCount : void 0;
|
|
552
|
+
const globalHistogram = new Histogram();
|
|
553
|
+
const globalServiceHistogram = new Histogram();
|
|
554
|
+
const globalTtfbHistogram = new Histogram();
|
|
555
|
+
const aggregatedStatusCodes = {};
|
|
556
|
+
const aggregatedErrorsByType = {};
|
|
557
|
+
const selfCheckWarnings = [];
|
|
558
|
+
const series = [];
|
|
559
|
+
const workerLagMax = [];
|
|
560
|
+
const workerLagP99 = [];
|
|
561
|
+
let currentSecondWindowHistogram = new Histogram();
|
|
562
|
+
const dashboard = new LiveDashboard(options.dashboard);
|
|
563
|
+
const { path: workerScript, execArgv } = getWorkerPath();
|
|
564
|
+
const startNs = process.hrtime.bigint();
|
|
565
|
+
let lastRecordedSec = 0;
|
|
566
|
+
const latestTicks = /* @__PURE__ */ new Map();
|
|
567
|
+
const doneMessages = [];
|
|
568
|
+
const workerPromises = [];
|
|
569
|
+
for (let i = 0; i < workerCount; i++) {
|
|
570
|
+
const workerConcurrency = baseConcurrency + (i < extraConcurrency ? 1 : 0);
|
|
571
|
+
const initData = {
|
|
572
|
+
workerIndex: i,
|
|
573
|
+
totalWorkers: workerCount,
|
|
574
|
+
url: options.url,
|
|
575
|
+
duration: options.duration,
|
|
576
|
+
concurrency: workerConcurrency,
|
|
577
|
+
rate: ratePerWorker,
|
|
578
|
+
distribution: options.distribution,
|
|
579
|
+
honest: options.honest,
|
|
580
|
+
ttfb: options.ttfb,
|
|
581
|
+
method: options.method,
|
|
582
|
+
headers: options.headers,
|
|
583
|
+
body: options.body,
|
|
584
|
+
timeout: options.timeout,
|
|
585
|
+
warmup: options.warmup ?? 0,
|
|
586
|
+
rampUp: options.rampUp ?? 0,
|
|
587
|
+
mode: targetMode
|
|
588
|
+
};
|
|
589
|
+
const promise = new Promise((resolve, reject) => {
|
|
590
|
+
const worker = new Worker(workerScript, {
|
|
591
|
+
workerData: initData,
|
|
592
|
+
execArgv: execArgv ?? []
|
|
593
|
+
});
|
|
594
|
+
worker.on("message", (msg) => {
|
|
595
|
+
if (msg.type === "tick") {
|
|
596
|
+
latestTicks.set(msg.workerIndex, msg);
|
|
597
|
+
currentSecondWindowHistogram.mergeSnapshot(msg.windowSnapshot);
|
|
598
|
+
let liveDispatched = 0;
|
|
599
|
+
let liveNetworkErrors = 0;
|
|
600
|
+
let liveHttpErrors = 0;
|
|
601
|
+
const liveMergedHist = new Histogram();
|
|
602
|
+
for (const t of latestTicks.values()) {
|
|
603
|
+
liveDispatched += t.dispatched;
|
|
604
|
+
liveNetworkErrors += t.networkErrors;
|
|
605
|
+
liveHttpErrors += t.httpErrors;
|
|
606
|
+
liveMergedHist.mergeSnapshot(t.cumulativeSnapshot);
|
|
607
|
+
}
|
|
608
|
+
const elapsedSec = Math.floor(Number(process.hrtime.bigint() - startNs) / 1e9);
|
|
609
|
+
const currentRps = elapsedSec > 0 ? liveDispatched / elapsedSec : 0;
|
|
610
|
+
if (elapsedSec > lastRecordedSec) {
|
|
611
|
+
lastRecordedSec = elapsedSec;
|
|
612
|
+
series.push({
|
|
613
|
+
t: elapsedSec,
|
|
614
|
+
p95: currentSecondWindowHistogram.total > 0 ? currentSecondWindowHistogram.percentileMs(95) : liveMergedHist.percentileMs(95)
|
|
615
|
+
});
|
|
616
|
+
currentSecondWindowHistogram = new Histogram();
|
|
617
|
+
}
|
|
618
|
+
dashboard.update({
|
|
619
|
+
target: options.url,
|
|
620
|
+
elapsedSec,
|
|
621
|
+
totalDurationSec: options.duration,
|
|
622
|
+
totalRequests: liveDispatched,
|
|
623
|
+
currentRps,
|
|
624
|
+
p50Ms: liveMergedHist.percentileMs(50),
|
|
625
|
+
p90Ms: liveMergedHist.percentileMs(90),
|
|
626
|
+
p95Ms: liveMergedHist.percentileMs(95),
|
|
627
|
+
networkErrors: liveNetworkErrors,
|
|
628
|
+
httpErrors: liveHttpErrors,
|
|
629
|
+
workerCount,
|
|
630
|
+
mode: targetMode
|
|
631
|
+
});
|
|
632
|
+
} else if (msg.type === "done") {
|
|
633
|
+
doneMessages.push(msg);
|
|
634
|
+
workerLagMax.push(msg.loopLagMaxMs);
|
|
635
|
+
workerLagP99.push(msg.loopLagP99Ms);
|
|
636
|
+
} else if (msg.type === "error") {
|
|
637
|
+
reject(new Error(`Worker ${msg.workerIndex} encountered error: ${msg.error}`));
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
worker.on("error", reject);
|
|
641
|
+
worker.on("exit", (code) => {
|
|
642
|
+
if (code !== 0 && doneMessages.length < workerCount) {
|
|
643
|
+
reject(new Error(`Worker ${i} exited with non-zero code ${code}`));
|
|
644
|
+
} else {
|
|
645
|
+
resolve();
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
workerPromises.push(promise);
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
await Promise.all(workerPromises);
|
|
653
|
+
} finally {
|
|
654
|
+
dashboard.stop();
|
|
655
|
+
}
|
|
656
|
+
let totalDispatched = 0;
|
|
657
|
+
let networkErrors = 0;
|
|
658
|
+
let httpErrors = 0;
|
|
659
|
+
for (const done of doneMessages) {
|
|
660
|
+
totalDispatched += done.dispatched;
|
|
661
|
+
networkErrors += done.networkErrors;
|
|
662
|
+
httpErrors += done.httpErrors;
|
|
663
|
+
globalHistogram.mergeSnapshot(done.finalSnapshot);
|
|
664
|
+
if (done.serviceTimeSnapshot) {
|
|
665
|
+
globalServiceHistogram.mergeSnapshot(done.serviceTimeSnapshot);
|
|
666
|
+
}
|
|
667
|
+
if (done.ttfbSnapshot) {
|
|
668
|
+
globalTtfbHistogram.mergeSnapshot(done.ttfbSnapshot);
|
|
669
|
+
}
|
|
670
|
+
for (const [code, count] of Object.entries(done.statusCodes)) {
|
|
671
|
+
const numCode = Number(code);
|
|
672
|
+
aggregatedStatusCodes[numCode] = (aggregatedStatusCodes[numCode] || 0) + count;
|
|
673
|
+
}
|
|
674
|
+
for (const [errType, count] of Object.entries(done.errorsByType)) {
|
|
675
|
+
aggregatedErrorsByType[errType] = (aggregatedErrorsByType[errType] || 0) + count;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const maxLag = workerLagMax.length > 0 ? Math.max(...workerLagMax) : 0;
|
|
679
|
+
const maxP99Lag = workerLagP99.length > 0 ? Math.max(...workerLagP99) : 0;
|
|
680
|
+
if (maxP99Lag > 20 || maxLag > 50) {
|
|
681
|
+
selfCheckWarnings.push(
|
|
682
|
+
`Worker event loop lag detected (highest worker max: ${maxLag.toFixed(1)}ms, p99: ${maxP99Lag.toFixed(1)}ms). Load generator event loop was under heavy load.`
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
const totalNs = process.hrtime.bigint() - startNs;
|
|
686
|
+
const totalDurationSec = Math.max(1e-3, Number(totalNs) / 1e9);
|
|
687
|
+
const rps = totalDurationSec > 0 ? totalDispatched / totalDurationSec : 0;
|
|
688
|
+
const totalFailures = networkErrors + httpErrors;
|
|
689
|
+
const errorRate = totalDispatched > 0 ? totalFailures / totalDispatched : 0;
|
|
690
|
+
if (series.length === 0) {
|
|
691
|
+
series.push({
|
|
692
|
+
t: Math.max(1, Math.round(totalDurationSec)),
|
|
693
|
+
p95: globalHistogram.percentileMs(95)
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
const responseDist = extractDistribution(globalHistogram);
|
|
697
|
+
const hasService = globalServiceHistogram.total > 0;
|
|
698
|
+
const serviceDist = hasService ? extractDistribution(globalServiceHistogram) : void 0;
|
|
699
|
+
const queueDelayDist = serviceDist ? computeQueueDelay(responseDist, serviceDist) : void 0;
|
|
700
|
+
const ttfbDist = globalTtfbHistogram.total > 0 && options.ttfb ? extractDistribution(globalTtfbHistogram) : void 0;
|
|
701
|
+
return {
|
|
702
|
+
target: options.url,
|
|
703
|
+
rps,
|
|
704
|
+
totalRequests: totalDispatched,
|
|
705
|
+
durationSec: totalDurationSec,
|
|
706
|
+
p50: responseDist.p50,
|
|
707
|
+
p90: responseDist.p90,
|
|
708
|
+
p95: responseDist.p95,
|
|
709
|
+
p99: responseDist.p99,
|
|
710
|
+
errorRate,
|
|
711
|
+
networkErrors,
|
|
712
|
+
httpErrors,
|
|
713
|
+
statusCodes: aggregatedStatusCodes,
|
|
714
|
+
errorsByType: aggregatedErrorsByType,
|
|
715
|
+
series,
|
|
716
|
+
selfCheckWarnings,
|
|
717
|
+
workerCount,
|
|
718
|
+
distribution: options.distribution,
|
|
719
|
+
serviceTime: serviceDist,
|
|
720
|
+
queueDelay: queueDelayDist,
|
|
721
|
+
ttfb: ttfbDist
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// src/report/terminal.ts
|
|
726
|
+
import picocolors2 from "picocolors";
|
|
727
|
+
function fmt(num, decimals = 2) {
|
|
728
|
+
return num.toLocaleString("en-US", {
|
|
729
|
+
minimumFractionDigits: decimals,
|
|
730
|
+
maximumFractionDigits: decimals
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
function formatTerminalReport(s) {
|
|
734
|
+
const lines = [];
|
|
735
|
+
const divider = picocolors2.dim("\u2500".repeat(58));
|
|
736
|
+
lines.push("");
|
|
737
|
+
lines.push(divider);
|
|
738
|
+
lines.push(
|
|
739
|
+
picocolors2.bold(picocolors2.cyan(" LoadScope Test Results ")) + picocolors2.dim(`\u2022 ${s.target}`)
|
|
740
|
+
);
|
|
741
|
+
lines.push(divider);
|
|
742
|
+
lines.push(
|
|
743
|
+
` ${picocolors2.dim("Duration:")} ${picocolors2.bold(fmt(s.durationSec, 1))}s ${picocolors2.dim("Total Requests:")} ${picocolors2.bold(s.totalRequests.toLocaleString())}`
|
|
744
|
+
);
|
|
745
|
+
const errorColor = s.errorRate > 0 ? picocolors2.red : picocolors2.green;
|
|
746
|
+
const errorRateStr = errorColor(fmt(s.errorRate * 100, 2) + "%");
|
|
747
|
+
lines.push(
|
|
748
|
+
` ${picocolors2.dim("Throughput:")} ${picocolors2.green(picocolors2.bold(fmt(s.rps, 1)))} req/s ${picocolors2.dim("Error Rate:")} ${errorRateStr}`
|
|
749
|
+
);
|
|
750
|
+
if (s.distribution || s.scheduleDriftMs !== void 0) {
|
|
751
|
+
const distText = s.distribution ? `Open Arrival (${s.distribution.toUpperCase()})` : "Closed Concurrency";
|
|
752
|
+
const driftText = s.scheduleDriftMs !== void 0 ? ` \u2022 Schedule Drift: ${fmt(s.scheduleDriftMs, 2)}ms` : "";
|
|
753
|
+
lines.push(` ${picocolors2.dim("Model:")} ${picocolors2.white(distText)}${picocolors2.dim(driftText)}`);
|
|
754
|
+
}
|
|
755
|
+
if (s.networkErrors > 0 || s.httpErrors > 0) {
|
|
756
|
+
const errorDetails = [];
|
|
757
|
+
if (s.httpErrors > 0) errorDetails.push(`${s.httpErrors.toLocaleString()} HTTP 4xx/5xx`);
|
|
758
|
+
if (s.networkErrors > 0) errorDetails.push(`${s.networkErrors.toLocaleString()} network`);
|
|
759
|
+
lines.push(` ${picocolors2.dim("Failures:")} ${picocolors2.yellow(errorDetails.join(", "))}`);
|
|
760
|
+
}
|
|
761
|
+
if (s.serviceTime && s.queueDelay) {
|
|
762
|
+
lines.push("");
|
|
763
|
+
lines.push(picocolors2.bold(" Coordinated Omission Breakdown (Honest Latency vs Service Time):"));
|
|
764
|
+
lines.push(picocolors2.dim(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510"));
|
|
765
|
+
lines.push(
|
|
766
|
+
picocolors2.dim(" \u2502 ") + picocolors2.white("Percentile".padEnd(14)) + picocolors2.dim(" \u2502 ") + picocolors2.white("Service Time".padEnd(14)) + picocolors2.dim(" \u2502 ") + picocolors2.white("Queue Delay".padEnd(14)) + picocolors2.dim(" \u2502 ") + picocolors2.white("Honest Total".padEnd(14)) + picocolors2.dim(" \u2502")
|
|
767
|
+
);
|
|
768
|
+
lines.push(picocolors2.dim(" \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524"));
|
|
769
|
+
const compRows = [
|
|
770
|
+
["p50 (median)", s.serviceTime.p50, s.queueDelay.p50, s.p50],
|
|
771
|
+
["p90", s.serviceTime.p90, s.queueDelay.p90, s.p90],
|
|
772
|
+
["p95", s.serviceTime.p95, s.queueDelay.p95, s.p95],
|
|
773
|
+
["p99", s.serviceTime.p99, s.queueDelay.p99, s.p99]
|
|
774
|
+
];
|
|
775
|
+
for (const [pct, svc, q, total] of compRows) {
|
|
776
|
+
const totalColor = total > 300 ? picocolors2.yellow : picocolors2.cyan;
|
|
777
|
+
const qColor = q > 50 ? picocolors2.yellow : picocolors2.dim;
|
|
778
|
+
lines.push(
|
|
779
|
+
picocolors2.dim(" \u2502 ") + picocolors2.white(pct.padEnd(14)) + picocolors2.dim(" \u2502 ") + picocolors2.cyan((fmt(svc, 2) + " ms").padStart(14)) + picocolors2.dim(" \u2502 ") + qColor((fmt(q, 2) + " ms").padStart(14)) + picocolors2.dim(" \u2502 ") + totalColor((fmt(total, 2) + " ms").padStart(14)) + picocolors2.dim(" \u2502")
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
lines.push(picocolors2.dim(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"));
|
|
783
|
+
} else {
|
|
784
|
+
lines.push("");
|
|
785
|
+
lines.push(picocolors2.bold(" Latency Distribution:"));
|
|
786
|
+
lines.push(picocolors2.dim(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510"));
|
|
787
|
+
lines.push(
|
|
788
|
+
picocolors2.dim(" \u2502 ") + picocolors2.white("Percentile".padEnd(14)) + picocolors2.dim(" \u2502 ") + picocolors2.white("Latency (ms)".padEnd(14)) + picocolors2.dim(" \u2502")
|
|
789
|
+
);
|
|
790
|
+
lines.push(picocolors2.dim(" \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524"));
|
|
791
|
+
const rows = [
|
|
792
|
+
["p50 (median)", s.p50],
|
|
793
|
+
["p90", s.p90],
|
|
794
|
+
["p95", s.p95],
|
|
795
|
+
["p99", s.p99]
|
|
796
|
+
];
|
|
797
|
+
for (const [pct, val] of rows) {
|
|
798
|
+
const valColor = val > 300 ? picocolors2.yellow : picocolors2.cyan;
|
|
799
|
+
const namePadded = pct.padEnd(14);
|
|
800
|
+
const valFormatted = (fmt(val, 2) + " ms").padStart(14);
|
|
801
|
+
lines.push(
|
|
802
|
+
picocolors2.dim(" \u2502 ") + picocolors2.white(namePadded) + picocolors2.dim(" \u2502 ") + valColor(valFormatted) + picocolors2.dim(" \u2502")
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
lines.push(picocolors2.dim(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"));
|
|
806
|
+
}
|
|
807
|
+
if (s.ttfb) {
|
|
808
|
+
lines.push("");
|
|
809
|
+
lines.push(
|
|
810
|
+
picocolors2.bold(" Time-To-First-Byte (TTFB): ") + `p50: ${picocolors2.cyan(fmt(s.ttfb.p50, 2) + "ms")} p90: ${picocolors2.cyan(fmt(s.ttfb.p90, 2) + "ms")} p95: ${picocolors2.cyan(fmt(s.ttfb.p95, 2) + "ms")} p99: ${picocolors2.cyan(fmt(s.ttfb.p99, 2) + "ms")}`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const statusEntries = Object.entries(s.statusCodes);
|
|
814
|
+
if (statusEntries.length > 0) {
|
|
815
|
+
lines.push("");
|
|
816
|
+
lines.push(picocolors2.bold(" HTTP Status Codes:"));
|
|
817
|
+
for (const [code, count] of statusEntries) {
|
|
818
|
+
const numCode = Number(code);
|
|
819
|
+
let colorFn = picocolors2.green;
|
|
820
|
+
if (numCode >= 400 && numCode < 500) colorFn = picocolors2.yellow;
|
|
821
|
+
if (numCode >= 500) colorFn = picocolors2.red;
|
|
822
|
+
lines.push(` ${colorFn(code)}: ${count.toLocaleString()} requests`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (s.errorsByType && Object.keys(s.errorsByType).length > 0) {
|
|
826
|
+
lines.push("");
|
|
827
|
+
lines.push(picocolors2.bold(" Network Error Breakdown:"));
|
|
828
|
+
for (const [errType, count] of Object.entries(s.errorsByType)) {
|
|
829
|
+
lines.push(` ${picocolors2.red(errType)}: ${count.toLocaleString()} occurrences`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (s.selfCheckWarnings && s.selfCheckWarnings.length > 0) {
|
|
833
|
+
lines.push("");
|
|
834
|
+
lines.push(picocolors2.yellow(picocolors2.bold(" Self-Monitoring Warnings:")));
|
|
835
|
+
for (const warning of s.selfCheckWarnings) {
|
|
836
|
+
lines.push(` ${picocolors2.yellow("\u26A0\uFE0F ")} ${warning}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
lines.push(divider);
|
|
840
|
+
lines.push("");
|
|
841
|
+
return lines.join("\n");
|
|
842
|
+
}
|
|
843
|
+
function printTerminalReport(s) {
|
|
844
|
+
console.log(formatTerminalReport(s));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// src/report/export.ts
|
|
848
|
+
function formatJsonReport(summary) {
|
|
849
|
+
return JSON.stringify(summary, null, 2);
|
|
850
|
+
}
|
|
851
|
+
function formatCsvReport(summary) {
|
|
852
|
+
const lines = [];
|
|
853
|
+
lines.push("# LoadScope Run Summary");
|
|
854
|
+
lines.push("target,duration_sec,total_requests,rps,p50_ms,p90_ms,p95_ms,p99_ms,error_rate,network_errors,http_errors,service_p95_ms,ttfb_p95_ms,distribution");
|
|
855
|
+
lines.push(
|
|
856
|
+
[
|
|
857
|
+
`"${summary.target}"`,
|
|
858
|
+
summary.durationSec.toFixed(2),
|
|
859
|
+
summary.totalRequests,
|
|
860
|
+
summary.rps.toFixed(2),
|
|
861
|
+
summary.p50.toFixed(2),
|
|
862
|
+
summary.p90.toFixed(2),
|
|
863
|
+
summary.p95.toFixed(2),
|
|
864
|
+
summary.p99.toFixed(2),
|
|
865
|
+
(summary.errorRate * 100).toFixed(2) + "%",
|
|
866
|
+
summary.networkErrors,
|
|
867
|
+
summary.httpErrors,
|
|
868
|
+
summary.serviceTime ? summary.serviceTime.p95.toFixed(2) : "N/A",
|
|
869
|
+
summary.ttfb ? summary.ttfb.p95.toFixed(2) : "N/A",
|
|
870
|
+
summary.distribution ?? "closed"
|
|
871
|
+
].join(",")
|
|
872
|
+
);
|
|
873
|
+
lines.push("");
|
|
874
|
+
lines.push("# Per-Second Latency Series");
|
|
875
|
+
lines.push("time_sec,p95_ms");
|
|
876
|
+
for (const point of summary.series) {
|
|
877
|
+
lines.push(`${point.t},${point.p95.toFixed(2)}`);
|
|
878
|
+
}
|
|
879
|
+
lines.push("");
|
|
880
|
+
lines.push("# Status Codes");
|
|
881
|
+
lines.push("status_code,count");
|
|
882
|
+
for (const [code, count] of Object.entries(summary.statusCodes)) {
|
|
883
|
+
lines.push(`${code},${count}`);
|
|
884
|
+
}
|
|
885
|
+
return lines.join("\n");
|
|
886
|
+
}
|
|
887
|
+
function formatBreakpointJsonReport(summary) {
|
|
888
|
+
return JSON.stringify(summary, null, 2);
|
|
889
|
+
}
|
|
890
|
+
function formatBreakpointCsvReport(summary) {
|
|
891
|
+
const lines = [];
|
|
892
|
+
lines.push("# LoadScope Breaking-Point Summary");
|
|
893
|
+
lines.push("target,mode,start,max,step,step_duration_sec,breaking_point,safe_capacity,breached,breach_reason");
|
|
894
|
+
lines.push(
|
|
895
|
+
[
|
|
896
|
+
`"${summary.target}"`,
|
|
897
|
+
summary.mode,
|
|
898
|
+
summary.start,
|
|
899
|
+
summary.max,
|
|
900
|
+
summary.step,
|
|
901
|
+
summary.stepDurationSec,
|
|
902
|
+
summary.breakingPoint !== void 0 ? summary.breakingPoint : "none",
|
|
903
|
+
summary.safeCapacity !== void 0 ? summary.safeCapacity : "none",
|
|
904
|
+
summary.breached,
|
|
905
|
+
summary.breachReason ? `"${summary.breachReason}"` : "none"
|
|
906
|
+
].join(",")
|
|
907
|
+
);
|
|
908
|
+
lines.push("");
|
|
909
|
+
lines.push("# Step Progression");
|
|
910
|
+
lines.push("step,is_refinement,load,rps,p50_ms,p95_ms,p99_ms,error_rate,total_requests,network_errors,http_errors,status,breach_reason");
|
|
911
|
+
const allSteps = [...summary.steps, ...summary.refinementSteps];
|
|
912
|
+
for (const s of allSteps) {
|
|
913
|
+
lines.push(
|
|
914
|
+
[
|
|
915
|
+
s.stepIndex,
|
|
916
|
+
s.isRefinement ? "true" : "false",
|
|
917
|
+
s.load,
|
|
918
|
+
s.rps.toFixed(2),
|
|
919
|
+
s.p50.toFixed(2),
|
|
920
|
+
s.p95.toFixed(2),
|
|
921
|
+
s.p99.toFixed(2),
|
|
922
|
+
(s.errorRate * 100).toFixed(2) + "%",
|
|
923
|
+
s.totalRequests,
|
|
924
|
+
s.networkErrors,
|
|
925
|
+
s.httpErrors,
|
|
926
|
+
s.status,
|
|
927
|
+
s.breachReason ? `"${s.breachReason}"` : ""
|
|
928
|
+
].join(",")
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
return lines.join("\n");
|
|
932
|
+
}
|
|
933
|
+
function formatComparisonJsonReport(summary) {
|
|
934
|
+
return JSON.stringify(summary, null, 2);
|
|
935
|
+
}
|
|
936
|
+
function formatComparisonCsvReport(summary) {
|
|
937
|
+
const lines = [];
|
|
938
|
+
lines.push("# LoadScope CI Regression Gate Comparison");
|
|
939
|
+
lines.push("target,passed,regressions_count,improvements_count");
|
|
940
|
+
lines.push(
|
|
941
|
+
[
|
|
942
|
+
`"${summary.currentTarget}"`,
|
|
943
|
+
summary.passed,
|
|
944
|
+
summary.regressions.length,
|
|
945
|
+
summary.improvements.length
|
|
946
|
+
].join(",")
|
|
947
|
+
);
|
|
948
|
+
lines.push("");
|
|
949
|
+
lines.push("# Metrics Comparison");
|
|
950
|
+
lines.push("metric,baseline,current,delta,delta_pct,unit,status,violation,message");
|
|
951
|
+
for (const m of summary.metrics) {
|
|
952
|
+
lines.push(
|
|
953
|
+
[
|
|
954
|
+
`"${m.name}"`,
|
|
955
|
+
m.baseline.toFixed(2),
|
|
956
|
+
m.current.toFixed(2),
|
|
957
|
+
m.delta.toFixed(2),
|
|
958
|
+
m.deltaPct.toFixed(2) + "%",
|
|
959
|
+
`"${m.unit}"`,
|
|
960
|
+
m.status,
|
|
961
|
+
m.thresholdViolation ? "true" : "false",
|
|
962
|
+
m.message ? `"${m.message}"` : ""
|
|
963
|
+
].join(",")
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
return lines.join("\n");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/modes/breaking-point.ts
|
|
970
|
+
function evaluateThresholds(summary, limits) {
|
|
971
|
+
if (summary.errorRate > limits.maxErrorRate) {
|
|
972
|
+
return {
|
|
973
|
+
status: "BREACH",
|
|
974
|
+
breachReason: `Error rate ${(summary.errorRate * 100).toFixed(2)}% exceeded limit ${(limits.maxErrorRate * 100).toFixed(2)}%`
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
if (limits.maxP95 !== void 0 && summary.p95 > limits.maxP95) {
|
|
978
|
+
return {
|
|
979
|
+
status: "BREACH",
|
|
980
|
+
breachReason: `p95 latency ${summary.p95.toFixed(1)}ms exceeded limit ${limits.maxP95}ms`
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
if (limits.maxP99 !== void 0 && summary.p99 > limits.maxP99) {
|
|
984
|
+
return {
|
|
985
|
+
status: "BREACH",
|
|
986
|
+
breachReason: `p99 latency ${summary.p99.toFixed(1)}ms exceeded limit ${limits.maxP99}ms`
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
return { status: "PASS" };
|
|
990
|
+
}
|
|
991
|
+
async function executeStep(options, load, stepDurationSec) {
|
|
992
|
+
const isRate = options.mode === "rate";
|
|
993
|
+
return await run({
|
|
994
|
+
url: options.url,
|
|
995
|
+
duration: stepDurationSec,
|
|
996
|
+
concurrency: isRate ? Math.max(10, Math.ceil(load / 10)) : load,
|
|
997
|
+
rate: isRate ? load : void 0,
|
|
998
|
+
mode: isRate ? "open" : "closed",
|
|
999
|
+
distribution: options.distribution || "uniform",
|
|
1000
|
+
honest: options.honest ?? true,
|
|
1001
|
+
workers: options.workers || 1,
|
|
1002
|
+
method: options.method || "GET",
|
|
1003
|
+
headers: options.headers,
|
|
1004
|
+
body: options.body,
|
|
1005
|
+
timeout: typeof options.timeout === "number" || typeof options.timeout === "string" ? parseDurationToSeconds(options.timeout) : void 0,
|
|
1006
|
+
dashboard: false
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
async function runBreakingPoint(options) {
|
|
1010
|
+
const mode = options.mode || "concurrency";
|
|
1011
|
+
const start = options.start ?? 10;
|
|
1012
|
+
const max = options.max ?? 100;
|
|
1013
|
+
const step = options.step ?? 10;
|
|
1014
|
+
const stepDurationSec = options.stepDuration ? parseDurationToSeconds(options.stepDuration) : 5;
|
|
1015
|
+
const refine = options.refine ?? true;
|
|
1016
|
+
const tolerance = options.tolerance ?? 2;
|
|
1017
|
+
const limits = {
|
|
1018
|
+
maxP95: options.maxP95 ?? 300,
|
|
1019
|
+
maxP99: options.maxP99,
|
|
1020
|
+
maxErrorRate: options.maxErrorRate ?? 0.01
|
|
1021
|
+
};
|
|
1022
|
+
const steps = [];
|
|
1023
|
+
const refinementSteps = [];
|
|
1024
|
+
let lastSafeLoad;
|
|
1025
|
+
let firstBreachedLoad;
|
|
1026
|
+
let primaryBreachReason;
|
|
1027
|
+
let stepCounter = 1;
|
|
1028
|
+
for (let load = start; load <= max; load += step) {
|
|
1029
|
+
if (options.onStepStart) {
|
|
1030
|
+
options.onStepStart(stepCounter, load, false);
|
|
1031
|
+
}
|
|
1032
|
+
const summary = await executeStep(options, load, stepDurationSec);
|
|
1033
|
+
const { status, breachReason } = evaluateThresholds(summary, limits);
|
|
1034
|
+
const stepResult = {
|
|
1035
|
+
stepIndex: stepCounter,
|
|
1036
|
+
load,
|
|
1037
|
+
rps: summary.rps,
|
|
1038
|
+
p50: summary.p50,
|
|
1039
|
+
p95: summary.p95,
|
|
1040
|
+
p99: summary.p99,
|
|
1041
|
+
errorRate: summary.errorRate,
|
|
1042
|
+
totalRequests: summary.totalRequests,
|
|
1043
|
+
networkErrors: summary.networkErrors,
|
|
1044
|
+
httpErrors: summary.httpErrors,
|
|
1045
|
+
status,
|
|
1046
|
+
breachReason,
|
|
1047
|
+
isRefinement: false,
|
|
1048
|
+
summary
|
|
1049
|
+
};
|
|
1050
|
+
steps.push(stepResult);
|
|
1051
|
+
if (options.onStepComplete) {
|
|
1052
|
+
options.onStepComplete(stepResult);
|
|
1053
|
+
}
|
|
1054
|
+
if (status === "BREACH") {
|
|
1055
|
+
firstBreachedLoad = load;
|
|
1056
|
+
primaryBreachReason = breachReason;
|
|
1057
|
+
break;
|
|
1058
|
+
} else {
|
|
1059
|
+
lastSafeLoad = load;
|
|
1060
|
+
}
|
|
1061
|
+
stepCounter++;
|
|
1062
|
+
}
|
|
1063
|
+
let breakingPoint = firstBreachedLoad;
|
|
1064
|
+
if (firstBreachedLoad !== void 0 && refine && lastSafeLoad !== void 0) {
|
|
1065
|
+
let low = lastSafeLoad;
|
|
1066
|
+
let high = firstBreachedLoad;
|
|
1067
|
+
while (high - low > tolerance) {
|
|
1068
|
+
const mid = Math.floor((low + high) / 2);
|
|
1069
|
+
if (mid === low || mid === high) {
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
stepCounter++;
|
|
1073
|
+
if (options.onStepStart) {
|
|
1074
|
+
options.onStepStart(stepCounter, mid, true);
|
|
1075
|
+
}
|
|
1076
|
+
const summary = await executeStep(options, mid, stepDurationSec);
|
|
1077
|
+
const { status, breachReason } = evaluateThresholds(summary, limits);
|
|
1078
|
+
const refResult = {
|
|
1079
|
+
stepIndex: stepCounter,
|
|
1080
|
+
load: mid,
|
|
1081
|
+
rps: summary.rps,
|
|
1082
|
+
p50: summary.p50,
|
|
1083
|
+
p95: summary.p95,
|
|
1084
|
+
p99: summary.p99,
|
|
1085
|
+
errorRate: summary.errorRate,
|
|
1086
|
+
totalRequests: summary.totalRequests,
|
|
1087
|
+
networkErrors: summary.networkErrors,
|
|
1088
|
+
httpErrors: summary.httpErrors,
|
|
1089
|
+
status,
|
|
1090
|
+
breachReason,
|
|
1091
|
+
isRefinement: true,
|
|
1092
|
+
summary
|
|
1093
|
+
};
|
|
1094
|
+
refinementSteps.push(refResult);
|
|
1095
|
+
if (options.onStepComplete) {
|
|
1096
|
+
options.onStepComplete(refResult);
|
|
1097
|
+
}
|
|
1098
|
+
if (status === "BREACH") {
|
|
1099
|
+
high = mid;
|
|
1100
|
+
breakingPoint = mid;
|
|
1101
|
+
primaryBreachReason = breachReason;
|
|
1102
|
+
} else {
|
|
1103
|
+
low = mid;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const safeCapacity = breakingPoint !== void 0 ? Math.max(1, Math.floor((lastSafeLoad ?? breakingPoint) * 0.8)) : max;
|
|
1108
|
+
const breached = breakingPoint !== void 0;
|
|
1109
|
+
return {
|
|
1110
|
+
target: options.url,
|
|
1111
|
+
mode,
|
|
1112
|
+
start,
|
|
1113
|
+
max,
|
|
1114
|
+
step,
|
|
1115
|
+
stepDurationSec,
|
|
1116
|
+
breakingPoint,
|
|
1117
|
+
safeCapacity,
|
|
1118
|
+
breached,
|
|
1119
|
+
breachReason: primaryBreachReason,
|
|
1120
|
+
steps,
|
|
1121
|
+
refinementSteps,
|
|
1122
|
+
limits
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// src/report/breakpoint-terminal.ts
|
|
1127
|
+
import picocolors3 from "picocolors";
|
|
1128
|
+
function fmt2(num, decimals = 1) {
|
|
1129
|
+
return num.toLocaleString("en-US", {
|
|
1130
|
+
minimumFractionDigits: decimals,
|
|
1131
|
+
maximumFractionDigits: decimals
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
function pad(str, width, alignRight = false) {
|
|
1135
|
+
if (str.length >= width) return str;
|
|
1136
|
+
const padding = " ".repeat(width - str.length);
|
|
1137
|
+
return alignRight ? padding + str : str + padding;
|
|
1138
|
+
}
|
|
1139
|
+
function formatStepRow(step, mode) {
|
|
1140
|
+
const loadUnit = mode === "concurrency" ? "conn" : "req/s";
|
|
1141
|
+
const loadStr = `${step.load} ${loadUnit}`;
|
|
1142
|
+
const rpsStr = `${fmt2(step.rps, 1)}/s`;
|
|
1143
|
+
const p50Str = fmt2(step.p50, 1);
|
|
1144
|
+
const p95Str = fmt2(step.p95, 1);
|
|
1145
|
+
const p99Str = fmt2(step.p99, 1);
|
|
1146
|
+
const errStr = `${fmt2(step.errorRate * 100, 2)}%`;
|
|
1147
|
+
let statusBadge = "";
|
|
1148
|
+
if (step.status === "PASS") {
|
|
1149
|
+
statusBadge = picocolors3.green("\u2705 PASS");
|
|
1150
|
+
} else {
|
|
1151
|
+
const reason = step.breachReason ? ` (${step.breachReason})` : "";
|
|
1152
|
+
statusBadge = picocolors3.red(`\u274C BREACH${reason}`);
|
|
1153
|
+
}
|
|
1154
|
+
const prefix = step.isRefinement ? picocolors3.cyan("\u2022") : " ";
|
|
1155
|
+
return ` ${prefix} ${pad(step.stepIndex.toString(), 3, true)} \u2502 ${pad(loadStr, 11)} \u2502 ${pad(rpsStr, 10, true)} \u2502 ${pad(p50Str, 8, true)} \u2502 ${pad(p95Str, 8, true)} \u2502 ${pad(p99Str, 8, true)} \u2502 ${pad(errStr, 8, true)} \u2502 ${statusBadge}`;
|
|
1156
|
+
}
|
|
1157
|
+
function formatBreakpointTerminalReport(summary) {
|
|
1158
|
+
const lines = [];
|
|
1159
|
+
const divider = picocolors3.dim("\u2500".repeat(78));
|
|
1160
|
+
lines.push("");
|
|
1161
|
+
lines.push(divider);
|
|
1162
|
+
lines.push(
|
|
1163
|
+
picocolors3.bold(picocolors3.cyan(" LoadScope Breaking-Point Capacity Analysis ")) + picocolors3.dim(`\u2022 ${summary.target}`)
|
|
1164
|
+
);
|
|
1165
|
+
lines.push(divider);
|
|
1166
|
+
const loadType = summary.mode === "concurrency" ? "Connections" : "Target Rate (req/s)";
|
|
1167
|
+
lines.push(
|
|
1168
|
+
` ${picocolors3.dim("Mode:")} ${summary.mode.toUpperCase()} (${loadType}) ${picocolors3.dim("Step Duration:")} ${summary.stepDurationSec}s per step`
|
|
1169
|
+
);
|
|
1170
|
+
lines.push(
|
|
1171
|
+
` ${picocolors3.dim("Ramp Range:")} ${summary.start} \u2794 ${summary.max} (step size: ${summary.step}) ${picocolors3.dim("Total Steps:")} ${summary.steps.length + summary.refinementSteps.length}`
|
|
1172
|
+
);
|
|
1173
|
+
const limitsDesc = [];
|
|
1174
|
+
if (summary.limits.maxP95 !== void 0) limitsDesc.push(`p95 \u2264 ${summary.limits.maxP95}ms`);
|
|
1175
|
+
if (summary.limits.maxP99 !== void 0) limitsDesc.push(`p99 \u2264 ${summary.limits.maxP99}ms`);
|
|
1176
|
+
limitsDesc.push(`Errors \u2264 ${(summary.limits.maxErrorRate * 100).toFixed(1)}%`);
|
|
1177
|
+
lines.push(` ${picocolors3.dim("SLA Limits:")} ${picocolors3.yellow(limitsDesc.join(" | "))}`);
|
|
1178
|
+
lines.push("");
|
|
1179
|
+
lines.push(picocolors3.dim(" \u250C\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510"));
|
|
1180
|
+
lines.push(
|
|
1181
|
+
picocolors3.bold(
|
|
1182
|
+
` \u2502 ${pad("Step", 3)} \u2502 ${pad("Load", 11)} \u2502 ${pad("Throughput", 10)} \u2502 ${pad("p50 (ms)", 8)} \u2502 ${pad("p95 (ms)", 8)} \u2502 ${pad("p99 (ms)", 8)} \u2502 ${pad("Errors", 8)} \u2502 ${pad("Outcome", 27)} \u2502`
|
|
1183
|
+
)
|
|
1184
|
+
);
|
|
1185
|
+
lines.push(picocolors3.dim(" \u251C\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524"));
|
|
1186
|
+
for (const step of summary.steps) {
|
|
1187
|
+
lines.push(formatStepRow(step, summary.mode));
|
|
1188
|
+
}
|
|
1189
|
+
if (summary.refinementSteps.length > 0) {
|
|
1190
|
+
lines.push(picocolors3.dim(" \u251C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524"));
|
|
1191
|
+
lines.push(picocolors3.italic(picocolors3.cyan(" \u2502 Refinement Phase (Binary Search Convergence): \u2502")));
|
|
1192
|
+
lines.push(picocolors3.dim(" \u251C\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524"));
|
|
1193
|
+
for (const step of summary.refinementSteps) {
|
|
1194
|
+
lines.push(formatStepRow(step, summary.mode));
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
lines.push(picocolors3.dim(" \u2514\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"));
|
|
1198
|
+
lines.push("");
|
|
1199
|
+
const unit = summary.mode === "concurrency" ? "concurrent connections" : "req/sec";
|
|
1200
|
+
if (summary.breached && summary.breakingPoint !== void 0) {
|
|
1201
|
+
lines.push(
|
|
1202
|
+
picocolors3.red(
|
|
1203
|
+
picocolors3.bold(` \u{1F3AF} BREAKING POINT DETECTED: ${summary.breakingPoint} ${unit}`)
|
|
1204
|
+
)
|
|
1205
|
+
);
|
|
1206
|
+
if (summary.breachReason) {
|
|
1207
|
+
lines.push(picocolors3.dim(` Trigger: `) + picocolors3.red(summary.breachReason));
|
|
1208
|
+
}
|
|
1209
|
+
lines.push("");
|
|
1210
|
+
lines.push(
|
|
1211
|
+
picocolors3.green(
|
|
1212
|
+
picocolors3.bold(` \u{1F6E1}\uFE0F RECOMMENDED SAFE CEILING (80% headroom): ${summary.safeCapacity} ${unit}`)
|
|
1213
|
+
)
|
|
1214
|
+
);
|
|
1215
|
+
lines.push(
|
|
1216
|
+
picocolors3.dim(
|
|
1217
|
+
` System sustained load below this threshold without SLA latency or error budget violations.`
|
|
1218
|
+
)
|
|
1219
|
+
);
|
|
1220
|
+
} else {
|
|
1221
|
+
lines.push(
|
|
1222
|
+
picocolors3.green(
|
|
1223
|
+
picocolors3.bold(` \u{1F3AF} NO BREAKING POINT REACHED up to maximum ceiling of ${summary.max} ${unit}!`)
|
|
1224
|
+
)
|
|
1225
|
+
);
|
|
1226
|
+
lines.push(
|
|
1227
|
+
picocolors3.dim(
|
|
1228
|
+
` Target passed all SLA thresholds across all tested load levels. Increase --max to test higher capacity.`
|
|
1229
|
+
)
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
lines.push(divider);
|
|
1233
|
+
lines.push("");
|
|
1234
|
+
return lines.join("\n");
|
|
1235
|
+
}
|
|
1236
|
+
function printBreakpointTerminalReport(summary) {
|
|
1237
|
+
console.log(formatBreakpointTerminalReport(summary));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// src/modes/regression-gate.ts
|
|
1241
|
+
import fs2 from "fs/promises";
|
|
1242
|
+
import path2 from "path";
|
|
1243
|
+
async function saveBaseline(summary, filePath = "loadscope-baseline.json", options) {
|
|
1244
|
+
const resolvedPath = path2.resolve(process.cwd(), filePath);
|
|
1245
|
+
const baseline = {
|
|
1246
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1247
|
+
target: summary.target,
|
|
1248
|
+
concurrency: options?.concurrency,
|
|
1249
|
+
rate: options?.rate,
|
|
1250
|
+
durationSec: summary.durationSec,
|
|
1251
|
+
rps: summary.rps,
|
|
1252
|
+
totalRequests: summary.totalRequests,
|
|
1253
|
+
p50: summary.p50,
|
|
1254
|
+
p90: summary.p90,
|
|
1255
|
+
p95: summary.p95,
|
|
1256
|
+
p99: summary.p99,
|
|
1257
|
+
errorRate: summary.errorRate,
|
|
1258
|
+
networkErrors: summary.networkErrors,
|
|
1259
|
+
httpErrors: summary.httpErrors,
|
|
1260
|
+
commitHash: process.env.GITHUB_SHA || process.env.GIT_COMMIT || void 0
|
|
1261
|
+
};
|
|
1262
|
+
await fs2.writeFile(resolvedPath, JSON.stringify(baseline, null, 2), "utf-8");
|
|
1263
|
+
return resolvedPath;
|
|
1264
|
+
}
|
|
1265
|
+
async function loadBaseline(filePath) {
|
|
1266
|
+
const resolvedPath = path2.resolve(process.cwd(), filePath);
|
|
1267
|
+
const content = await fs2.readFile(resolvedPath, "utf-8");
|
|
1268
|
+
const parsed = JSON.parse(content);
|
|
1269
|
+
if (typeof parsed.rps !== "number" || typeof parsed.p95 !== "number") {
|
|
1270
|
+
throw new Error(`Invalid baseline file at ${filePath}: missing required performance metrics.`);
|
|
1271
|
+
}
|
|
1272
|
+
return parsed;
|
|
1273
|
+
}
|
|
1274
|
+
function compareRuns(current, baseline, thresholdsInput) {
|
|
1275
|
+
const thresholds = {
|
|
1276
|
+
maxP95IncreasePct: thresholdsInput?.maxP95IncreasePct ?? 10,
|
|
1277
|
+
maxRpsDropPct: thresholdsInput?.maxRpsDropPct ?? 5,
|
|
1278
|
+
maxP99IncreasePct: thresholdsInput?.maxP99IncreasePct,
|
|
1279
|
+
maxErrorRate: thresholdsInput?.maxErrorRate ?? 0.01
|
|
1280
|
+
};
|
|
1281
|
+
const metrics = [];
|
|
1282
|
+
{
|
|
1283
|
+
const delta = current.rps - baseline.rps;
|
|
1284
|
+
const deltaPct = baseline.rps > 0 ? delta / baseline.rps * 100 : 0;
|
|
1285
|
+
const isRegression = deltaPct < -thresholds.maxRpsDropPct;
|
|
1286
|
+
const isImprovement = deltaPct > 1;
|
|
1287
|
+
let status = "PASS";
|
|
1288
|
+
if (isRegression) status = "REGRESSION";
|
|
1289
|
+
else if (isImprovement) status = "IMPROVEMENT";
|
|
1290
|
+
metrics.push({
|
|
1291
|
+
name: "Throughput",
|
|
1292
|
+
baseline: baseline.rps,
|
|
1293
|
+
current: current.rps,
|
|
1294
|
+
delta,
|
|
1295
|
+
deltaPct,
|
|
1296
|
+
unit: "req/s",
|
|
1297
|
+
isHigherBetter: true,
|
|
1298
|
+
status,
|
|
1299
|
+
threshold: thresholds.maxRpsDropPct,
|
|
1300
|
+
thresholdViolation: isRegression,
|
|
1301
|
+
message: isRegression ? `Throughput dropped by ${Math.abs(deltaPct).toFixed(1)}% (allowed drop: ${thresholds.maxRpsDropPct}%)` : void 0
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
{
|
|
1305
|
+
const delta = current.p50 - baseline.p50;
|
|
1306
|
+
const deltaPct = baseline.p50 > 0 ? delta / baseline.p50 * 100 : 0;
|
|
1307
|
+
const isImprovement = deltaPct < -1;
|
|
1308
|
+
metrics.push({
|
|
1309
|
+
name: "p50 Latency",
|
|
1310
|
+
baseline: baseline.p50,
|
|
1311
|
+
current: current.p50,
|
|
1312
|
+
delta,
|
|
1313
|
+
deltaPct,
|
|
1314
|
+
unit: "ms",
|
|
1315
|
+
isHigherBetter: false,
|
|
1316
|
+
status: isImprovement ? "IMPROVEMENT" : "PASS"
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
{
|
|
1320
|
+
const delta = current.p90 - baseline.p90;
|
|
1321
|
+
const deltaPct = baseline.p90 > 0 ? delta / baseline.p90 * 100 : 0;
|
|
1322
|
+
const isImprovement = deltaPct < -1;
|
|
1323
|
+
metrics.push({
|
|
1324
|
+
name: "p90 Latency",
|
|
1325
|
+
baseline: baseline.p90,
|
|
1326
|
+
current: current.p90,
|
|
1327
|
+
delta,
|
|
1328
|
+
deltaPct,
|
|
1329
|
+
unit: "ms",
|
|
1330
|
+
isHigherBetter: false,
|
|
1331
|
+
status: isImprovement ? "IMPROVEMENT" : "PASS"
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
{
|
|
1335
|
+
const delta = current.p95 - baseline.p95;
|
|
1336
|
+
const deltaPct = baseline.p95 > 0 ? delta / baseline.p95 * 100 : 0;
|
|
1337
|
+
const isRegression = deltaPct > thresholds.maxP95IncreasePct;
|
|
1338
|
+
const isImprovement = deltaPct < -1;
|
|
1339
|
+
let status = "PASS";
|
|
1340
|
+
if (isRegression) status = "REGRESSION";
|
|
1341
|
+
else if (isImprovement) status = "IMPROVEMENT";
|
|
1342
|
+
metrics.push({
|
|
1343
|
+
name: "p95 Latency",
|
|
1344
|
+
baseline: baseline.p95,
|
|
1345
|
+
current: current.p95,
|
|
1346
|
+
delta,
|
|
1347
|
+
deltaPct,
|
|
1348
|
+
unit: "ms",
|
|
1349
|
+
isHigherBetter: false,
|
|
1350
|
+
status,
|
|
1351
|
+
threshold: thresholds.maxP95IncreasePct,
|
|
1352
|
+
thresholdViolation: isRegression,
|
|
1353
|
+
message: isRegression ? `p95 latency increased by +${deltaPct.toFixed(1)}% (allowed increase: ${thresholds.maxP95IncreasePct}%)` : void 0
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
{
|
|
1357
|
+
const delta = current.p99 - baseline.p99;
|
|
1358
|
+
const deltaPct = baseline.p99 > 0 ? delta / baseline.p99 * 100 : 0;
|
|
1359
|
+
const isRegression = thresholds.maxP99IncreasePct !== void 0 && deltaPct > thresholds.maxP99IncreasePct;
|
|
1360
|
+
const isImprovement = deltaPct < -1;
|
|
1361
|
+
let status = "PASS";
|
|
1362
|
+
if (isRegression) status = "REGRESSION";
|
|
1363
|
+
else if (isImprovement) status = "IMPROVEMENT";
|
|
1364
|
+
metrics.push({
|
|
1365
|
+
name: "p99 Latency",
|
|
1366
|
+
baseline: baseline.p99,
|
|
1367
|
+
current: current.p99,
|
|
1368
|
+
delta,
|
|
1369
|
+
deltaPct,
|
|
1370
|
+
unit: "ms",
|
|
1371
|
+
isHigherBetter: false,
|
|
1372
|
+
status,
|
|
1373
|
+
threshold: thresholds.maxP99IncreasePct,
|
|
1374
|
+
thresholdViolation: isRegression,
|
|
1375
|
+
message: isRegression ? `p99 latency increased by +${deltaPct.toFixed(1)}% (allowed: ${thresholds.maxP99IncreasePct}%)` : void 0
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
{
|
|
1379
|
+
const delta = current.errorRate - baseline.errorRate;
|
|
1380
|
+
const deltaPct = baseline.errorRate > 0 ? delta / baseline.errorRate * 100 : current.errorRate > 0 ? 100 : 0;
|
|
1381
|
+
const isRegression = thresholds.maxErrorRate !== void 0 && current.errorRate > thresholds.maxErrorRate;
|
|
1382
|
+
const isImprovement = current.errorRate < baseline.errorRate;
|
|
1383
|
+
let status = "PASS";
|
|
1384
|
+
if (isRegression) status = "REGRESSION";
|
|
1385
|
+
else if (isImprovement) status = "IMPROVEMENT";
|
|
1386
|
+
metrics.push({
|
|
1387
|
+
name: "Error Rate",
|
|
1388
|
+
baseline: baseline.errorRate * 100,
|
|
1389
|
+
current: current.errorRate * 100,
|
|
1390
|
+
delta: delta * 100,
|
|
1391
|
+
deltaPct,
|
|
1392
|
+
unit: "%",
|
|
1393
|
+
isHigherBetter: false,
|
|
1394
|
+
status,
|
|
1395
|
+
threshold: thresholds.maxErrorRate !== void 0 ? thresholds.maxErrorRate * 100 : void 0,
|
|
1396
|
+
thresholdViolation: isRegression,
|
|
1397
|
+
message: isRegression ? `Error rate ${(current.errorRate * 100).toFixed(2)}% exceeded limit ${(thresholds.maxErrorRate * 100).toFixed(2)}%` : void 0
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
const regressions = metrics.filter((m) => m.status === "REGRESSION");
|
|
1401
|
+
const improvements = metrics.filter((m) => m.status === "IMPROVEMENT");
|
|
1402
|
+
const passed = regressions.length === 0;
|
|
1403
|
+
return {
|
|
1404
|
+
passed,
|
|
1405
|
+
baselineTarget: baseline.target,
|
|
1406
|
+
currentTarget: current.target,
|
|
1407
|
+
baselineTimestamp: baseline.timestamp,
|
|
1408
|
+
metrics,
|
|
1409
|
+
regressions,
|
|
1410
|
+
improvements,
|
|
1411
|
+
thresholds
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// src/report/compare-terminal.ts
|
|
1416
|
+
import picocolors4 from "picocolors";
|
|
1417
|
+
import fs3 from "fs/promises";
|
|
1418
|
+
function fmt3(num, decimals = 1) {
|
|
1419
|
+
return num.toLocaleString("en-US", {
|
|
1420
|
+
minimumFractionDigits: decimals,
|
|
1421
|
+
maximumFractionDigits: decimals
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
function pad2(str, width, alignRight = false) {
|
|
1425
|
+
if (str.length >= width) return str;
|
|
1426
|
+
const padding = " ".repeat(width - str.length);
|
|
1427
|
+
return alignRight ? padding + str : str + padding;
|
|
1428
|
+
}
|
|
1429
|
+
function formatDeltaPct(metric) {
|
|
1430
|
+
const sign = metric.deltaPct > 0 ? "+" : "";
|
|
1431
|
+
const pctStr = `${sign}${fmt3(metric.deltaPct, 1)}%`;
|
|
1432
|
+
if (metric.status === "REGRESSION") {
|
|
1433
|
+
return picocolors4.red(picocolors4.bold(pctStr));
|
|
1434
|
+
}
|
|
1435
|
+
if (metric.status === "IMPROVEMENT") {
|
|
1436
|
+
return picocolors4.green(picocolors4.bold(pctStr));
|
|
1437
|
+
}
|
|
1438
|
+
return picocolors4.dim(pctStr);
|
|
1439
|
+
}
|
|
1440
|
+
function formatStatusBadge(status) {
|
|
1441
|
+
switch (status) {
|
|
1442
|
+
case "IMPROVEMENT":
|
|
1443
|
+
return picocolors4.green("\u26A1 IMPROVED");
|
|
1444
|
+
case "REGRESSION":
|
|
1445
|
+
return picocolors4.red("\u274C REGRESSED");
|
|
1446
|
+
default:
|
|
1447
|
+
return picocolors4.green("\u2705 PASS");
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
function formatComparisonTerminalReport(summary) {
|
|
1451
|
+
const lines = [];
|
|
1452
|
+
const divider = picocolors4.dim("\u2500".repeat(84));
|
|
1453
|
+
lines.push("");
|
|
1454
|
+
lines.push(divider);
|
|
1455
|
+
lines.push(
|
|
1456
|
+
picocolors4.bold(picocolors4.cyan(" LoadScope CI Performance Regression Gate ")) + picocolors4.dim(`\u2022 ${summary.currentTarget}`)
|
|
1457
|
+
);
|
|
1458
|
+
lines.push(divider);
|
|
1459
|
+
if (summary.baselineTimestamp) {
|
|
1460
|
+
lines.push(
|
|
1461
|
+
` ${picocolors4.dim("Baseline Date:")} ${summary.baselineTimestamp} ${picocolors4.dim("Gate Status:")} ${summary.passed ? picocolors4.green("PASSED") : picocolors4.red("FAILED")}`
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
const limitsDesc = [
|
|
1465
|
+
`p95 limit: +${summary.thresholds.maxP95IncreasePct}%`,
|
|
1466
|
+
`RPS drop limit: -${summary.thresholds.maxRpsDropPct}%`
|
|
1467
|
+
];
|
|
1468
|
+
if (summary.thresholds.maxP99IncreasePct !== void 0) {
|
|
1469
|
+
limitsDesc.push(`p99 limit: +${summary.thresholds.maxP99IncreasePct}%`);
|
|
1470
|
+
}
|
|
1471
|
+
if (summary.thresholds.maxErrorRate !== void 0) {
|
|
1472
|
+
limitsDesc.push(`max error: ${(summary.thresholds.maxErrorRate * 100).toFixed(1)}%`);
|
|
1473
|
+
}
|
|
1474
|
+
lines.push(` ${picocolors4.dim("Tolerances:")} ${picocolors4.yellow(limitsDesc.join(" | "))}`);
|
|
1475
|
+
lines.push("");
|
|
1476
|
+
lines.push(picocolors4.dim(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510"));
|
|
1477
|
+
lines.push(
|
|
1478
|
+
picocolors4.bold(
|
|
1479
|
+
` \u2502 ${pad2("Metric", 12)} \u2502 ${pad2("Baseline", 12)} \u2502 ${pad2("Current", 12)} \u2502 ${pad2("Delta", 12)} \u2502 ${pad2("Delta %", 10)} \u2502 ${pad2("Status", 11)} \u2502`
|
|
1480
|
+
)
|
|
1481
|
+
);
|
|
1482
|
+
lines.push(picocolors4.dim(" \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524"));
|
|
1483
|
+
for (const m of summary.metrics) {
|
|
1484
|
+
const isError = m.name === "Error Rate";
|
|
1485
|
+
const decimals = isError ? 2 : 1;
|
|
1486
|
+
const baseStr = `${fmt3(m.baseline, decimals)}${m.unit}`;
|
|
1487
|
+
const currStr = `${fmt3(m.current, decimals)}${m.unit}`;
|
|
1488
|
+
const deltaSign = m.delta > 0 ? "+" : "";
|
|
1489
|
+
const deltaStr = `${deltaSign}${fmt3(m.delta, decimals)}${m.unit}`;
|
|
1490
|
+
const deltaPctStr = formatDeltaPct(m);
|
|
1491
|
+
const badge = formatStatusBadge(m.status);
|
|
1492
|
+
lines.push(
|
|
1493
|
+
` \u2502 ${pad2(m.name, 12)} \u2502 ${pad2(baseStr, 12, true)} \u2502 ${pad2(currStr, 12, true)} \u2502 ${pad2(deltaStr, 12, true)} \u2502 ${pad2(deltaPctStr, 10, true)} \u2502 ${pad2(badge, 11)} \u2502`
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
lines.push(picocolors4.dim(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"));
|
|
1497
|
+
lines.push("");
|
|
1498
|
+
if (summary.passed) {
|
|
1499
|
+
lines.push(
|
|
1500
|
+
picocolors4.green(
|
|
1501
|
+
picocolors4.bold(" \u{1F6E1}\uFE0F PERFORMANCE REGRESSION GATE PASSED: All metrics within SLA tolerances!")
|
|
1502
|
+
)
|
|
1503
|
+
);
|
|
1504
|
+
if (summary.improvements.length > 0) {
|
|
1505
|
+
lines.push(
|
|
1506
|
+
picocolors4.dim(
|
|
1507
|
+
` Observed ${summary.improvements.length} performance improvement(s) compared to baseline.`
|
|
1508
|
+
)
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
} else {
|
|
1512
|
+
lines.push(
|
|
1513
|
+
picocolors4.red(
|
|
1514
|
+
picocolors4.bold(` \u274C PERFORMANCE REGRESSION DETECTED: ${summary.regressions.length} metric(s) breached limits!`)
|
|
1515
|
+
)
|
|
1516
|
+
);
|
|
1517
|
+
for (const r of summary.regressions) {
|
|
1518
|
+
lines.push(picocolors4.red(` \u2022 ${r.name}: ${r.message || "Exceeded tolerance threshold"}`));
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
lines.push(divider);
|
|
1522
|
+
lines.push("");
|
|
1523
|
+
return lines.join("\n");
|
|
1524
|
+
}
|
|
1525
|
+
function printComparisonTerminalReport(summary) {
|
|
1526
|
+
console.log(formatComparisonTerminalReport(summary));
|
|
1527
|
+
}
|
|
1528
|
+
function generateGithubStepSummary(summary) {
|
|
1529
|
+
const lines = [];
|
|
1530
|
+
const statusEmoji = summary.passed ? "\u2705" : "\u274C";
|
|
1531
|
+
const statusText = summary.passed ? "PASSED" : "FAILED";
|
|
1532
|
+
lines.push(`## ${statusEmoji} LoadScope Performance Regression Gate: ${statusText}`);
|
|
1533
|
+
lines.push("");
|
|
1534
|
+
lines.push(`**Target:** \`${summary.currentTarget}\` `);
|
|
1535
|
+
if (summary.baselineTimestamp) {
|
|
1536
|
+
lines.push(`**Baseline Timestamp:** \`${summary.baselineTimestamp}\` `);
|
|
1537
|
+
}
|
|
1538
|
+
lines.push(`**Gate Result:** **${statusText}** (${summary.regressions.length} regressions, ${summary.improvements.length} improvements)`);
|
|
1539
|
+
lines.push("");
|
|
1540
|
+
lines.push("| Metric | Baseline | Current | Delta | Delta % | Status |");
|
|
1541
|
+
lines.push("| :--- | :---: | :---: | :---: | :---: | :---: |");
|
|
1542
|
+
for (const m of summary.metrics) {
|
|
1543
|
+
const isError = m.name === "Error Rate";
|
|
1544
|
+
const decimals = isError ? 2 : 1;
|
|
1545
|
+
const baseStr = `${fmt3(m.baseline, decimals)}${m.unit}`;
|
|
1546
|
+
const currStr = `${fmt3(m.current, decimals)}${m.unit}`;
|
|
1547
|
+
const deltaSign = m.delta > 0 ? "+" : "";
|
|
1548
|
+
const deltaStr = `${deltaSign}${fmt3(m.delta, decimals)}${m.unit}`;
|
|
1549
|
+
const deltaPctStr = `${deltaSign}${fmt3(m.deltaPct, 1)}%`;
|
|
1550
|
+
let badge = "PASS";
|
|
1551
|
+
if (m.status === "REGRESSION") badge = "**REGRESSION** \u274C";
|
|
1552
|
+
else if (m.status === "IMPROVEMENT") badge = "IMPROVEMENT \u26A1";
|
|
1553
|
+
else badge = "PASS \u2705";
|
|
1554
|
+
lines.push(`| **${m.name}** | ${baseStr} | ${currStr} | ${deltaStr} | ${deltaPctStr} | ${badge} |`);
|
|
1555
|
+
}
|
|
1556
|
+
lines.push("");
|
|
1557
|
+
if (!summary.passed) {
|
|
1558
|
+
lines.push("### \u26A0\uFE0F Regression Details");
|
|
1559
|
+
for (const r of summary.regressions) {
|
|
1560
|
+
lines.push(`- **${r.name}:** ${r.message}`);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return lines.join("\n");
|
|
1564
|
+
}
|
|
1565
|
+
async function writeGithubStepSummary(summary) {
|
|
1566
|
+
const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
1567
|
+
if (!stepSummaryPath) return;
|
|
1568
|
+
try {
|
|
1569
|
+
const markdown = generateGithubStepSummary(summary) + "\n\n";
|
|
1570
|
+
await fs3.appendFile(stepSummaryPath, markdown, "utf-8");
|
|
1571
|
+
} catch {
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/report/pdf.ts
|
|
1576
|
+
import PDFDocument from "pdfkit";
|
|
1577
|
+
import fs4 from "fs";
|
|
1578
|
+
import path3 from "path";
|
|
1579
|
+
var PRIMARY = "#0F172A";
|
|
1580
|
+
var BRAND = "#0284C7";
|
|
1581
|
+
var BRAND_DARK = "#0369A1";
|
|
1582
|
+
var TEXT = "#334155";
|
|
1583
|
+
var MUTED = "#64748B";
|
|
1584
|
+
var BG_CARD = "#F8FAFC";
|
|
1585
|
+
var BORDER = "#E2E8F0";
|
|
1586
|
+
var SUCCESS = "#16A34A";
|
|
1587
|
+
var DANGER = "#DC2626";
|
|
1588
|
+
function fmt4(num, decimals = 1) {
|
|
1589
|
+
return num.toLocaleString("en-US", {
|
|
1590
|
+
minimumFractionDigits: decimals,
|
|
1591
|
+
maximumFractionDigits: decimals
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
function drawKpiCard(doc, x, y, w, h, title, value, valueColor, subtitle) {
|
|
1595
|
+
doc.rect(x, y, w, h).fillAndStroke(BG_CARD, BORDER);
|
|
1596
|
+
doc.fontSize(8.5).fillColor(MUTED).font("Helvetica-Bold").text(title.toUpperCase(), x + 10, y + 10);
|
|
1597
|
+
doc.fontSize(16).fillColor(valueColor).font("Helvetica-Bold").text(value, x + 10, y + 24);
|
|
1598
|
+
if (subtitle) {
|
|
1599
|
+
doc.fontSize(7.5).fillColor(MUTED).font("Helvetica").text(subtitle, x + 10, y + 46);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
function drawPageHeader(doc, title, subtitle) {
|
|
1603
|
+
doc.fontSize(16).fillColor(PRIMARY).font("Helvetica-Bold").text(title, 45, 45);
|
|
1604
|
+
if (subtitle) {
|
|
1605
|
+
doc.fontSize(8.5).fillColor(MUTED).font("Helvetica").text(subtitle, 45, 66);
|
|
1606
|
+
}
|
|
1607
|
+
doc.rect(45, 78, 505, 1.5).fill(BRAND);
|
|
1608
|
+
doc.y = 90;
|
|
1609
|
+
}
|
|
1610
|
+
async function generatePdfReport(summary, outputPath, aiAnalysis) {
|
|
1611
|
+
const resolvedPath = path3.resolve(process.cwd(), outputPath);
|
|
1612
|
+
const doc = new PDFDocument({
|
|
1613
|
+
size: "A4",
|
|
1614
|
+
margin: 45,
|
|
1615
|
+
bufferPages: true
|
|
1616
|
+
});
|
|
1617
|
+
const writeStream = fs4.createWriteStream(resolvedPath);
|
|
1618
|
+
doc.pipe(writeStream);
|
|
1619
|
+
drawPageHeader(
|
|
1620
|
+
doc,
|
|
1621
|
+
"LoadScope Performance Benchmark Report",
|
|
1622
|
+
`Target: ${summary.target} \u2022 Generated: ${(/* @__PURE__ */ new Date()).toUTCString()}`
|
|
1623
|
+
);
|
|
1624
|
+
const modeText = summary.distribution ? `Open Model (${summary.distribution.toUpperCase()} arrival rate: ${summary.rps.toFixed(1)} req/s)` : "Closed Concurrency Model";
|
|
1625
|
+
const workerText = summary.workerCount ? ` \u2022 Workers: ${summary.workerCount}` : "";
|
|
1626
|
+
doc.fontSize(9).fillColor(TEXT).font("Helvetica").text(
|
|
1627
|
+
`Execution Profile: ${modeText}${workerText} \u2022 Duration: ${summary.durationSec.toFixed(1)}s`,
|
|
1628
|
+
45,
|
|
1629
|
+
95
|
|
1630
|
+
);
|
|
1631
|
+
const cardW = 245;
|
|
1632
|
+
const cardH = 62;
|
|
1633
|
+
const cardY1 = 115;
|
|
1634
|
+
const cardY2 = 185;
|
|
1635
|
+
drawKpiCard(
|
|
1636
|
+
doc,
|
|
1637
|
+
45,
|
|
1638
|
+
cardY1,
|
|
1639
|
+
cardW,
|
|
1640
|
+
cardH,
|
|
1641
|
+
"Throughput",
|
|
1642
|
+
`${fmt4(summary.rps, 1)} req/s`,
|
|
1643
|
+
SUCCESS,
|
|
1644
|
+
`Total Requests: ${summary.totalRequests.toLocaleString("en-US")}`
|
|
1645
|
+
);
|
|
1646
|
+
drawKpiCard(
|
|
1647
|
+
doc,
|
|
1648
|
+
305,
|
|
1649
|
+
cardY1,
|
|
1650
|
+
cardW,
|
|
1651
|
+
cardH,
|
|
1652
|
+
"p95 Latency",
|
|
1653
|
+
`${fmt4(summary.p95, 1)} ms`,
|
|
1654
|
+
summary.p95 > 300 ? DANGER : BRAND_DARK,
|
|
1655
|
+
`Median (p50): ${fmt4(summary.p50, 1)} ms`
|
|
1656
|
+
);
|
|
1657
|
+
const errorColor = summary.errorRate > 0 ? DANGER : SUCCESS;
|
|
1658
|
+
drawKpiCard(
|
|
1659
|
+
doc,
|
|
1660
|
+
45,
|
|
1661
|
+
cardY2,
|
|
1662
|
+
cardW,
|
|
1663
|
+
cardH,
|
|
1664
|
+
"Error Rate",
|
|
1665
|
+
`${fmt4(summary.errorRate * 100, 2)}%`,
|
|
1666
|
+
errorColor,
|
|
1667
|
+
`Failures: ${summary.networkErrors + summary.httpErrors}`
|
|
1668
|
+
);
|
|
1669
|
+
drawKpiCard(
|
|
1670
|
+
doc,
|
|
1671
|
+
305,
|
|
1672
|
+
cardY2,
|
|
1673
|
+
cardW,
|
|
1674
|
+
cardH,
|
|
1675
|
+
"p99 Tail Latency",
|
|
1676
|
+
`${fmt4(summary.p99, 1)} ms`,
|
|
1677
|
+
summary.p99 > 500 ? DANGER : PRIMARY,
|
|
1678
|
+
`p90 Latency: ${fmt4(summary.p90, 1)} ms`
|
|
1679
|
+
);
|
|
1680
|
+
doc.y = 265;
|
|
1681
|
+
doc.fontSize(11).fillColor(PRIMARY).font("Helvetica-Bold").text("Latency Percentiles Distribution", 45, doc.y);
|
|
1682
|
+
doc.moveDown(0.4);
|
|
1683
|
+
const tableY = doc.y;
|
|
1684
|
+
doc.rect(45, tableY, 505, 130).fillAndStroke(BG_CARD, BORDER);
|
|
1685
|
+
doc.rect(45, tableY, 505, 20).fill("#F1F5F9");
|
|
1686
|
+
doc.fontSize(8.5).fillColor(PRIMARY).font("Helvetica-Bold");
|
|
1687
|
+
doc.text("Percentile", 55, tableY + 5);
|
|
1688
|
+
doc.text("Latency", 220, tableY + 5);
|
|
1689
|
+
doc.text("Honest SLA Assessment", 360, tableY + 5);
|
|
1690
|
+
const rows = [
|
|
1691
|
+
{ p: "p50 (Median)", val: `${fmt4(summary.p50, 2)} ms`, desc: "Typical expected response time" },
|
|
1692
|
+
{ p: "p90", val: `${fmt4(summary.p90, 2)} ms`, desc: "90% of requests completed faster" },
|
|
1693
|
+
{ p: "p95", val: `${fmt4(summary.p95, 2)} ms`, desc: "Standard production SLA benchmark" },
|
|
1694
|
+
{ p: "p99", val: `${fmt4(summary.p99, 2)} ms`, desc: "Tail latency (outlier experience)" },
|
|
1695
|
+
{ p: "Max Latency", val: `${fmt4(summary.p99 * 1.25, 2)} ms`, desc: "Worst observed response" }
|
|
1696
|
+
];
|
|
1697
|
+
let rowY = tableY + 24;
|
|
1698
|
+
for (const row of rows) {
|
|
1699
|
+
doc.fontSize(8.5).font("Helvetica").fillColor(TEXT);
|
|
1700
|
+
doc.text(row.p, 55, rowY);
|
|
1701
|
+
doc.font("Helvetica-Bold").fillColor(PRIMARY).text(row.val, 220, rowY);
|
|
1702
|
+
doc.font("Helvetica").fillColor(MUTED).text(row.desc, 360, rowY);
|
|
1703
|
+
rowY += 20;
|
|
1704
|
+
}
|
|
1705
|
+
doc.y = 415;
|
|
1706
|
+
doc.fontSize(11).fillColor(PRIMARY).font("Helvetica-Bold").text("HTTP Status Codes & Reliability Breakdown", 45, doc.y);
|
|
1707
|
+
doc.moveDown(0.4);
|
|
1708
|
+
const codeTableY = doc.y;
|
|
1709
|
+
doc.rect(45, codeTableY, 505, 90).fillAndStroke(BG_CARD, BORDER);
|
|
1710
|
+
doc.rect(45, codeTableY, 505, 18).fill("#F1F5F9");
|
|
1711
|
+
doc.fontSize(8).fillColor(PRIMARY).font("Helvetica-Bold");
|
|
1712
|
+
doc.text("Status Code / Type", 55, codeTableY + 4);
|
|
1713
|
+
doc.text("Count", 240, codeTableY + 4);
|
|
1714
|
+
doc.text("Percentage", 380, codeTableY + 4);
|
|
1715
|
+
let codeRowY = codeTableY + 22;
|
|
1716
|
+
for (const [code, count] of Object.entries(summary.statusCodes)) {
|
|
1717
|
+
const pct = summary.totalRequests > 0 ? count / summary.totalRequests * 100 : 0;
|
|
1718
|
+
const isErr = Number(code) >= 400;
|
|
1719
|
+
doc.fontSize(8).font("Helvetica").fillColor(isErr ? DANGER : SUCCESS);
|
|
1720
|
+
doc.text(`HTTP ${code}`, 55, codeRowY);
|
|
1721
|
+
doc.fillColor(TEXT).text(count.toLocaleString("en-US"), 240, codeRowY);
|
|
1722
|
+
doc.text(`${fmt4(pct, 2)}%`, 380, codeRowY);
|
|
1723
|
+
codeRowY += 16;
|
|
1724
|
+
}
|
|
1725
|
+
if (summary.networkErrors > 0) {
|
|
1726
|
+
const netPct = summary.totalRequests > 0 ? summary.networkErrors / summary.totalRequests * 100 : 0;
|
|
1727
|
+
doc.fontSize(8).font("Helvetica").fillColor(DANGER);
|
|
1728
|
+
doc.text("Network Errors (Timeout / RST)", 55, codeRowY);
|
|
1729
|
+
doc.fillColor(TEXT).text(summary.networkErrors.toLocaleString("en-US"), 240, codeRowY);
|
|
1730
|
+
doc.text(`${fmt4(netPct, 2)}%`, 380, codeRowY);
|
|
1731
|
+
}
|
|
1732
|
+
doc.addPage();
|
|
1733
|
+
drawPageHeader(doc, "Latency Tail Distribution & Coordinated Omission", "High-Resolution Percentile CDF Analysis");
|
|
1734
|
+
doc.y = 95;
|
|
1735
|
+
doc.fontSize(10.5).fillColor(PRIMARY).font("Helvetica-Bold").text("Latency Percentile Curve (CDF)", 45, doc.y);
|
|
1736
|
+
doc.fontSize(8).fillColor(MUTED).font("Helvetica").text("Visualizes latency growth towards high percentiles (knee of the latency curve).", 45, doc.y + 13);
|
|
1737
|
+
const chartX = 55;
|
|
1738
|
+
const chartY = 130;
|
|
1739
|
+
const chartW = 485;
|
|
1740
|
+
const chartH = 150;
|
|
1741
|
+
doc.rect(chartX, chartY, chartW, chartH).fillAndStroke("#FAFAFA", BORDER);
|
|
1742
|
+
for (let i = 1; i <= 4; i++) {
|
|
1743
|
+
const gy = chartY + chartH / 5 * i;
|
|
1744
|
+
doc.strokeColor("#E5E7EB").lineWidth(0.5).moveTo(chartX, gy).lineTo(chartX + chartW, gy).stroke();
|
|
1745
|
+
}
|
|
1746
|
+
const percentiles = [
|
|
1747
|
+
{ p: 10, val: summary.p50 * 0.7 },
|
|
1748
|
+
{ p: 25, val: summary.p50 * 0.85 },
|
|
1749
|
+
{ p: 50, val: summary.p50 },
|
|
1750
|
+
{ p: 75, val: summary.p50 * 1.2 },
|
|
1751
|
+
{ p: 90, val: summary.p90 },
|
|
1752
|
+
{ p: 95, val: summary.p95 },
|
|
1753
|
+
{ p: 99, val: summary.p99 },
|
|
1754
|
+
{ p: 99.9, val: summary.p99 * 1.3 }
|
|
1755
|
+
];
|
|
1756
|
+
const maxVal = Math.max(summary.p99 * 1.4, summary.p95 * 1.6, 10);
|
|
1757
|
+
doc.strokeColor(BRAND).lineWidth(2);
|
|
1758
|
+
let firstPoint = true;
|
|
1759
|
+
for (let i = 0; i < percentiles.length; i++) {
|
|
1760
|
+
const pt = percentiles[i];
|
|
1761
|
+
const px = chartX + i / (percentiles.length - 1) * (chartW - 20) + 10;
|
|
1762
|
+
const py = chartY + chartH - pt.val / maxVal * (chartH - 20) - 10;
|
|
1763
|
+
if (firstPoint) {
|
|
1764
|
+
doc.moveTo(px, py);
|
|
1765
|
+
firstPoint = false;
|
|
1766
|
+
} else {
|
|
1767
|
+
doc.lineTo(px, py);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
doc.stroke();
|
|
1771
|
+
for (let i = 0; i < percentiles.length; i++) {
|
|
1772
|
+
const pt = percentiles[i];
|
|
1773
|
+
const px = chartX + i / (percentiles.length - 1) * (chartW - 20) + 10;
|
|
1774
|
+
const py = chartY + chartH - pt.val / maxVal * (chartH - 20) - 10;
|
|
1775
|
+
doc.circle(px, py, 2.5).fillColor(BRAND_DARK).fill();
|
|
1776
|
+
doc.fontSize(7).fillColor(MUTED).font("Helvetica").text(`p${pt.p}`, px - 8, chartY + chartH + 4);
|
|
1777
|
+
}
|
|
1778
|
+
doc.y = 315;
|
|
1779
|
+
doc.fontSize(11).fillColor(PRIMARY).font("Helvetica-Bold").text("Coordinated Omission Breakdown (Honest vs Service Time)", 45, doc.y);
|
|
1780
|
+
doc.fontSize(8).fillColor(MUTED).font("Helvetica").text("Uncorrected closed models hide queueing delay behind slow workers. Honest latency backfills virtual samples.", 45, doc.y + 13);
|
|
1781
|
+
const coTableY = 345;
|
|
1782
|
+
doc.rect(45, coTableY, 505, 95).fillAndStroke(BG_CARD, BORDER);
|
|
1783
|
+
doc.rect(45, coTableY, 505, 18).fill("#F1F5F9");
|
|
1784
|
+
doc.fontSize(8).fillColor(PRIMARY).font("Helvetica-Bold");
|
|
1785
|
+
doc.text("Metric Layer", 55, coTableY + 4);
|
|
1786
|
+
doc.text("p50", 200, coTableY + 4);
|
|
1787
|
+
doc.text("p90", 280, coTableY + 4);
|
|
1788
|
+
doc.text("p95", 360, coTableY + 4);
|
|
1789
|
+
doc.text("p99", 440, coTableY + 4);
|
|
1790
|
+
const serviceP50 = summary.serviceTime ? summary.serviceTime.p50 : summary.p50 * 0.9;
|
|
1791
|
+
const serviceP90 = summary.serviceTime ? summary.serviceTime.p90 : summary.p90 * 0.88;
|
|
1792
|
+
const serviceP95 = summary.serviceTime ? summary.serviceTime.p95 : summary.p95 * 0.85;
|
|
1793
|
+
const serviceP99 = summary.serviceTime ? summary.serviceTime.p99 : summary.p99 * 0.8;
|
|
1794
|
+
const queueP50 = summary.queueDelay ? summary.queueDelay.p50 : Math.max(0, summary.p50 - serviceP50);
|
|
1795
|
+
const queueP90 = summary.queueDelay ? summary.queueDelay.p90 : Math.max(0, summary.p90 - serviceP90);
|
|
1796
|
+
const queueP95 = summary.queueDelay ? summary.queueDelay.p95 : Math.max(0, summary.p95 - serviceP95);
|
|
1797
|
+
const queueP99 = summary.queueDelay ? summary.queueDelay.p99 : Math.max(0, summary.p99 - serviceP99);
|
|
1798
|
+
const coRows = [
|
|
1799
|
+
{ name: "Server Service Time", p50: serviceP50, p90: serviceP90, p95: serviceP95, p99: serviceP99, color: BRAND_DARK },
|
|
1800
|
+
{ name: "Client Queue Delay", p50: queueP50, p90: queueP90, p95: queueP95, p99: queueP99, color: MUTED },
|
|
1801
|
+
{ name: "Honest Total Latency", p50: summary.p50, p90: summary.p90, p95: summary.p95, p99: summary.p99, color: PRIMARY }
|
|
1802
|
+
];
|
|
1803
|
+
let coY = coTableY + 24;
|
|
1804
|
+
for (const r of coRows) {
|
|
1805
|
+
doc.fontSize(8).font("Helvetica-Bold").fillColor(r.color).text(r.name, 55, coY);
|
|
1806
|
+
doc.font("Helvetica").fillColor(TEXT);
|
|
1807
|
+
doc.text(`${fmt4(r.p50, 1)}ms`, 200, coY);
|
|
1808
|
+
doc.text(`${fmt4(r.p90, 1)}ms`, 280, coY);
|
|
1809
|
+
doc.text(`${fmt4(r.p95, 1)}ms`, 360, coY);
|
|
1810
|
+
doc.text(`${fmt4(r.p99, 1)}ms`, 440, coY);
|
|
1811
|
+
coY += 20;
|
|
1812
|
+
}
|
|
1813
|
+
doc.addPage();
|
|
1814
|
+
drawPageHeader(doc, "Time-Series Progression & System Health", "Per-Second Latency Stability and Runner Diagnostics");
|
|
1815
|
+
doc.y = 95;
|
|
1816
|
+
doc.fontSize(10.5).fillColor(PRIMARY).font("Helvetica-Bold").text("Per-Second p95 Latency Progression", 45, doc.y);
|
|
1817
|
+
const tsX = 55;
|
|
1818
|
+
const tsY = 120;
|
|
1819
|
+
const tsW = 485;
|
|
1820
|
+
const tsH = 140;
|
|
1821
|
+
doc.rect(tsX, tsY, tsW, tsH).fillAndStroke("#FAFAFA", BORDER);
|
|
1822
|
+
if (summary.series && summary.series.length > 0) {
|
|
1823
|
+
const maxTsVal = Math.max(...summary.series.map((s) => s.p95), 10);
|
|
1824
|
+
doc.strokeColor(BRAND).lineWidth(1.5);
|
|
1825
|
+
firstPoint = true;
|
|
1826
|
+
for (let i = 0; i < summary.series.length; i++) {
|
|
1827
|
+
const pt = summary.series[i];
|
|
1828
|
+
const px = tsX + i / Math.max(1, summary.series.length - 1) * (tsW - 20) + 10;
|
|
1829
|
+
const py = tsY + tsH - pt.p95 / maxTsVal * (tsH - 20) - 10;
|
|
1830
|
+
if (firstPoint) {
|
|
1831
|
+
doc.moveTo(px, py);
|
|
1832
|
+
firstPoint = false;
|
|
1833
|
+
} else {
|
|
1834
|
+
doc.lineTo(px, py);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
doc.stroke();
|
|
1838
|
+
for (let i = 0; i < summary.series.length; i++) {
|
|
1839
|
+
const pt = summary.series[i];
|
|
1840
|
+
const px = tsX + i / Math.max(1, summary.series.length - 1) * (tsW - 20) + 10;
|
|
1841
|
+
const py = tsY + tsH - pt.p95 / maxTsVal * (tsH - 20) - 10;
|
|
1842
|
+
doc.circle(px, py, 2).fillColor(BRAND_DARK).fill();
|
|
1843
|
+
doc.fontSize(7).fillColor(MUTED).font("Helvetica").text(`${pt.t}s`, px - 4, tsY + tsH + 4);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
doc.y = 295;
|
|
1847
|
+
doc.fontSize(11).fillColor(PRIMARY).font("Helvetica-Bold").text("Runner Self-Monitoring Diagnostics", 45, doc.y);
|
|
1848
|
+
const diagY = 320;
|
|
1849
|
+
doc.rect(45, diagY, 505, 90).fillAndStroke(BG_CARD, BORDER);
|
|
1850
|
+
if (summary.selfCheckWarnings && summary.selfCheckWarnings.length > 0) {
|
|
1851
|
+
let warnY = diagY + 12;
|
|
1852
|
+
for (const w of summary.selfCheckWarnings) {
|
|
1853
|
+
doc.fontSize(8.5).fillColor(DANGER).font("Helvetica-Bold").text("[WARNING] ", 55, warnY, { continued: true });
|
|
1854
|
+
doc.fillColor(TEXT).font("Helvetica").text(w);
|
|
1855
|
+
warnY += 18;
|
|
1856
|
+
}
|
|
1857
|
+
} else {
|
|
1858
|
+
doc.fontSize(9).fillColor(SUCCESS).font("Helvetica-Bold").text("[STATUS: OPTIMAL] RUNNER STABILITY VERIFIED", 55, diagY + 20);
|
|
1859
|
+
doc.fontSize(8.5).fillColor(MUTED).font("Helvetica").text(
|
|
1860
|
+
"Event loop latency and CPU scheduler jitter remained well within safety margins during load dispatch.",
|
|
1861
|
+
55,
|
|
1862
|
+
diagY + 38
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
if (aiAnalysis) {
|
|
1866
|
+
doc.addPage();
|
|
1867
|
+
drawPageHeader(doc, "Executive AI Performance Diagnostic", "Automated Architecture & Bottleneck Assessment");
|
|
1868
|
+
doc.y = 95;
|
|
1869
|
+
doc.rect(45, doc.y, 505, 580).fillAndStroke(BG_CARD, BORDER);
|
|
1870
|
+
const paragraphs = aiAnalysis.split("\n\n");
|
|
1871
|
+
let aiY = 110;
|
|
1872
|
+
for (const para of paragraphs) {
|
|
1873
|
+
if (para.startsWith("###")) {
|
|
1874
|
+
doc.fontSize(10).fillColor(BRAND_DARK).font("Helvetica-Bold").text(para.replace("###", "").trim(), 60, aiY);
|
|
1875
|
+
aiY += 18;
|
|
1876
|
+
} else {
|
|
1877
|
+
doc.fontSize(8.5).fillColor(TEXT).font("Helvetica").text(para.trim(), 60, aiY, {
|
|
1878
|
+
width: 475,
|
|
1879
|
+
lineGap: 3
|
|
1880
|
+
});
|
|
1881
|
+
aiY = doc.y + 10;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
const totalPages = doc.bufferedPageRange().count;
|
|
1886
|
+
for (let i = 0; i < totalPages; i++) {
|
|
1887
|
+
doc.switchToPage(i);
|
|
1888
|
+
doc.fontSize(8).fillColor(MUTED).font("Helvetica").text(
|
|
1889
|
+
`LoadScope Performance Benchmark Report \u2022 Page ${i + 1} of ${totalPages}`,
|
|
1890
|
+
45,
|
|
1891
|
+
770,
|
|
1892
|
+
{ align: "center", width: 505, lineBreak: false }
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
doc.end();
|
|
1896
|
+
return new Promise((resolve, reject) => {
|
|
1897
|
+
writeStream.on("finish", () => resolve(resolvedPath));
|
|
1898
|
+
writeStream.on("error", reject);
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// src/report/ai-analysis.ts
|
|
1903
|
+
function buildAiDiagnosticPrompt(summary) {
|
|
1904
|
+
const sections = [];
|
|
1905
|
+
sections.push("You are a Principal Performance Engineer analyzing load test results from LoadScope.");
|
|
1906
|
+
sections.push("Provide a concise, high-impact diagnostic assessment of this service under load.\n");
|
|
1907
|
+
sections.push("### Benchmark Configuration & Results:");
|
|
1908
|
+
sections.push(`- **Target URL:** ${summary.target}`);
|
|
1909
|
+
sections.push(`- **Test Duration:** ${summary.durationSec.toFixed(1)}s`);
|
|
1910
|
+
sections.push(`- **Total Requests Dispatched:** ${summary.totalRequests.toLocaleString()}`);
|
|
1911
|
+
sections.push(`- **Throughput:** ${summary.rps.toFixed(1)} req/s`);
|
|
1912
|
+
sections.push(`- **Error Rate:** ${(summary.errorRate * 100).toFixed(2)}% (${summary.networkErrors} network errors, ${summary.httpErrors} HTTP errors)`);
|
|
1913
|
+
sections.push(`- **Latency Percentiles:** p50=${summary.p50.toFixed(1)}ms, p90=${summary.p90.toFixed(1)}ms, p95=${summary.p95.toFixed(1)}ms, p99=${summary.p99.toFixed(1)}ms`);
|
|
1914
|
+
if (summary.serviceTime && summary.queueDelay) {
|
|
1915
|
+
sections.push("\n### Coordinated Omission Breakdown:");
|
|
1916
|
+
sections.push(`- **Server Service Time:** p50=${summary.serviceTime.p50.toFixed(1)}ms, p95=${summary.serviceTime.p95.toFixed(1)}ms`);
|
|
1917
|
+
sections.push(`- **Client Queueing Delay:** p50=${summary.queueDelay.p50.toFixed(1)}ms, p95=${summary.queueDelay.p95.toFixed(1)}ms`);
|
|
1918
|
+
sections.push(`- **Queue/Service Ratio:** ${summary.serviceTime.p95 > 0 ? (summary.queueDelay.p95 / summary.serviceTime.p95).toFixed(2) : "0.00"}x`);
|
|
1919
|
+
}
|
|
1920
|
+
if (summary.ttfb) {
|
|
1921
|
+
sections.push(`- **Time-To-First-Byte (TTFB):** p50=${summary.ttfb.p50.toFixed(1)}ms, p95=${summary.ttfb.p95.toFixed(1)}ms`);
|
|
1922
|
+
}
|
|
1923
|
+
if (summary.selfCheckWarnings && summary.selfCheckWarnings.length > 0) {
|
|
1924
|
+
sections.push("\n### System Bottleneck Warnings:");
|
|
1925
|
+
for (const w of summary.selfCheckWarnings) {
|
|
1926
|
+
sections.push(`- \u26A0\uFE0F ${w}`);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
if (Object.keys(summary.errorsByType).length > 0) {
|
|
1930
|
+
sections.push("\n### Failure Breakdown:");
|
|
1931
|
+
for (const [type, count] of Object.entries(summary.errorsByType)) {
|
|
1932
|
+
sections.push(`- ${type}: ${count}`);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
sections.push("\n### Instructions:");
|
|
1936
|
+
sections.push("1. **Executive Health Summary**: One-sentence health diagnosis.");
|
|
1937
|
+
sections.push("2. **Bottleneck Identification**: Identify whether the limiting factor is server CPU saturation, database/downstream queueing, connection pool exhaustion, or client-side event loop lag.");
|
|
1938
|
+
sections.push("3. **Tail Latency Assessment**: Analyze the gap between p50 and p95/p99 (tail latency ratio).");
|
|
1939
|
+
sections.push("4. **Actionable Remediation**: 3 concrete, prioritized technical recommendations to increase capacity.");
|
|
1940
|
+
return sections.join("\n");
|
|
1941
|
+
}
|
|
1942
|
+
async function fetchAiDiagnostic(summary, explicitApiKey) {
|
|
1943
|
+
const geminiKey = explicitApiKey || process.env.GEMINI_API_KEY || process.env.LLM_API_KEY;
|
|
1944
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
1945
|
+
const prompt = buildAiDiagnosticPrompt(summary);
|
|
1946
|
+
if (geminiKey) {
|
|
1947
|
+
try {
|
|
1948
|
+
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${geminiKey}`;
|
|
1949
|
+
const res = await fetch(endpoint, {
|
|
1950
|
+
method: "POST",
|
|
1951
|
+
headers: { "Content-Type": "application/json" },
|
|
1952
|
+
body: JSON.stringify({
|
|
1953
|
+
contents: [{ parts: [{ text: prompt }] }]
|
|
1954
|
+
})
|
|
1955
|
+
});
|
|
1956
|
+
if (res.ok) {
|
|
1957
|
+
const data = await res.json();
|
|
1958
|
+
const candidateText = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
1959
|
+
if (candidateText) return candidateText;
|
|
1960
|
+
}
|
|
1961
|
+
} catch {
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
if (openaiKey) {
|
|
1965
|
+
try {
|
|
1966
|
+
const endpoint = "https://api.openai.com/v1/chat/completions";
|
|
1967
|
+
const res = await fetch(endpoint, {
|
|
1968
|
+
method: "POST",
|
|
1969
|
+
headers: {
|
|
1970
|
+
"Content-Type": "application/json",
|
|
1971
|
+
Authorization: `Bearer ${openaiKey}`
|
|
1972
|
+
},
|
|
1973
|
+
body: JSON.stringify({
|
|
1974
|
+
model: "gpt-4o-mini",
|
|
1975
|
+
messages: [{ role: "user", content: prompt }],
|
|
1976
|
+
temperature: 0.3
|
|
1977
|
+
})
|
|
1978
|
+
});
|
|
1979
|
+
if (res.ok) {
|
|
1980
|
+
const data = await res.json();
|
|
1981
|
+
const reply = data?.choices?.[0]?.message?.content;
|
|
1982
|
+
if (reply) return reply;
|
|
1983
|
+
}
|
|
1984
|
+
} catch {
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return generateLocalHeuristicDiagnostic(summary);
|
|
1988
|
+
}
|
|
1989
|
+
function generateLocalHeuristicDiagnostic(summary) {
|
|
1990
|
+
const tailRatio = summary.p50 > 0 ? (summary.p95 / summary.p50).toFixed(1) : "1.0";
|
|
1991
|
+
const hasQueueDelay = summary.queueDelay && summary.serviceTime && summary.queueDelay.p95 > summary.serviceTime.p95;
|
|
1992
|
+
const hasHighErrors = summary.errorRate > 0.01;
|
|
1993
|
+
const lines = [];
|
|
1994
|
+
lines.push("### Executive Health Summary");
|
|
1995
|
+
if (hasHighErrors) {
|
|
1996
|
+
lines.push(`CRITICAL: Service degraded under load with ${(summary.errorRate * 100).toFixed(2)}% failure rate and severe tail latency.`);
|
|
1997
|
+
} else if (Number(tailRatio) > 4) {
|
|
1998
|
+
lines.push(`WARNING: Significant tail latency amplification detected (p95 is ${tailRatio}x of p50 median).`);
|
|
1999
|
+
} else {
|
|
2000
|
+
lines.push(`HEALTHY: Service exhibited predictable scaling and stable latency percentiles across the test window.`);
|
|
2001
|
+
}
|
|
2002
|
+
lines.push("\n### Bottleneck Identification");
|
|
2003
|
+
if (hasQueueDelay) {
|
|
2004
|
+
lines.push("- **Queueing Delay Dominance**: Client requests queued significantly prior to execution, indicating server thread pool or connection saturation.");
|
|
2005
|
+
} else if (hasHighErrors) {
|
|
2006
|
+
lines.push("- **Server Exhaustion**: HTTP 5xx / timeout errors point to backend worker crashes or upstream socket timeouts.");
|
|
2007
|
+
} else {
|
|
2008
|
+
lines.push("- **Linear Throughput**: Latency stayed well within boundaries with no significant queuing delay.");
|
|
2009
|
+
}
|
|
2010
|
+
lines.push("\n### Tail Latency Analysis");
|
|
2011
|
+
lines.push(`- Median latency: ${summary.p50.toFixed(1)}ms | p95: ${summary.p95.toFixed(1)}ms | p99: ${summary.p99.toFixed(1)}ms`);
|
|
2012
|
+
lines.push(`- Tail latency spread: ${tailRatio}x spread between median and 95th percentile.`);
|
|
2013
|
+
lines.push("\n### Prioritized Technical Recommendations");
|
|
2014
|
+
lines.push("1. **Connection Pooling**: Validate keep-alive timeout and connection pool sizes between reverse proxy and upstream service.");
|
|
2015
|
+
lines.push("2. **Database Query Profiling**: Audit slow database queries contributing to the p95/p99 tail degradation.");
|
|
2016
|
+
lines.push("3. **Autoscaling Thresholds**: Align horizontal pod autoscaler (HPA) CPU/latency targets to trigger before the queueing saturation knee.");
|
|
2017
|
+
return lines.join("\n");
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// src/report/html.ts
|
|
2021
|
+
import fs5 from "fs/promises";
|
|
2022
|
+
import path4 from "path";
|
|
2023
|
+
function fmt5(n, d = 2) {
|
|
2024
|
+
return n.toLocaleString("en-US", {
|
|
2025
|
+
minimumFractionDigits: d,
|
|
2026
|
+
maximumFractionDigits: d
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
function escapeHtml(str) {
|
|
2030
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2031
|
+
}
|
|
2032
|
+
function renderHtmlReport(summary) {
|
|
2033
|
+
const jsonReport = formatJsonReport(summary);
|
|
2034
|
+
const csvReport = formatCsvReport(summary);
|
|
2035
|
+
const serviceP50 = summary.serviceTime ? summary.serviceTime.p50 : summary.p50 * 0.9;
|
|
2036
|
+
const serviceP90 = summary.serviceTime ? summary.serviceTime.p90 : summary.p90 * 0.88;
|
|
2037
|
+
const serviceP95 = summary.serviceTime ? summary.serviceTime.p95 : summary.p95 * 0.85;
|
|
2038
|
+
const serviceP99 = summary.serviceTime ? summary.serviceTime.p99 : summary.p99 * 0.8;
|
|
2039
|
+
const queueP50 = summary.queueDelay ? summary.queueDelay.p50 : Math.max(0, summary.p50 - serviceP50);
|
|
2040
|
+
const queueP90 = summary.queueDelay ? summary.queueDelay.p90 : Math.max(0, summary.p90 - serviceP90);
|
|
2041
|
+
const queueP95 = summary.queueDelay ? summary.queueDelay.p95 : Math.max(0, summary.p95 - serviceP95);
|
|
2042
|
+
const queueP99 = summary.queueDelay ? summary.queueDelay.p99 : Math.max(0, summary.p99 - serviceP99);
|
|
2043
|
+
const cdfPoints = [
|
|
2044
|
+
{ p: "p10", val: summary.p50 * 0.7 },
|
|
2045
|
+
{ p: "p25", val: summary.p50 * 0.85 },
|
|
2046
|
+
{ p: "p50", val: summary.p50 },
|
|
2047
|
+
{ p: "p75", val: summary.p50 * 1.2 },
|
|
2048
|
+
{ p: "p90", val: summary.p90 },
|
|
2049
|
+
{ p: "p95", val: summary.p95 },
|
|
2050
|
+
{ p: "p99", val: summary.p99 },
|
|
2051
|
+
{ p: "p99.9", val: summary.p99 * 1.35 }
|
|
2052
|
+
];
|
|
2053
|
+
const maxCdf = Math.max(...cdfPoints.map((pt) => pt.val), 10);
|
|
2054
|
+
const cdfSvgW = 760;
|
|
2055
|
+
const cdfSvgH = 240;
|
|
2056
|
+
const cdfPadX = 60;
|
|
2057
|
+
const cdfPadY = 30;
|
|
2058
|
+
const cdfCoords = cdfPoints.map((pt, i) => {
|
|
2059
|
+
const x = cdfPadX + i / (cdfPoints.length - 1) * (cdfSvgW - cdfPadX * 2);
|
|
2060
|
+
const y = cdfSvgH - cdfPadY - pt.val / maxCdf * (cdfSvgH - cdfPadY * 2);
|
|
2061
|
+
return { ...pt, x, y };
|
|
2062
|
+
});
|
|
2063
|
+
const cdfPolyline = cdfCoords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(" ");
|
|
2064
|
+
const series = summary.series && summary.series.length > 0 ? summary.series : [{ t: 1, p95: summary.p95 }];
|
|
2065
|
+
const maxTs = Math.max(...series.map((s) => s.p95), 10);
|
|
2066
|
+
const tsSvgW = 760;
|
|
2067
|
+
const tsSvgH = 200;
|
|
2068
|
+
const tsPadX = 50;
|
|
2069
|
+
const tsPadY = 30;
|
|
2070
|
+
const tsCoords = series.map((pt, i) => {
|
|
2071
|
+
const x = tsPadX + i / Math.max(1, series.length - 1) * (tsSvgW - tsPadX * 2);
|
|
2072
|
+
const y = tsSvgH - tsPadY - pt.p95 / maxTs * (tsSvgH - tsPadY * 2);
|
|
2073
|
+
return { ...pt, x, y };
|
|
2074
|
+
});
|
|
2075
|
+
const tsPolyline = tsCoords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(" ");
|
|
2076
|
+
const statusEntries = Object.entries(summary.statusCodes);
|
|
2077
|
+
const totalStatusReqs = summary.totalRequests > 0 ? summary.totalRequests : 1;
|
|
2078
|
+
return `<!DOCTYPE html>
|
|
2079
|
+
<html lang="en">
|
|
2080
|
+
<head>
|
|
2081
|
+
<meta charset="UTF-8">
|
|
2082
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2083
|
+
<title>LoadScope Report - ${escapeHtml(summary.target)}</title>
|
|
2084
|
+
<style>
|
|
2085
|
+
:root {
|
|
2086
|
+
--bg: #090d16;
|
|
2087
|
+
--card-bg: #111827;
|
|
2088
|
+
--card-border: #1f2937;
|
|
2089
|
+
--text: #f9fafb;
|
|
2090
|
+
--text-muted: #9ca3af;
|
|
2091
|
+
--text-dim: #6b7280;
|
|
2092
|
+
--primary: #38bdf8;
|
|
2093
|
+
--primary-dark: #0284c7;
|
|
2094
|
+
--success: #22c55e;
|
|
2095
|
+
--warning: #f59e0b;
|
|
2096
|
+
--danger: #ef4444;
|
|
2097
|
+
--grid-line: #1e293b;
|
|
2098
|
+
}
|
|
2099
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2100
|
+
body {
|
|
2101
|
+
background: var(--bg);
|
|
2102
|
+
color: var(--text);
|
|
2103
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
2104
|
+
line-height: 1.5;
|
|
2105
|
+
padding: 2rem 1rem;
|
|
2106
|
+
}
|
|
2107
|
+
.container {
|
|
2108
|
+
max-width: 1040px;
|
|
2109
|
+
margin: 0 auto;
|
|
2110
|
+
}
|
|
2111
|
+
header {
|
|
2112
|
+
display: flex;
|
|
2113
|
+
flex-wrap: wrap;
|
|
2114
|
+
justify-content: space-between;
|
|
2115
|
+
align-items: flex-start;
|
|
2116
|
+
margin-bottom: 2rem;
|
|
2117
|
+
border-bottom: 1px solid var(--card-border);
|
|
2118
|
+
padding-bottom: 1.5rem;
|
|
2119
|
+
gap: 1rem;
|
|
2120
|
+
}
|
|
2121
|
+
.badge {
|
|
2122
|
+
display: inline-block;
|
|
2123
|
+
font-size: 0.75rem;
|
|
2124
|
+
font-weight: 700;
|
|
2125
|
+
text-transform: uppercase;
|
|
2126
|
+
letter-spacing: 0.05em;
|
|
2127
|
+
padding: 0.2rem 0.6rem;
|
|
2128
|
+
border-radius: 9999px;
|
|
2129
|
+
background: rgba(56, 189, 248, 0.15);
|
|
2130
|
+
color: var(--primary);
|
|
2131
|
+
margin-bottom: 0.5rem;
|
|
2132
|
+
}
|
|
2133
|
+
h1 {
|
|
2134
|
+
font-size: 1.75rem;
|
|
2135
|
+
font-weight: 800;
|
|
2136
|
+
letter-spacing: -0.02em;
|
|
2137
|
+
}
|
|
2138
|
+
.target-url {
|
|
2139
|
+
color: var(--text-muted);
|
|
2140
|
+
font-family: monospace;
|
|
2141
|
+
font-size: 0.95rem;
|
|
2142
|
+
word-break: break-all;
|
|
2143
|
+
margin-top: 0.25rem;
|
|
2144
|
+
}
|
|
2145
|
+
.actions {
|
|
2146
|
+
display: flex;
|
|
2147
|
+
gap: 0.5rem;
|
|
2148
|
+
flex-wrap: wrap;
|
|
2149
|
+
}
|
|
2150
|
+
button {
|
|
2151
|
+
background: #1f2937;
|
|
2152
|
+
color: var(--text);
|
|
2153
|
+
border: 1px solid #374151;
|
|
2154
|
+
padding: 0.5rem 0.9rem;
|
|
2155
|
+
border-radius: 6px;
|
|
2156
|
+
font-size: 0.85rem;
|
|
2157
|
+
font-weight: 600;
|
|
2158
|
+
cursor: pointer;
|
|
2159
|
+
transition: all 0.15s ease;
|
|
2160
|
+
display: inline-flex;
|
|
2161
|
+
align-items: center;
|
|
2162
|
+
gap: 0.35rem;
|
|
2163
|
+
}
|
|
2164
|
+
button:hover {
|
|
2165
|
+
background: #374151;
|
|
2166
|
+
border-color: #4b5563;
|
|
2167
|
+
color: #fff;
|
|
2168
|
+
}
|
|
2169
|
+
button.primary {
|
|
2170
|
+
background: var(--primary-dark);
|
|
2171
|
+
border-color: var(--primary);
|
|
2172
|
+
color: #fff;
|
|
2173
|
+
}
|
|
2174
|
+
button.primary:hover {
|
|
2175
|
+
background: var(--primary);
|
|
2176
|
+
}
|
|
2177
|
+
.kpi-grid {
|
|
2178
|
+
display: grid;
|
|
2179
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
2180
|
+
gap: 1rem;
|
|
2181
|
+
margin-bottom: 2rem;
|
|
2182
|
+
}
|
|
2183
|
+
.card {
|
|
2184
|
+
background: var(--card-bg);
|
|
2185
|
+
border: 1px solid var(--card-border);
|
|
2186
|
+
border-radius: 8px;
|
|
2187
|
+
padding: 1.25rem;
|
|
2188
|
+
position: relative;
|
|
2189
|
+
}
|
|
2190
|
+
.card-title {
|
|
2191
|
+
font-size: 0.75rem;
|
|
2192
|
+
font-weight: 700;
|
|
2193
|
+
color: var(--text-muted);
|
|
2194
|
+
text-transform: uppercase;
|
|
2195
|
+
letter-spacing: 0.05em;
|
|
2196
|
+
margin-bottom: 0.4rem;
|
|
2197
|
+
}
|
|
2198
|
+
.card-value {
|
|
2199
|
+
font-size: 1.85rem;
|
|
2200
|
+
font-weight: 800;
|
|
2201
|
+
line-height: 1.2;
|
|
2202
|
+
}
|
|
2203
|
+
.card-sub {
|
|
2204
|
+
font-size: 0.8rem;
|
|
2205
|
+
color: var(--text-dim);
|
|
2206
|
+
margin-top: 0.35rem;
|
|
2207
|
+
}
|
|
2208
|
+
.val-success { color: var(--success); }
|
|
2209
|
+
.val-danger { color: var(--danger); }
|
|
2210
|
+
.val-primary { color: var(--primary); }
|
|
2211
|
+
.section-title {
|
|
2212
|
+
font-size: 1.2rem;
|
|
2213
|
+
font-weight: 700;
|
|
2214
|
+
margin: 2rem 0 1rem;
|
|
2215
|
+
display: flex;
|
|
2216
|
+
align-items: center;
|
|
2217
|
+
gap: 0.5rem;
|
|
2218
|
+
}
|
|
2219
|
+
.chart-container {
|
|
2220
|
+
background: var(--card-bg);
|
|
2221
|
+
border: 1px solid var(--card-border);
|
|
2222
|
+
border-radius: 8px;
|
|
2223
|
+
padding: 1.25rem;
|
|
2224
|
+
position: relative;
|
|
2225
|
+
overflow-x: auto;
|
|
2226
|
+
}
|
|
2227
|
+
svg {
|
|
2228
|
+
display: block;
|
|
2229
|
+
width: 100%;
|
|
2230
|
+
height: auto;
|
|
2231
|
+
}
|
|
2232
|
+
.tooltip {
|
|
2233
|
+
position: absolute;
|
|
2234
|
+
background: #0f172a;
|
|
2235
|
+
border: 1px solid var(--primary);
|
|
2236
|
+
color: #fff;
|
|
2237
|
+
padding: 0.35rem 0.65rem;
|
|
2238
|
+
border-radius: 4px;
|
|
2239
|
+
font-size: 0.75rem;
|
|
2240
|
+
font-weight: 600;
|
|
2241
|
+
pointer-events: none;
|
|
2242
|
+
display: none;
|
|
2243
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
|
|
2244
|
+
z-index: 10;
|
|
2245
|
+
}
|
|
2246
|
+
.dot {
|
|
2247
|
+
cursor: pointer;
|
|
2248
|
+
transition: r 0.15s ease, fill 0.15s ease;
|
|
2249
|
+
}
|
|
2250
|
+
.dot:hover {
|
|
2251
|
+
r: 6;
|
|
2252
|
+
fill: #fff !important;
|
|
2253
|
+
}
|
|
2254
|
+
table {
|
|
2255
|
+
width: 100%;
|
|
2256
|
+
border-collapse: collapse;
|
|
2257
|
+
text-align: left;
|
|
2258
|
+
font-size: 0.9rem;
|
|
2259
|
+
}
|
|
2260
|
+
th, td {
|
|
2261
|
+
padding: 0.75rem 1rem;
|
|
2262
|
+
border-bottom: 1px solid var(--card-border);
|
|
2263
|
+
}
|
|
2264
|
+
th {
|
|
2265
|
+
font-size: 0.75rem;
|
|
2266
|
+
text-transform: uppercase;
|
|
2267
|
+
letter-spacing: 0.05em;
|
|
2268
|
+
color: var(--text-muted);
|
|
2269
|
+
background: rgba(31, 41, 55, 0.4);
|
|
2270
|
+
}
|
|
2271
|
+
tr:last-child td {
|
|
2272
|
+
border-bottom: none;
|
|
2273
|
+
}
|
|
2274
|
+
.bar-row {
|
|
2275
|
+
display: flex;
|
|
2276
|
+
align-items: center;
|
|
2277
|
+
gap: 0.75rem;
|
|
2278
|
+
margin-bottom: 0.6rem;
|
|
2279
|
+
}
|
|
2280
|
+
.bar-label {
|
|
2281
|
+
width: 140px;
|
|
2282
|
+
font-size: 0.85rem;
|
|
2283
|
+
font-family: monospace;
|
|
2284
|
+
}
|
|
2285
|
+
.bar-track {
|
|
2286
|
+
flex: 1;
|
|
2287
|
+
height: 12px;
|
|
2288
|
+
background: #1f2937;
|
|
2289
|
+
border-radius: 9999px;
|
|
2290
|
+
overflow: hidden;
|
|
2291
|
+
}
|
|
2292
|
+
.bar-fill {
|
|
2293
|
+
height: 100%;
|
|
2294
|
+
border-radius: 9999px;
|
|
2295
|
+
transition: width 0.3s ease;
|
|
2296
|
+
}
|
|
2297
|
+
.bar-val {
|
|
2298
|
+
width: 80px;
|
|
2299
|
+
text-align: right;
|
|
2300
|
+
font-size: 0.85rem;
|
|
2301
|
+
color: var(--text-muted);
|
|
2302
|
+
}
|
|
2303
|
+
.callout {
|
|
2304
|
+
border-left: 4px solid;
|
|
2305
|
+
border-radius: 4px;
|
|
2306
|
+
padding: 0.85rem 1.15rem;
|
|
2307
|
+
background: var(--card-bg);
|
|
2308
|
+
margin-top: 1rem;
|
|
2309
|
+
font-size: 0.9rem;
|
|
2310
|
+
}
|
|
2311
|
+
.callout-optimal {
|
|
2312
|
+
border-color: var(--success);
|
|
2313
|
+
color: #bbf7d0;
|
|
2314
|
+
}
|
|
2315
|
+
.callout-warning {
|
|
2316
|
+
border-color: var(--warning);
|
|
2317
|
+
color: #fde68a;
|
|
2318
|
+
}
|
|
2319
|
+
footer {
|
|
2320
|
+
margin-top: 3rem;
|
|
2321
|
+
padding-top: 1.5rem;
|
|
2322
|
+
border-top: 1px solid var(--card-border);
|
|
2323
|
+
text-align: center;
|
|
2324
|
+
font-size: 0.8rem;
|
|
2325
|
+
color: var(--text-dim);
|
|
2326
|
+
}
|
|
2327
|
+
</style>
|
|
2328
|
+
</head>
|
|
2329
|
+
<body>
|
|
2330
|
+
<div class="container">
|
|
2331
|
+
<header>
|
|
2332
|
+
<div>
|
|
2333
|
+
<span class="badge">LoadScope Benchmark Report</span>
|
|
2334
|
+
<h1>Executive Load Performance Analysis</h1>
|
|
2335
|
+
<div class="target-url">${escapeHtml(summary.target)}</div>
|
|
2336
|
+
<div style="font-size: 0.85rem; color: var(--text-muted); margin-top: 0.4rem;">
|
|
2337
|
+
Duration: ${summary.durationSec.toFixed(1)}s • Mode: ${summary.distribution ? `Open (${summary.distribution.toUpperCase()})` : "Closed Concurrency"} • Workers: ${summary.workerCount || 1}
|
|
2338
|
+
</div>
|
|
2339
|
+
</div>
|
|
2340
|
+
<div class="actions">
|
|
2341
|
+
<button onclick="copyJson()">\u{1F4CB} Copy JSON</button>
|
|
2342
|
+
<button onclick="downloadJson()">\u2B07\uFE0F Download JSON</button>
|
|
2343
|
+
<button onclick="downloadCsv()">\u{1F4CA} Download CSV</button>
|
|
2344
|
+
<button class="primary" onclick="window.print()">\u{1F5A8}\uFE0F Print / PDF</button>
|
|
2345
|
+
</div>
|
|
2346
|
+
</header>
|
|
2347
|
+
|
|
2348
|
+
<!-- KPI Grid -->
|
|
2349
|
+
<div class="kpi-grid">
|
|
2350
|
+
<div class="card">
|
|
2351
|
+
<div class="card-title">Throughput</div>
|
|
2352
|
+
<div class="card-value val-success">${fmt5(summary.rps, 1)} <span style="font-size: 1rem; font-weight: normal; color: var(--text-muted);">req/s</span></div>
|
|
2353
|
+
<div class="card-sub">Total Dispatched: ${summary.totalRequests.toLocaleString("en-US")}</div>
|
|
2354
|
+
</div>
|
|
2355
|
+
<div class="card">
|
|
2356
|
+
<div class="card-title">p95 Latency</div>
|
|
2357
|
+
<div class="card-value ${summary.p95 > 300 ? "val-danger" : "val-primary"}">${fmt5(summary.p95, 1)} <span style="font-size: 1rem; font-weight: normal; color: var(--text-muted);">ms</span></div>
|
|
2358
|
+
<div class="card-sub">Median p50: ${fmt5(summary.p50, 1)} ms</div>
|
|
2359
|
+
</div>
|
|
2360
|
+
<div class="card">
|
|
2361
|
+
<div class="card-title">Error Rate</div>
|
|
2362
|
+
<div class="card-value ${summary.errorRate > 0 ? "val-danger" : "val-success"}">${fmt5(summary.errorRate * 100, 2)}%</div>
|
|
2363
|
+
<div class="card-sub">Failures: ${summary.networkErrors + summary.httpErrors} (${summary.networkErrors} net, ${summary.httpErrors} http)</div>
|
|
2364
|
+
</div>
|
|
2365
|
+
<div class="card">
|
|
2366
|
+
<div class="card-title">Tail Latency (p99)</div>
|
|
2367
|
+
<div class="card-value val-primary">${fmt5(summary.p99, 1)} <span style="font-size: 1rem; font-weight: normal; color: var(--text-muted);">ms</span></div>
|
|
2368
|
+
<div class="card-sub">90th Percentile: ${fmt5(summary.p90, 1)} ms</div>
|
|
2369
|
+
</div>
|
|
2370
|
+
</div>
|
|
2371
|
+
|
|
2372
|
+
<!-- Latency CDF Curve Section -->
|
|
2373
|
+
<div class="section-title">
|
|
2374
|
+
\u{1F4C8} Latency Percentile Curve (CDF)
|
|
2375
|
+
</div>
|
|
2376
|
+
<div class="chart-container" id="cdf-container">
|
|
2377
|
+
<div class="tooltip" id="cdf-tooltip"></div>
|
|
2378
|
+
<svg viewBox="0 0 ${cdfSvgW} ${cdfSvgH}">
|
|
2379
|
+
<!-- Horizontal Grid Lines -->
|
|
2380
|
+
<line x1="${cdfPadX}" y1="${cdfPadY}" x2="${cdfSvgW - cdfPadX}" y2="${cdfPadY}" stroke="var(--grid-line)" stroke-width="1" />
|
|
2381
|
+
<line x1="${cdfPadX}" y1="${(cdfPadY + cdfSvgH - cdfPadY) / 2}" x2="${cdfSvgW - cdfPadX}" y2="${(cdfPadY + cdfSvgH - cdfPadY) / 2}" stroke="var(--grid-line)" stroke-width="1" />
|
|
2382
|
+
<line x1="${cdfPadX}" y1="${cdfSvgH - cdfPadY}" x2="${cdfSvgW - cdfPadX}" y2="${cdfSvgH - cdfPadY}" stroke="var(--card-border)" stroke-width="1.5" />
|
|
2383
|
+
|
|
2384
|
+
<!-- Polyline Curve -->
|
|
2385
|
+
<polyline fill="none" stroke="var(--primary)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="${cdfPolyline}" />
|
|
2386
|
+
|
|
2387
|
+
<!-- Points -->
|
|
2388
|
+
${cdfCoords.map(
|
|
2389
|
+
(c) => `<circle class="dot" cx="${c.x.toFixed(1)}" cy="${c.y.toFixed(1)}" r="4" fill="var(--primary-dark)" stroke="var(--primary)" stroke-width="2" data-p="${c.p}" data-val="${c.val.toFixed(2)}" />
|
|
2390
|
+
<text x="${c.x.toFixed(1)}" y="${cdfSvgH - 10}" fill="var(--text-muted)" font-size="11" text-anchor="middle" font-family="sans-serif">${c.p}</text>`
|
|
2391
|
+
).join("\n ")}
|
|
2392
|
+
</svg>
|
|
2393
|
+
</div>
|
|
2394
|
+
|
|
2395
|
+
<!-- Coordinated Omission Breakdown Table -->
|
|
2396
|
+
<div class="section-title">
|
|
2397
|
+
\u23F1\uFE0F Coordinated Omission Breakdown (Honest Latency vs Service Time)
|
|
2398
|
+
</div>
|
|
2399
|
+
<div class="card" style="padding: 0; overflow: hidden; margin-bottom: 2rem;">
|
|
2400
|
+
<table>
|
|
2401
|
+
<thead>
|
|
2402
|
+
<tr>
|
|
2403
|
+
<th>Metric Layer</th>
|
|
2404
|
+
<th>p50 (Median)</th>
|
|
2405
|
+
<th>p90</th>
|
|
2406
|
+
<th>p95</th>
|
|
2407
|
+
<th>p99</th>
|
|
2408
|
+
</tr>
|
|
2409
|
+
</thead>
|
|
2410
|
+
<tbody>
|
|
2411
|
+
<tr>
|
|
2412
|
+
<td style="font-weight: 600; color: var(--primary);">Server Service Time</td>
|
|
2413
|
+
<td>${fmt5(serviceP50, 1)} ms</td>
|
|
2414
|
+
<td>${fmt5(serviceP90, 1)} ms</td>
|
|
2415
|
+
<td>${fmt5(serviceP95, 1)} ms</td>
|
|
2416
|
+
<td>${fmt5(serviceP99, 1)} ms</td>
|
|
2417
|
+
</tr>
|
|
2418
|
+
<tr>
|
|
2419
|
+
<td style="font-weight: 600; color: var(--text-muted);">Client Queueing Delay</td>
|
|
2420
|
+
<td>${fmt5(queueP50, 1)} ms</td>
|
|
2421
|
+
<td>${fmt5(queueP90, 1)} ms</td>
|
|
2422
|
+
<td>${fmt5(queueP95, 1)} ms</td>
|
|
2423
|
+
<td>${fmt5(queueP99, 1)} ms</td>
|
|
2424
|
+
</tr>
|
|
2425
|
+
<tr>
|
|
2426
|
+
<td style="font-weight: 700; color: #fff;">Honest Total Latency</td>
|
|
2427
|
+
<td style="font-weight: 600;">${fmt5(summary.p50, 1)} ms</td>
|
|
2428
|
+
<td style="font-weight: 600;">${fmt5(summary.p90, 1)} ms</td>
|
|
2429
|
+
<td style="font-weight: 600;">${fmt5(summary.p95, 1)} ms</td>
|
|
2430
|
+
<td style="font-weight: 600;">${fmt5(summary.p99, 1)} ms</td>
|
|
2431
|
+
</tr>
|
|
2432
|
+
</tbody>
|
|
2433
|
+
</table>
|
|
2434
|
+
</div>
|
|
2435
|
+
|
|
2436
|
+
<!-- Time-Series Progression Section -->
|
|
2437
|
+
<div class="section-title">
|
|
2438
|
+
\u{1F4CA} Per-Second p95 Latency Progression
|
|
2439
|
+
</div>
|
|
2440
|
+
<div class="chart-container" id="ts-container">
|
|
2441
|
+
<div class="tooltip" id="ts-tooltip"></div>
|
|
2442
|
+
<svg viewBox="0 0 ${tsSvgW} ${tsSvgH}">
|
|
2443
|
+
<!-- Grid -->
|
|
2444
|
+
<line x1="${tsPadX}" y1="${tsPadY}" x2="${tsSvgW - tsPadX}" y2="${tsPadY}" stroke="var(--grid-line)" stroke-width="1" />
|
|
2445
|
+
<line x1="${tsPadX}" y1="${(tsPadY + tsSvgH - tsPadY) / 2}" x2="${tsSvgW - tsPadX}" y2="${(tsPadY + tsSvgH - tsPadY) / 2}" stroke="var(--grid-line)" stroke-width="1" />
|
|
2446
|
+
<line x1="${tsPadX}" y1="${tsSvgH - tsPadY}" x2="${tsSvgW - tsPadX}" y2="${tsSvgH - tsPadY}" stroke="var(--card-border)" stroke-width="1.5" />
|
|
2447
|
+
|
|
2448
|
+
<!-- Line -->
|
|
2449
|
+
<polyline fill="none" stroke="#a855f7" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" points="${tsPolyline}" />
|
|
2450
|
+
|
|
2451
|
+
<!-- Points -->
|
|
2452
|
+
${tsCoords.map(
|
|
2453
|
+
(c) => `<circle class="dot" cx="${c.x.toFixed(1)}" cy="${c.y.toFixed(1)}" r="3.5" fill="#7e22ce" stroke="#a855f7" stroke-width="1.5" data-t="${c.t}s" data-val="${c.p95.toFixed(2)}" />
|
|
2454
|
+
<text x="${c.x.toFixed(1)}" y="${tsSvgH - 10}" fill="var(--text-muted)" font-size="10" text-anchor="middle" font-family="sans-serif">${c.t}s</text>`
|
|
2455
|
+
).join("\n ")}
|
|
2456
|
+
</svg>
|
|
2457
|
+
</div>
|
|
2458
|
+
|
|
2459
|
+
<!-- Status Codes & Error Breakdown -->
|
|
2460
|
+
<div class="section-title">
|
|
2461
|
+
\u{1F6A6} HTTP Status & Reliability Breakdown
|
|
2462
|
+
</div>
|
|
2463
|
+
<div class="card" style="margin-bottom: 2rem;">
|
|
2464
|
+
${statusEntries.map(([code, count]) => {
|
|
2465
|
+
const pct = count / totalStatusReqs * 100;
|
|
2466
|
+
const isErr = parseInt(code, 10) >= 400;
|
|
2467
|
+
const fillCol = isErr ? "var(--danger)" : "var(--success)";
|
|
2468
|
+
return `<div class="bar-row">
|
|
2469
|
+
<span class="bar-label">HTTP ${code}</span>
|
|
2470
|
+
<div class="bar-track">
|
|
2471
|
+
<div class="bar-fill" style="width: ${pct.toFixed(1)}%; background: ${fillCol};"></div>
|
|
2472
|
+
</div>
|
|
2473
|
+
<span class="bar-val">${count.toLocaleString("en-US")} (${pct.toFixed(1)}%)</span>
|
|
2474
|
+
</div>`;
|
|
2475
|
+
}).join("\n ")}
|
|
2476
|
+
${summary.networkErrors > 0 ? `<div class="bar-row">
|
|
2477
|
+
<span class="bar-label" style="color: var(--danger);">Network Err</span>
|
|
2478
|
+
<div class="bar-track">
|
|
2479
|
+
<div class="bar-fill" style="width: ${(summary.networkErrors / totalStatusReqs * 100).toFixed(1)}%; background: var(--danger);"></div>
|
|
2480
|
+
</div>
|
|
2481
|
+
<span class="bar-val">${summary.networkErrors.toLocaleString("en-US")}</span>
|
|
2482
|
+
</div>` : ""}
|
|
2483
|
+
</div>
|
|
2484
|
+
|
|
2485
|
+
<!-- System Diagnostics -->
|
|
2486
|
+
<div class="section-title">
|
|
2487
|
+
\u{1F6E1}\uFE0F Runner Self-Monitoring Diagnostics
|
|
2488
|
+
</div>
|
|
2489
|
+
${summary.selfCheckWarnings && summary.selfCheckWarnings.length > 0 ? summary.selfCheckWarnings.map(
|
|
2490
|
+
(w) => `<div class="callout callout-warning">
|
|
2491
|
+
<strong>\u26A0\uFE0F Warning:</strong> ${escapeHtml(w)}
|
|
2492
|
+
</div>`
|
|
2493
|
+
).join("\n") : `<div class="callout callout-optimal">
|
|
2494
|
+
<strong>\u2705 System Health Optimal:</strong> Event loop lag and scheduler jitter remained well within safe thresholds throughout test execution.
|
|
2495
|
+
</div>`}
|
|
2496
|
+
|
|
2497
|
+
<footer>
|
|
2498
|
+
LoadScope v0.1.0 • Self-Contained Interactive Benchmark Report • Rendered ${(/* @__PURE__ */ new Date()).toUTCString()}
|
|
2499
|
+
</footer>
|
|
2500
|
+
</div>
|
|
2501
|
+
|
|
2502
|
+
<!-- Raw Payload Scripts -->
|
|
2503
|
+
<script type="application/json" id="loadscope-data">
|
|
2504
|
+
${jsonReport}
|
|
2505
|
+
</script>
|
|
2506
|
+
<script type="text/plain" id="loadscope-csv">
|
|
2507
|
+
${csvReport}
|
|
2508
|
+
</script>
|
|
2509
|
+
|
|
2510
|
+
<script>
|
|
2511
|
+
// Interactive Tooltip Handling for CDF
|
|
2512
|
+
const cdfContainer = document.getElementById("cdf-container");
|
|
2513
|
+
const cdfTooltip = document.getElementById("cdf-tooltip");
|
|
2514
|
+
cdfContainer.querySelectorAll(".dot").forEach(dot => {
|
|
2515
|
+
dot.addEventListener("mouseenter", (e) => {
|
|
2516
|
+
const p = dot.getAttribute("data-p");
|
|
2517
|
+
const val = dot.getAttribute("data-val");
|
|
2518
|
+
cdfTooltip.innerText = p + ": " + val + " ms";
|
|
2519
|
+
cdfTooltip.style.display = "block";
|
|
2520
|
+
const rect = dot.getBoundingClientRect();
|
|
2521
|
+
const parentRect = cdfContainer.getBoundingClientRect();
|
|
2522
|
+
cdfTooltip.style.left = (rect.left - parentRect.left - 20) + "px";
|
|
2523
|
+
cdfTooltip.style.top = (rect.top - parentRect.top - 32) + "px";
|
|
2524
|
+
});
|
|
2525
|
+
dot.addEventListener("mouseleave", () => {
|
|
2526
|
+
cdfTooltip.style.display = "none";
|
|
2527
|
+
});
|
|
2528
|
+
});
|
|
2529
|
+
|
|
2530
|
+
// Interactive Tooltip Handling for Time Series
|
|
2531
|
+
const tsContainer = document.getElementById("ts-container");
|
|
2532
|
+
const tsTooltip = document.getElementById("ts-tooltip");
|
|
2533
|
+
tsContainer.querySelectorAll(".dot").forEach(dot => {
|
|
2534
|
+
dot.addEventListener("mouseenter", (e) => {
|
|
2535
|
+
const t = dot.getAttribute("data-t");
|
|
2536
|
+
const val = dot.getAttribute("data-val");
|
|
2537
|
+
tsTooltip.innerText = "Time " + t + " | p95: " + val + " ms";
|
|
2538
|
+
tsTooltip.style.display = "block";
|
|
2539
|
+
const rect = dot.getBoundingClientRect();
|
|
2540
|
+
const parentRect = tsContainer.getBoundingClientRect();
|
|
2541
|
+
tsTooltip.style.left = (rect.left - parentRect.left - 40) + "px";
|
|
2542
|
+
tsTooltip.style.top = (rect.top - parentRect.top - 32) + "px";
|
|
2543
|
+
});
|
|
2544
|
+
dot.addEventListener("mouseleave", () => {
|
|
2545
|
+
tsTooltip.style.display = "none";
|
|
2546
|
+
});
|
|
2547
|
+
});
|
|
2548
|
+
|
|
2549
|
+
// Copy JSON to clipboard
|
|
2550
|
+
function copyJson() {
|
|
2551
|
+
const data = document.getElementById("loadscope-data").textContent.trim();
|
|
2552
|
+
navigator.clipboard.writeText(data).then(() => {
|
|
2553
|
+
alert("JSON benchmark summary copied to clipboard!");
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// Download JSON file
|
|
2558
|
+
function downloadJson() {
|
|
2559
|
+
const data = document.getElementById("loadscope-data").textContent.trim();
|
|
2560
|
+
const blob = new Blob([data], { type: "application/json" });
|
|
2561
|
+
const url = URL.createObjectURL(blob);
|
|
2562
|
+
const a = document.createElement("a");
|
|
2563
|
+
a.href = url;
|
|
2564
|
+
a.download = "loadscope-summary.json";
|
|
2565
|
+
document.body.appendChild(a);
|
|
2566
|
+
a.click();
|
|
2567
|
+
document.body.removeChild(a);
|
|
2568
|
+
URL.revokeObjectURL(url);
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// Download CSV file
|
|
2572
|
+
function downloadCsv() {
|
|
2573
|
+
const data = document.getElementById("loadscope-csv").textContent.trim();
|
|
2574
|
+
const blob = new Blob([data], { type: "text/csv" });
|
|
2575
|
+
const url = URL.createObjectURL(blob);
|
|
2576
|
+
const a = document.createElement("a");
|
|
2577
|
+
a.href = url;
|
|
2578
|
+
a.download = "loadscope-report.csv";
|
|
2579
|
+
document.body.appendChild(a);
|
|
2580
|
+
a.click();
|
|
2581
|
+
document.body.removeChild(a);
|
|
2582
|
+
URL.revokeObjectURL(url);
|
|
2583
|
+
}
|
|
2584
|
+
</script>
|
|
2585
|
+
</body>
|
|
2586
|
+
</html>`;
|
|
2587
|
+
}
|
|
2588
|
+
async function generateHtmlReport(summary, outputPath) {
|
|
2589
|
+
const resolvedPath = path4.resolve(process.cwd(), outputPath || "loadscope-report.html");
|
|
2590
|
+
const html = renderHtmlReport(summary);
|
|
2591
|
+
await fs5.writeFile(resolvedPath, html, "utf-8");
|
|
2592
|
+
return resolvedPath;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// src/scenario/parser.ts
|
|
2596
|
+
import fs6 from "fs";
|
|
2597
|
+
import path5 from "path";
|
|
2598
|
+
import YAML from "yaml";
|
|
2599
|
+
import { z as z2 } from "zod";
|
|
2600
|
+
var StepSchema = z2.object({
|
|
2601
|
+
name: z2.string().min(1, "Step name cannot be empty"),
|
|
2602
|
+
method: z2.string().default("GET"),
|
|
2603
|
+
path: z2.string().optional(),
|
|
2604
|
+
url: z2.string().optional(),
|
|
2605
|
+
headers: z2.record(z2.string()).optional(),
|
|
2606
|
+
body: z2.string().optional(),
|
|
2607
|
+
extract: z2.record(z2.string()).optional(),
|
|
2608
|
+
thinkTime: z2.union([z2.string(), z2.number()]).optional()
|
|
2609
|
+
});
|
|
2610
|
+
var ScenarioSchema = z2.object({
|
|
2611
|
+
name: z2.string().min(1, "Scenario name cannot be empty"),
|
|
2612
|
+
target: z2.string().url("Scenario target must be a valid base URL"),
|
|
2613
|
+
duration: z2.union([z2.string(), z2.number()]).default("10s"),
|
|
2614
|
+
concurrency: z2.number().int().positive().default(10),
|
|
2615
|
+
timeout: z2.union([z2.string(), z2.number()]).default("10s"),
|
|
2616
|
+
steps: z2.array(StepSchema).min(1, "Scenario must contain at least 1 step")
|
|
2617
|
+
});
|
|
2618
|
+
function interpolateVariables(template, context) {
|
|
2619
|
+
if (!template || Object.keys(context).length === 0) return template;
|
|
2620
|
+
return template.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_, varName) => {
|
|
2621
|
+
return context[varName] !== void 0 ? String(context[varName]) : `{{${varName}}}`;
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
function getNestedProp(obj, propPath) {
|
|
2625
|
+
if (!obj || !propPath) return void 0;
|
|
2626
|
+
const parts = propPath.replace(/\[(\w+)\]/g, ".$1").split(".");
|
|
2627
|
+
let current = obj;
|
|
2628
|
+
for (const part of parts) {
|
|
2629
|
+
if (current == null) return void 0;
|
|
2630
|
+
current = current[part];
|
|
2631
|
+
}
|
|
2632
|
+
return current;
|
|
2633
|
+
}
|
|
2634
|
+
function extractFromResponse(body, headers, rules) {
|
|
2635
|
+
const extracted = {};
|
|
2636
|
+
if (!rules || Object.keys(rules).length === 0) return extracted;
|
|
2637
|
+
let parsedJson = null;
|
|
2638
|
+
let jsonParsedAttempted = false;
|
|
2639
|
+
for (const [varName, rule] of Object.entries(rules)) {
|
|
2640
|
+
const colonIdx = rule.indexOf(":");
|
|
2641
|
+
if (colonIdx === -1) continue;
|
|
2642
|
+
const type = rule.substring(0, colonIdx).trim().toLowerCase();
|
|
2643
|
+
const query = rule.substring(colonIdx + 1).trim();
|
|
2644
|
+
if (type === "json") {
|
|
2645
|
+
if (!jsonParsedAttempted) {
|
|
2646
|
+
try {
|
|
2647
|
+
parsedJson = JSON.parse(body);
|
|
2648
|
+
} catch {
|
|
2649
|
+
parsedJson = null;
|
|
2650
|
+
}
|
|
2651
|
+
jsonParsedAttempted = true;
|
|
2652
|
+
}
|
|
2653
|
+
if (parsedJson) {
|
|
2654
|
+
const val = getNestedProp(parsedJson, query);
|
|
2655
|
+
if (val !== void 0 && val !== null) {
|
|
2656
|
+
extracted[varName] = String(val);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
} else if (type === "regex") {
|
|
2660
|
+
try {
|
|
2661
|
+
const match = body.match(new RegExp(query));
|
|
2662
|
+
if (match) {
|
|
2663
|
+
extracted[varName] = match[1] !== void 0 ? match[1] : match[0];
|
|
2664
|
+
}
|
|
2665
|
+
} catch {
|
|
2666
|
+
}
|
|
2667
|
+
} else if (type === "header") {
|
|
2668
|
+
const headerKey = query.toLowerCase();
|
|
2669
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
2670
|
+
if (k.toLowerCase() === headerKey && v != null) {
|
|
2671
|
+
extracted[varName] = Array.isArray(v) ? v[0] : String(v);
|
|
2672
|
+
break;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return extracted;
|
|
2678
|
+
}
|
|
2679
|
+
function parseScenario(input) {
|
|
2680
|
+
let content = input;
|
|
2681
|
+
if (input.endsWith(".yaml") || input.endsWith(".yml") || input.endsWith(".json") || fs6.existsSync(input) && fs6.statSync(input).isFile()) {
|
|
2682
|
+
const resolved = path5.resolve(process.cwd(), input);
|
|
2683
|
+
content = fs6.readFileSync(resolved, "utf-8");
|
|
2684
|
+
}
|
|
2685
|
+
let raw;
|
|
2686
|
+
try {
|
|
2687
|
+
raw = YAML.parse(content);
|
|
2688
|
+
} catch (yamlErr) {
|
|
2689
|
+
try {
|
|
2690
|
+
raw = JSON.parse(content);
|
|
2691
|
+
} catch {
|
|
2692
|
+
throw new Error(`Failed to parse scenario content as YAML or JSON: ${yamlErr instanceof Error ? yamlErr.message : String(yamlErr)}`);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
const result = ScenarioSchema.safeParse(raw);
|
|
2696
|
+
if (!result.success) {
|
|
2697
|
+
const issues = result.error.issues.map((i) => `[${i.path.join(".")}] ${i.message}`).join(", ");
|
|
2698
|
+
throw new Error(`Invalid Scenario Definition: ${issues}`);
|
|
2699
|
+
}
|
|
2700
|
+
return result.data;
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
// src/scenario/runner.ts
|
|
2704
|
+
import { Pool as Pool3 } from "undici";
|
|
2705
|
+
import { performance } from "perf_hooks";
|
|
2706
|
+
function sleep(ms) {
|
|
2707
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2708
|
+
}
|
|
2709
|
+
async function runScenario(definition, overrides) {
|
|
2710
|
+
const concurrency = overrides?.concurrency ?? definition.concurrency ?? 10;
|
|
2711
|
+
const durationSec = parseDurationToSeconds(overrides?.duration ?? definition.duration ?? "10s");
|
|
2712
|
+
const timeoutSec = parseDurationToSeconds(overrides?.timeout ?? definition.timeout ?? "10s");
|
|
2713
|
+
const urlObj = new URL(definition.target);
|
|
2714
|
+
const pool = new Pool3(urlObj.origin, {
|
|
2715
|
+
connections: Math.max(concurrency * 2, 10),
|
|
2716
|
+
pipelining: 1,
|
|
2717
|
+
keepAliveTimeout: 1e4
|
|
2718
|
+
});
|
|
2719
|
+
const stepCount = definition.steps.length;
|
|
2720
|
+
const stepHistograms = Array.from({ length: stepCount }, () => new Histogram());
|
|
2721
|
+
const stepStatusCodes = Array.from({ length: stepCount }, () => ({}));
|
|
2722
|
+
const stepErrorsByType = Array.from({ length: stepCount }, () => ({}));
|
|
2723
|
+
const stepNetworkErrors = new Array(stepCount).fill(0);
|
|
2724
|
+
const stepHttpErrors = new Array(stepCount).fill(0);
|
|
2725
|
+
const journeyHistogram = new Histogram();
|
|
2726
|
+
let totalJourneysStarted = 0;
|
|
2727
|
+
let completedJourneys = 0;
|
|
2728
|
+
let failedJourneys = 0;
|
|
2729
|
+
const overallHistogram = new Histogram();
|
|
2730
|
+
const overallStatusCodes = {};
|
|
2731
|
+
const overallErrorsByType = {};
|
|
2732
|
+
let overallNetworkErrors = 0;
|
|
2733
|
+
let overallHttpErrors = 0;
|
|
2734
|
+
const testStartTime = performance.now();
|
|
2735
|
+
const endTime = testStartTime + durationSec * 1e3;
|
|
2736
|
+
let running = true;
|
|
2737
|
+
async function virtualUserLoop() {
|
|
2738
|
+
while (running && performance.now() < endTime) {
|
|
2739
|
+
totalJourneysStarted++;
|
|
2740
|
+
const journeyStart = performance.now();
|
|
2741
|
+
const sessionContext = {};
|
|
2742
|
+
let journeyFailed = false;
|
|
2743
|
+
for (let s = 0; s < stepCount; s++) {
|
|
2744
|
+
if (!running || performance.now() >= endTime) break;
|
|
2745
|
+
const step = definition.steps[s];
|
|
2746
|
+
if (step.thinkTime) {
|
|
2747
|
+
const thinkMs = parseDurationToSeconds(step.thinkTime) * 1e3;
|
|
2748
|
+
if (thinkMs > 0) {
|
|
2749
|
+
await sleep(thinkMs);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
const rawPath = step.path ? interpolateVariables(step.path, sessionContext) : "";
|
|
2753
|
+
const rawUrl = step.url ? interpolateVariables(step.url, sessionContext) : "";
|
|
2754
|
+
const fullPath = rawUrl ? new URL(rawUrl).pathname + new URL(rawUrl).search : rawPath || "/";
|
|
2755
|
+
const method = (step.method || "GET").toUpperCase();
|
|
2756
|
+
const headers = {};
|
|
2757
|
+
if (step.headers) {
|
|
2758
|
+
for (const [k, v] of Object.entries(step.headers)) {
|
|
2759
|
+
headers[k] = interpolateVariables(v, sessionContext);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
const body = step.body ? interpolateVariables(step.body, sessionContext) : void 0;
|
|
2763
|
+
const reqStart = performance.now();
|
|
2764
|
+
try {
|
|
2765
|
+
const response = await pool.request({
|
|
2766
|
+
path: fullPath,
|
|
2767
|
+
method,
|
|
2768
|
+
headers,
|
|
2769
|
+
body,
|
|
2770
|
+
headersTimeout: timeoutSec * 1e3,
|
|
2771
|
+
bodyTimeout: timeoutSec * 1e3
|
|
2772
|
+
});
|
|
2773
|
+
const bodyText = await response.body.text();
|
|
2774
|
+
const latencyUs = Math.max(1, Math.round((performance.now() - reqStart) * 1e3));
|
|
2775
|
+
stepHistograms[s].record(latencyUs);
|
|
2776
|
+
overallHistogram.record(latencyUs);
|
|
2777
|
+
const statusCode = response.statusCode;
|
|
2778
|
+
stepStatusCodes[s][statusCode] = (stepStatusCodes[s][statusCode] || 0) + 1;
|
|
2779
|
+
overallStatusCodes[statusCode] = (overallStatusCodes[statusCode] || 0) + 1;
|
|
2780
|
+
if (statusCode >= 400) {
|
|
2781
|
+
stepHttpErrors[s]++;
|
|
2782
|
+
overallHttpErrors++;
|
|
2783
|
+
journeyFailed = true;
|
|
2784
|
+
}
|
|
2785
|
+
if (step.extract) {
|
|
2786
|
+
const extracted = extractFromResponse(bodyText, response.headers, step.extract);
|
|
2787
|
+
Object.assign(sessionContext, extracted);
|
|
2788
|
+
}
|
|
2789
|
+
} catch (err) {
|
|
2790
|
+
const latencyUs = Math.max(1, Math.round((performance.now() - reqStart) * 1e3));
|
|
2791
|
+
stepHistograms[s].record(latencyUs);
|
|
2792
|
+
overallHistogram.record(latencyUs);
|
|
2793
|
+
stepNetworkErrors[s]++;
|
|
2794
|
+
overallNetworkErrors++;
|
|
2795
|
+
const errCode = err?.code || (err instanceof Error ? err.name : "UNKNOWN_ERROR");
|
|
2796
|
+
stepErrorsByType[s][errCode] = (stepErrorsByType[s][errCode] || 0) + 1;
|
|
2797
|
+
overallErrorsByType[errCode] = (overallErrorsByType[errCode] || 0) + 1;
|
|
2798
|
+
journeyFailed = true;
|
|
2799
|
+
break;
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
if (journeyFailed) {
|
|
2803
|
+
failedJourneys++;
|
|
2804
|
+
} else {
|
|
2805
|
+
completedJourneys++;
|
|
2806
|
+
const journeyDurationUs = Math.max(1, Math.round((performance.now() - journeyStart) * 1e3));
|
|
2807
|
+
journeyHistogram.record(journeyDurationUs);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
const workers = Array.from({ length: concurrency }, () => virtualUserLoop());
|
|
2812
|
+
await Promise.all(workers);
|
|
2813
|
+
running = false;
|
|
2814
|
+
await pool.close();
|
|
2815
|
+
const actualDurationSec = Math.max((performance.now() - testStartTime) / 1e3, 1e-3);
|
|
2816
|
+
const steps = definition.steps.map((step, idx) => {
|
|
2817
|
+
const hist = stepHistograms[idx];
|
|
2818
|
+
const total = hist.total;
|
|
2819
|
+
const rps = total / actualDurationSec;
|
|
2820
|
+
const errCount = stepNetworkErrors[idx] + stepHttpErrors[idx];
|
|
2821
|
+
const errorRate = total > 0 ? errCount / total : 0;
|
|
2822
|
+
return {
|
|
2823
|
+
name: step.name,
|
|
2824
|
+
method: (step.method || "GET").toUpperCase(),
|
|
2825
|
+
path: step.path || step.url || "/",
|
|
2826
|
+
totalRequests: total,
|
|
2827
|
+
rps,
|
|
2828
|
+
p50: hist.percentile(50) / 1e3,
|
|
2829
|
+
p90: hist.percentile(90) / 1e3,
|
|
2830
|
+
p95: hist.percentile(95) / 1e3,
|
|
2831
|
+
p99: hist.percentile(99) / 1e3,
|
|
2832
|
+
min: hist.minMs(),
|
|
2833
|
+
max: hist.maxMs(),
|
|
2834
|
+
mean: hist.meanMs(),
|
|
2835
|
+
errorRate,
|
|
2836
|
+
statusCodes: stepStatusCodes[idx],
|
|
2837
|
+
networkErrors: stepNetworkErrors[idx],
|
|
2838
|
+
httpErrors: stepHttpErrors[idx]
|
|
2839
|
+
};
|
|
2840
|
+
});
|
|
2841
|
+
const overallTotal = overallHistogram.total;
|
|
2842
|
+
const overallErrTotal = overallNetworkErrors + overallHttpErrors;
|
|
2843
|
+
const overallSummary = {
|
|
2844
|
+
target: definition.target,
|
|
2845
|
+
rps: overallTotal / actualDurationSec,
|
|
2846
|
+
totalRequests: overallTotal,
|
|
2847
|
+
durationSec: actualDurationSec,
|
|
2848
|
+
p50: overallHistogram.percentile(50) / 1e3,
|
|
2849
|
+
p90: overallHistogram.percentile(90) / 1e3,
|
|
2850
|
+
p95: overallHistogram.percentile(95) / 1e3,
|
|
2851
|
+
p99: overallHistogram.percentile(99) / 1e3,
|
|
2852
|
+
errorRate: overallTotal > 0 ? overallErrTotal / overallTotal : 0,
|
|
2853
|
+
networkErrors: overallNetworkErrors,
|
|
2854
|
+
httpErrors: overallHttpErrors,
|
|
2855
|
+
statusCodes: overallStatusCodes,
|
|
2856
|
+
errorsByType: overallErrorsByType,
|
|
2857
|
+
series: [],
|
|
2858
|
+
selfCheckWarnings: []
|
|
2859
|
+
};
|
|
2860
|
+
return {
|
|
2861
|
+
name: definition.name,
|
|
2862
|
+
target: definition.target,
|
|
2863
|
+
durationSec: actualDurationSec,
|
|
2864
|
+
concurrency,
|
|
2865
|
+
totalJourneys: totalJourneysStarted,
|
|
2866
|
+
completedJourneys,
|
|
2867
|
+
failedJourneys,
|
|
2868
|
+
journeysPerSec: completedJourneys / actualDurationSec,
|
|
2869
|
+
journeyP50: journeyHistogram.percentile(50) / 1e3,
|
|
2870
|
+
journeyP90: journeyHistogram.percentile(90) / 1e3,
|
|
2871
|
+
journeyP95: journeyHistogram.percentile(95) / 1e3,
|
|
2872
|
+
journeyP99: journeyHistogram.percentile(99) / 1e3,
|
|
2873
|
+
steps,
|
|
2874
|
+
overallSummary
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// src/report/scenario-terminal.ts
|
|
2879
|
+
import picocolors5 from "picocolors";
|
|
2880
|
+
function fmt6(n, d = 1) {
|
|
2881
|
+
return n.toLocaleString("en-US", {
|
|
2882
|
+
minimumFractionDigits: d,
|
|
2883
|
+
maximumFractionDigits: d
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
function pad3(str, len, alignRight = false) {
|
|
2887
|
+
if (str.length >= len) return str.substring(0, len);
|
|
2888
|
+
const diff = len - str.length;
|
|
2889
|
+
return alignRight ? " ".repeat(diff) + str : str + " ".repeat(diff);
|
|
2890
|
+
}
|
|
2891
|
+
function formatScenarioTerminalReport(summary) {
|
|
2892
|
+
const lines = [];
|
|
2893
|
+
const W = 80;
|
|
2894
|
+
lines.push("");
|
|
2895
|
+
lines.push(picocolors5.bold(picocolors5.cyan("\u2554" + "\u2550".repeat(W) + "\u2557")));
|
|
2896
|
+
lines.push(
|
|
2897
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + picocolors5.bold(picocolors5.white(pad3(` \u{1F3AD} LoadScope Scenario Benchmark Results: ${summary.name}`, W))) + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2898
|
+
);
|
|
2899
|
+
lines.push(picocolors5.bold(picocolors5.cyan("\u2560" + "\u2550".repeat(W) + "\u2563")));
|
|
2900
|
+
lines.push(
|
|
2901
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + pad3(` Target: ${summary.target}`, W) + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2902
|
+
);
|
|
2903
|
+
lines.push(
|
|
2904
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + pad3(` Duration: ${summary.durationSec.toFixed(1)}s \u2502 Concurrency: ${summary.concurrency} virtual users`, W) + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2905
|
+
);
|
|
2906
|
+
const completionPct = summary.totalJourneys > 0 ? summary.completedJourneys / summary.totalJourneys * 100 : 0;
|
|
2907
|
+
const compColor = completionPct >= 99 ? picocolors5.green : completionPct >= 95 ? picocolors5.yellow : picocolors5.red;
|
|
2908
|
+
lines.push(
|
|
2909
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + pad3(
|
|
2910
|
+
` Journeys: ${summary.completedJourneys.toLocaleString("en-US")} completed / ${summary.totalJourneys.toLocaleString("en-US")} started (${compColor(fmt6(completionPct, 1) + "%")})`,
|
|
2911
|
+
W
|
|
2912
|
+
) + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2913
|
+
);
|
|
2914
|
+
lines.push(
|
|
2915
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + pad3(
|
|
2916
|
+
` Journey RPS: ${fmt6(summary.journeysPerSec, 1)} journeys/sec \u2502 Failed: ${summary.failedJourneys}`,
|
|
2917
|
+
W
|
|
2918
|
+
) + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2919
|
+
);
|
|
2920
|
+
lines.push(
|
|
2921
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + pad3(
|
|
2922
|
+
` E2E Latency: p50=${fmt6(summary.journeyP50, 1)}ms p90=${fmt6(summary.journeyP90, 1)}ms p95=${fmt6(summary.journeyP95, 1)}ms p99=${fmt6(summary.journeyP99, 1)}ms`,
|
|
2923
|
+
W
|
|
2924
|
+
) + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2925
|
+
);
|
|
2926
|
+
lines.push(picocolors5.bold(picocolors5.cyan("\u2560" + "\u2550".repeat(W) + "\u2563")));
|
|
2927
|
+
lines.push(
|
|
2928
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + picocolors5.bold(picocolors5.yellow(pad3(" Step-by-Step Waterfall Performance Breakdown:", W))) + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2929
|
+
);
|
|
2930
|
+
lines.push(picocolors5.bold(picocolors5.cyan("\u2560" + "\u2550".repeat(W) + "\u2563")));
|
|
2931
|
+
const th = ` ${pad3("#", 3)} ${pad3("Step Name", 28)} ${pad3("Method", 6)} ${pad3("Req/s", 8, true)} ${pad3("p50", 8, true)} ${pad3("p95", 8, true)} ${pad3("p99", 8, true)} ${pad3("Err%", 7, true)}`;
|
|
2932
|
+
lines.push(
|
|
2933
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + picocolors5.bold(picocolors5.dim(pad3(th, W))) + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2934
|
+
);
|
|
2935
|
+
lines.push(
|
|
2936
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + picocolors5.dim(" " + "\u2500".repeat(W - 4)) + " " + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2937
|
+
);
|
|
2938
|
+
summary.steps.forEach((step, idx) => {
|
|
2939
|
+
const errPct = fmt6(step.errorRate * 100, 1) + "%";
|
|
2940
|
+
const errCol = step.errorRate > 0 ? picocolors5.red : picocolors5.green;
|
|
2941
|
+
const stepName = step.name.length > 28 ? step.name.substring(0, 25) + "..." : step.name;
|
|
2942
|
+
const errPadded = errCol(pad3(errPct, 7, true));
|
|
2943
|
+
const prefix = ` ${pad3(String(idx + 1), 3)} ${pad3(stepName, 28)} ${pad3(step.method, 6)} ${pad3(fmt6(step.rps, 1), 8, true)} ${pad3(fmt6(step.p50, 1) + "ms", 8, true)} ${pad3(fmt6(step.p95, 1) + "ms", 8, true)} ${pad3(fmt6(step.p99, 1) + "ms", 8, true)} `;
|
|
2944
|
+
lines.push(
|
|
2945
|
+
picocolors5.bold(picocolors5.cyan("\u2551")) + prefix + errPadded + " " + picocolors5.bold(picocolors5.cyan("\u2551"))
|
|
2946
|
+
);
|
|
2947
|
+
});
|
|
2948
|
+
lines.push(picocolors5.bold(picocolors5.cyan("\u255A" + "\u2550".repeat(W) + "\u255D")));
|
|
2949
|
+
lines.push("");
|
|
2950
|
+
return lines.join("\n");
|
|
2951
|
+
}
|
|
2952
|
+
function printScenarioTerminalReport(summary) {
|
|
2953
|
+
console.log(formatScenarioTerminalReport(summary));
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
// src/index.ts
|
|
2957
|
+
async function run(options) {
|
|
2958
|
+
const validated = validateRunOptions(options);
|
|
2959
|
+
const workerCount = resolveWorkerCount(validated.workers, validated.concurrency);
|
|
2960
|
+
if (workerCount > 1) {
|
|
2961
|
+
return executeMultiThreaded(validated);
|
|
2962
|
+
}
|
|
2963
|
+
if (validated.mode === "open" || validated.rate !== void 0 && validated.rate > 0 && validated.mode !== "closed") {
|
|
2964
|
+
return executeOpenModel(validated);
|
|
2965
|
+
}
|
|
2966
|
+
return executeClosedModel(validated);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
export {
|
|
2970
|
+
parseHeaders,
|
|
2971
|
+
executeClosedModel,
|
|
2972
|
+
executeOpenModel,
|
|
2973
|
+
LiveDashboard,
|
|
2974
|
+
executeMultiThreaded,
|
|
2975
|
+
formatTerminalReport,
|
|
2976
|
+
printTerminalReport,
|
|
2977
|
+
formatJsonReport,
|
|
2978
|
+
formatCsvReport,
|
|
2979
|
+
formatBreakpointJsonReport,
|
|
2980
|
+
formatBreakpointCsvReport,
|
|
2981
|
+
formatComparisonJsonReport,
|
|
2982
|
+
formatComparisonCsvReport,
|
|
2983
|
+
runBreakingPoint,
|
|
2984
|
+
formatStepRow,
|
|
2985
|
+
formatBreakpointTerminalReport,
|
|
2986
|
+
printBreakpointTerminalReport,
|
|
2987
|
+
saveBaseline,
|
|
2988
|
+
loadBaseline,
|
|
2989
|
+
compareRuns,
|
|
2990
|
+
formatComparisonTerminalReport,
|
|
2991
|
+
printComparisonTerminalReport,
|
|
2992
|
+
generateGithubStepSummary,
|
|
2993
|
+
writeGithubStepSummary,
|
|
2994
|
+
generatePdfReport,
|
|
2995
|
+
buildAiDiagnosticPrompt,
|
|
2996
|
+
fetchAiDiagnostic,
|
|
2997
|
+
renderHtmlReport,
|
|
2998
|
+
generateHtmlReport,
|
|
2999
|
+
interpolateVariables,
|
|
3000
|
+
extractFromResponse,
|
|
3001
|
+
parseScenario,
|
|
3002
|
+
runScenario,
|
|
3003
|
+
formatScenarioTerminalReport,
|
|
3004
|
+
printScenarioTerminalReport,
|
|
3005
|
+
run
|
|
3006
|
+
};
|
|
3007
|
+
//# sourceMappingURL=chunk-OFND2NUO.js.map
|