@loadstrike/loadstrike-sdk 1.0.30201 → 1.0.31001
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/cjs/cluster.js +2417 -7
- package/dist/cjs/index.js +13 -2
- package/dist/cjs/iteration-observation-diagnostics.js +513 -0
- package/dist/cjs/iteration-observations.js +884 -0
- package/dist/cjs/load-engine-v2.js +966 -0
- package/dist/cjs/local.js +128 -30
- package/dist/cjs/reporting.js +148 -19
- package/dist/cjs/runtime.js +2514 -196
- package/dist/cjs/sink-retry-policy.js +52 -0
- package/dist/cjs/sinks.js +580 -23
- package/dist/cjs/transports.js +154 -163
- package/dist/esm/cluster.js +2386 -7
- package/dist/esm/index.js +1 -0
- package/dist/esm/iteration-observation-diagnostics.js +508 -0
- package/dist/esm/iteration-observations.js +871 -0
- package/dist/esm/load-engine-v2.js +942 -0
- package/dist/esm/local.js +128 -30
- package/dist/esm/reporting.js +148 -19
- package/dist/esm/runtime.js +2515 -197
- package/dist/esm/sink-retry-policy.js +44 -0
- package/dist/esm/sinks.js +580 -23
- package/dist/esm/transports.js +154 -163
- package/dist/types/cluster.d.ts +379 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/iteration-observation-diagnostics.d.ts +21 -0
- package/dist/types/iteration-observations.d.ts +230 -0
- package/dist/types/load-engine-v2.d.ts +147 -0
- package/dist/types/runtime.d.ts +216 -8
- package/dist/types/sink-retry-policy.d.ts +9 -0
- package/dist/types/sinks.d.ts +73 -0
- package/dist/types/transports.d.ts +6 -8
- package/package.json +3 -4
package/dist/esm/local.js
CHANGED
|
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
9
9
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
10
10
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
11
|
};
|
|
12
|
-
var _LoadStrikeLocalClient_licensingApiBaseUrl, _LoadStrikeLocalClient_signingKeyCache;
|
|
12
|
+
var _LoadStrikeLocalClient_licensingApiBaseUrl, _LoadStrikeLocalClient_signingKeyCache, _LoadStrikeLocalClient_heartbeatDrains;
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as childProcess from "node:child_process";
|
|
@@ -70,6 +70,7 @@ export class LoadStrikeLocalClient {
|
|
|
70
70
|
constructor(options = {}) {
|
|
71
71
|
_LoadStrikeLocalClient_licensingApiBaseUrl.set(this, void 0);
|
|
72
72
|
_LoadStrikeLocalClient_signingKeyCache.set(this, new Map());
|
|
73
|
+
_LoadStrikeLocalClient_heartbeatDrains.set(this, new WeakMap());
|
|
73
74
|
assertNoDisableLicenseEnforcementOption(options, "LoadStrikeLocalClient");
|
|
74
75
|
__classPrivateFieldSet(this, _LoadStrikeLocalClient_licensingApiBaseUrl, resolveLicensingApiBaseUrl(), "f");
|
|
75
76
|
this.licenseValidationTimeoutMs = normalizeTimeoutMs(options.licenseValidationTimeoutMs);
|
|
@@ -189,28 +190,59 @@ export class LoadStrikeLocalClient {
|
|
|
189
190
|
}
|
|
190
191
|
await this.verifySignedRunToken(runToken, request, requestedFeatures, runnerKey, sessionId, computedDeviceHash);
|
|
191
192
|
const heartbeatIntervalSeconds = Math.max(asInt(pickValue(json, "HeartbeatIntervalSeconds", "heartbeatIntervalSeconds")), 1);
|
|
192
|
-
const
|
|
193
|
-
void this.sendRunTokenHeartbeat({
|
|
194
|
-
runToken,
|
|
195
|
-
sessionId,
|
|
196
|
-
deviceHash: computedDeviceHash,
|
|
197
|
-
machineName,
|
|
198
|
-
environmentClassification
|
|
199
|
-
}).catch(() => {
|
|
200
|
-
// Best-effort heartbeat: server-side lease expiration is authoritative.
|
|
201
|
-
});
|
|
202
|
-
}, heartbeatIntervalSeconds * 1000);
|
|
203
|
-
if (typeof heartbeatTimer.unref === "function") {
|
|
204
|
-
heartbeatTimer.unref();
|
|
205
|
-
}
|
|
206
|
-
return {
|
|
193
|
+
const session = {
|
|
207
194
|
runToken,
|
|
208
195
|
sessionId,
|
|
209
196
|
deviceHash: computedDeviceHash,
|
|
210
197
|
machineName,
|
|
211
|
-
environmentClassification
|
|
212
|
-
heartbeatTimer
|
|
198
|
+
environmentClassification
|
|
213
199
|
};
|
|
200
|
+
let heartbeatInFlight = false;
|
|
201
|
+
const runHeartbeat = async () => {
|
|
202
|
+
heartbeatInFlight = true;
|
|
203
|
+
try {
|
|
204
|
+
const currentRunToken = stringOrDefault(session.runToken, "").trim();
|
|
205
|
+
if (!currentRunToken) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const refreshedRunToken = await this.sendRunTokenHeartbeat({
|
|
209
|
+
runToken: currentRunToken,
|
|
210
|
+
sessionId,
|
|
211
|
+
deviceHash: computedDeviceHash,
|
|
212
|
+
machineName,
|
|
213
|
+
environmentClassification
|
|
214
|
+
});
|
|
215
|
+
if (!refreshedRunToken) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
await this.verifySignedRunToken(refreshedRunToken, request, requestedFeatures, runnerKey, sessionId, computedDeviceHash);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
session.runToken = refreshedRunToken;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Best-effort heartbeat: server-side lease expiration is authoritative.
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
heartbeatInFlight = false;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
let currentHeartbeat = Promise.resolve();
|
|
234
|
+
const heartbeatTimer = setInterval(() => {
|
|
235
|
+
if (!heartbeatInFlight) {
|
|
236
|
+
currentHeartbeat = runHeartbeat();
|
|
237
|
+
}
|
|
238
|
+
return currentHeartbeat;
|
|
239
|
+
}, heartbeatIntervalSeconds * 1000);
|
|
240
|
+
if (typeof heartbeatTimer.unref === "function") {
|
|
241
|
+
heartbeatTimer.unref();
|
|
242
|
+
}
|
|
243
|
+
session.heartbeatTimer = heartbeatTimer;
|
|
244
|
+
__classPrivateFieldGet(this, _LoadStrikeLocalClient_heartbeatDrains, "f").set(session, () => currentHeartbeat);
|
|
245
|
+
return session;
|
|
214
246
|
}
|
|
215
247
|
finally {
|
|
216
248
|
clearTimeout(timer);
|
|
@@ -394,18 +426,35 @@ export class LoadStrikeLocalClient {
|
|
|
394
426
|
const timer = controller
|
|
395
427
|
? setTimeout(() => controller.abort(), this.licenseValidationTimeoutMs)
|
|
396
428
|
: null;
|
|
397
|
-
const { response } = await this.postLicensingRequest("/api/v1/licenses/heartbeat", heartbeatPayload, signal ?? controller.signal);
|
|
429
|
+
const { response, json } = await this.postLicensingRequest("/api/v1/licenses/heartbeat", heartbeatPayload, signal ?? controller.signal);
|
|
398
430
|
if (timer) {
|
|
399
431
|
clearTimeout(timer);
|
|
400
432
|
}
|
|
401
433
|
if (!response.ok) {
|
|
402
434
|
throw new Error(`Runner key validation denied. DenialCode=run_token_heartbeat_failed, Message=Run token heartbeat failed with status ${response.status}.`);
|
|
403
435
|
}
|
|
436
|
+
if (pickValue(json ?? {}, "IsValid", "isValid") !== true) {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
const refreshedRunToken = stringOrDefault(pickValue(json ?? {}, "RunToken", "runToken"), "").trim();
|
|
440
|
+
return refreshedRunToken || undefined;
|
|
404
441
|
}
|
|
405
442
|
async stopLicenseLeaseIfRequired(session, _request) {
|
|
406
443
|
if (session.heartbeatTimer) {
|
|
407
444
|
clearInterval(session.heartbeatTimer);
|
|
408
445
|
}
|
|
446
|
+
const heartbeatDrain = __classPrivateFieldGet(this, _LoadStrikeLocalClient_heartbeatDrains, "f").get(session);
|
|
447
|
+
if (heartbeatDrain) {
|
|
448
|
+
try {
|
|
449
|
+
await heartbeatDrain();
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// Heartbeats are best-effort and retain the last fully verified token.
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
__classPrivateFieldGet(this, _LoadStrikeLocalClient_heartbeatDrains, "f").delete(session);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
409
458
|
if (!session.runToken) {
|
|
410
459
|
return;
|
|
411
460
|
}
|
|
@@ -463,7 +512,7 @@ export class LoadStrikeLocalClient {
|
|
|
463
512
|
return { response, json };
|
|
464
513
|
}
|
|
465
514
|
}
|
|
466
|
-
_LoadStrikeLocalClient_licensingApiBaseUrl = new WeakMap(), _LoadStrikeLocalClient_signingKeyCache = new WeakMap();
|
|
515
|
+
_LoadStrikeLocalClient_licensingApiBaseUrl = new WeakMap(), _LoadStrikeLocalClient_signingKeyCache = new WeakMap(), _LoadStrikeLocalClient_heartbeatDrains = new WeakMap();
|
|
467
516
|
function assertNoDisableLicenseEnforcementOption(value, source) {
|
|
468
517
|
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
469
518
|
return;
|
|
@@ -1329,9 +1378,20 @@ function readTrackingId(payload, selector) {
|
|
|
1329
1378
|
return null;
|
|
1330
1379
|
}
|
|
1331
1380
|
let current = body;
|
|
1332
|
-
|
|
1381
|
+
let segments;
|
|
1382
|
+
try {
|
|
1383
|
+
segments = safeJsonPathSegments(path);
|
|
1384
|
+
}
|
|
1385
|
+
catch {
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
for (const segment of segments) {
|
|
1333
1389
|
if (current && typeof current === "object" && !Array.isArray(current)) {
|
|
1334
|
-
|
|
1390
|
+
const record = current;
|
|
1391
|
+
if (!Object.prototype.hasOwnProperty.call(record, segment)) {
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
current = record[segment];
|
|
1335
1395
|
}
|
|
1336
1396
|
else {
|
|
1337
1397
|
return null;
|
|
@@ -1364,23 +1424,57 @@ function readOptionalTrackingSelectorValue(value) {
|
|
|
1364
1424
|
return undefined;
|
|
1365
1425
|
}
|
|
1366
1426
|
function setJsonPathValue(body, path, value) {
|
|
1367
|
-
const target =
|
|
1368
|
-
|
|
1369
|
-
: {};
|
|
1370
|
-
const segments = path.split(".").filter(Boolean);
|
|
1427
|
+
const target = cloneJsonRecord(body);
|
|
1428
|
+
const segments = safeJsonPathSegments(path);
|
|
1371
1429
|
if (!segments.length) {
|
|
1372
1430
|
return target;
|
|
1373
1431
|
}
|
|
1374
1432
|
let current = target;
|
|
1375
1433
|
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
1376
1434
|
const segment = segments[i];
|
|
1377
|
-
const next = current
|
|
1435
|
+
const next = readOwnJsonProperty(current, segment);
|
|
1436
|
+
let child;
|
|
1378
1437
|
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
1379
|
-
|
|
1438
|
+
child = {};
|
|
1380
1439
|
}
|
|
1381
|
-
|
|
1440
|
+
else {
|
|
1441
|
+
child = cloneJsonRecord(next);
|
|
1442
|
+
}
|
|
1443
|
+
defineJsonProperty(current, segment, child);
|
|
1444
|
+
current = child;
|
|
1445
|
+
}
|
|
1446
|
+
defineJsonProperty(current, segments[segments.length - 1], value);
|
|
1447
|
+
return target;
|
|
1448
|
+
}
|
|
1449
|
+
const FORBIDDEN_JSON_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
1450
|
+
function safeJsonPathSegments(path) {
|
|
1451
|
+
const segments = path.split(".").filter(Boolean);
|
|
1452
|
+
const forbidden = segments.find((segment) => FORBIDDEN_JSON_PATH_SEGMENTS.has(segment));
|
|
1453
|
+
if (forbidden) {
|
|
1454
|
+
throw new Error(`Tracking selector contains forbidden JSON path segment '${forbidden}'.`);
|
|
1455
|
+
}
|
|
1456
|
+
return segments;
|
|
1457
|
+
}
|
|
1458
|
+
function defineJsonProperty(target, key, value) {
|
|
1459
|
+
Object.defineProperty(target, key, {
|
|
1460
|
+
configurable: true,
|
|
1461
|
+
enumerable: true,
|
|
1462
|
+
value,
|
|
1463
|
+
writable: true
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
function readOwnJsonProperty(target, key) {
|
|
1467
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, key);
|
|
1468
|
+
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
1469
|
+
}
|
|
1470
|
+
function cloneJsonRecord(value) {
|
|
1471
|
+
const target = {};
|
|
1472
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1473
|
+
return target;
|
|
1474
|
+
}
|
|
1475
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1476
|
+
defineJsonProperty(target, key, entry);
|
|
1382
1477
|
}
|
|
1383
|
-
current[segments[segments.length - 1]] = value;
|
|
1384
1478
|
return target;
|
|
1385
1479
|
}
|
|
1386
1480
|
function mapCorrelationStore(tracking, runNamespace) {
|
|
@@ -1827,6 +1921,10 @@ function isRuntimeReportingSink(value) {
|
|
|
1827
1921
|
"SaveRealtimeMetrics",
|
|
1828
1922
|
"saveRunResult",
|
|
1829
1923
|
"SaveRunResult",
|
|
1924
|
+
"saveIterationBatch",
|
|
1925
|
+
"SaveIterationBatch",
|
|
1926
|
+
"completeIterationObservationStream",
|
|
1927
|
+
"CompleteIterationObservationStream",
|
|
1830
1928
|
"stop",
|
|
1831
1929
|
"Stop"
|
|
1832
1930
|
].some((name) => typeof record[name] === "function");
|
package/dist/esm/reporting.js
CHANGED
|
@@ -38,8 +38,12 @@ function reportValue(source, ...keys) {
|
|
|
38
38
|
}
|
|
39
39
|
const record = source;
|
|
40
40
|
for (const key of keys) {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
|
42
|
+
if (descriptor
|
|
43
|
+
&& "value" in descriptor
|
|
44
|
+
&& descriptor.value !== undefined
|
|
45
|
+
&& descriptor.value !== null) {
|
|
46
|
+
return descriptor.value;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
return undefined;
|
|
@@ -132,6 +136,26 @@ function asFloat(value) {
|
|
|
132
136
|
const parsed = Number.parseFloat(asString(value));
|
|
133
137
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
134
138
|
}
|
|
139
|
+
function combinedMeasurement(source) {
|
|
140
|
+
const all = reportValue(source, "allMeasurement", "AllMeasurement");
|
|
141
|
+
if (all && typeof all === "object" && !Array.isArray(all)) {
|
|
142
|
+
return all;
|
|
143
|
+
}
|
|
144
|
+
const ok = reportObject(source, "ok", "Ok");
|
|
145
|
+
const fail = reportObject(source, "fail", "Fail");
|
|
146
|
+
const okCount = asInt(reportValue(reportObject(ok, "request", "Request"), "count", "Count"));
|
|
147
|
+
const failCount = asInt(reportValue(reportObject(fail, "request", "Request"), "count", "Count"));
|
|
148
|
+
if (okCount === 0) {
|
|
149
|
+
return fail;
|
|
150
|
+
}
|
|
151
|
+
return failCount === 0 ? ok : undefined;
|
|
152
|
+
}
|
|
153
|
+
function formatCombinedLatency(source, ...keys) {
|
|
154
|
+
const measurement = combinedMeasurement(source);
|
|
155
|
+
return measurement
|
|
156
|
+
? formatReportNumber(reportValue(reportObject(measurement, "latency", "Latency"), ...keys))
|
|
157
|
+
: "n/a";
|
|
158
|
+
}
|
|
135
159
|
function asBool(value) {
|
|
136
160
|
if (typeof value === "boolean") {
|
|
137
161
|
return value;
|
|
@@ -302,7 +326,22 @@ function formatDotnetDateTime(value) {
|
|
|
302
326
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${fraction}Z`;
|
|
303
327
|
}
|
|
304
328
|
function escapeJsonForHtmlScript(value) {
|
|
305
|
-
return value.
|
|
329
|
+
return value.replace(/[<>&\u2028\u2029]/g, (character) => {
|
|
330
|
+
switch (character) {
|
|
331
|
+
case "<":
|
|
332
|
+
return "\\u003c";
|
|
333
|
+
case ">":
|
|
334
|
+
return "\\u003e";
|
|
335
|
+
case "&":
|
|
336
|
+
return "\\u0026";
|
|
337
|
+
case "\u2028":
|
|
338
|
+
return "\\u2028";
|
|
339
|
+
case "\u2029":
|
|
340
|
+
return "\\u2029";
|
|
341
|
+
default:
|
|
342
|
+
return character;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
306
345
|
}
|
|
307
346
|
function buildReportLogoDataUri(resourceName) {
|
|
308
347
|
const cached = REPORT_LOGO_CACHE.get(resourceName);
|
|
@@ -747,8 +786,8 @@ function buildDotnetScenarioRows(nodeStats) {
|
|
|
747
786
|
FAIL: reportFailCountValue(scenario),
|
|
748
787
|
Duration: formatDotnetTimeSpan(reportDurationValue(scenario)),
|
|
749
788
|
RPS: formatReportNumber(reportDurationSeconds(reportDurationValue(scenario)) <= 0 ? 0 : requestCount / reportDurationSeconds(reportDurationValue(scenario))),
|
|
750
|
-
LatencyP95Ms:
|
|
751
|
-
LatencyP99Ms:
|
|
789
|
+
LatencyP95Ms: formatCombinedLatency(scenario, "percent95", "Percent95"),
|
|
790
|
+
LatencyP99Ms: formatCombinedLatency(scenario, "percent99", "Percent99"),
|
|
752
791
|
CurrentOperation: asString(reportValue(scenario, "currentOperation", "CurrentOperation"))
|
|
753
792
|
};
|
|
754
793
|
});
|
|
@@ -765,8 +804,8 @@ function buildDotnetStepRows(nodeStats) {
|
|
|
765
804
|
FAIL: asInt(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "count", "Count")),
|
|
766
805
|
OK_RPS: formatReportNumber(reportValue(reportObject(reportObject(step, "ok", "Ok"), "request", "Request"), "rps", "RPS")),
|
|
767
806
|
FAIL_RPS: formatReportNumber(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "rps", "RPS")),
|
|
768
|
-
LatencyMeanMs:
|
|
769
|
-
P95LatencyMs:
|
|
807
|
+
LatencyMeanMs: formatCombinedLatency(step, "meanMs", "MeanMs"),
|
|
808
|
+
P95LatencyMs: formatCombinedLatency(step, "percent95", "Percent95")
|
|
770
809
|
});
|
|
771
810
|
}
|
|
772
811
|
}
|
|
@@ -776,6 +815,10 @@ function buildDotnetScenarioMeasurementRows(nodeStats) {
|
|
|
776
815
|
const rows = [];
|
|
777
816
|
for (const scenario of reportScenarios(nodeStats)) {
|
|
778
817
|
const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
|
|
818
|
+
const allMeasurement = reportValue(scenario, "allMeasurement", "AllMeasurement");
|
|
819
|
+
if (allMeasurement && typeof allMeasurement === "object" && !Array.isArray(allMeasurement)) {
|
|
820
|
+
pushMeasurementRowIfData(rows, "Scenario", scenarioName, "", "ALL", allMeasurement);
|
|
821
|
+
}
|
|
779
822
|
pushMeasurementRowIfData(rows, "Scenario", scenarioName, "", "OK", reportObject(scenario, "ok", "Ok"));
|
|
780
823
|
pushMeasurementRowIfData(rows, "Scenario", scenarioName, "", "FAIL", reportObject(scenario, "fail", "Fail"));
|
|
781
824
|
}
|
|
@@ -787,6 +830,10 @@ function buildDotnetStepMeasurementRows(nodeStats) {
|
|
|
787
830
|
const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
|
|
788
831
|
for (const step of reportSteps(scenario)) {
|
|
789
832
|
const stepName = asString(reportValue(step, "stepName", "StepName"));
|
|
833
|
+
const allMeasurement = reportValue(step, "allMeasurement", "AllMeasurement");
|
|
834
|
+
if (allMeasurement && typeof allMeasurement === "object" && !Array.isArray(allMeasurement)) {
|
|
835
|
+
pushMeasurementRowIfData(rows, "Step", scenarioName, stepName, "ALL", allMeasurement);
|
|
836
|
+
}
|
|
790
837
|
pushMeasurementRowIfData(rows, "Step", scenarioName, stepName, "OK", reportObject(step, "ok", "Ok"));
|
|
791
838
|
pushMeasurementRowIfData(rows, "Step", scenarioName, stepName, "FAIL", reportObject(step, "fail", "Fail"));
|
|
792
839
|
}
|
|
@@ -867,6 +914,9 @@ function buildDotnetStatusCodeClassChart(scenarios) {
|
|
|
867
914
|
}
|
|
868
915
|
function buildDotnetChartData(nodeStats) {
|
|
869
916
|
const scenarios = reportScenarios(nodeStats);
|
|
917
|
+
const combinedScenarios = scenarios
|
|
918
|
+
.map((scenario) => ({ scenario, measurement: combinedMeasurement(scenario) }))
|
|
919
|
+
.filter((item) => item.measurement !== undefined);
|
|
870
920
|
return {
|
|
871
921
|
overallOutcome: [
|
|
872
922
|
{ label: "OK", value: reportTotalOkCount(nodeStats, scenarios), color: "#18a957" },
|
|
@@ -877,9 +927,9 @@ function buildDotnetChartData(nodeStats) {
|
|
|
877
927
|
value: reportRequestCountValue(scenario),
|
|
878
928
|
color: "#3b82f6"
|
|
879
929
|
})),
|
|
880
|
-
scenarioP95Latency:
|
|
930
|
+
scenarioP95Latency: combinedScenarios.map(({ scenario, measurement }) => ({
|
|
881
931
|
label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
|
|
882
|
-
value:
|
|
932
|
+
value: asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95")),
|
|
883
933
|
color: "#8b5cf6"
|
|
884
934
|
})),
|
|
885
935
|
scenarioRps: scenarios.map((scenario) => ({
|
|
@@ -903,12 +953,12 @@ function buildDotnetChartData(nodeStats) {
|
|
|
903
953
|
})),
|
|
904
954
|
statusCodeClasses: buildDotnetStatusCodeClassChart(scenarios),
|
|
905
955
|
scenarioLatencyTrend: {
|
|
906
|
-
labels:
|
|
956
|
+
labels: combinedScenarios.map(({ scenario }) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
|
|
907
957
|
series: [
|
|
908
|
-
{ name: "P50", color: "#38bdf8", values:
|
|
909
|
-
{ name: "P75", color: "#22c55e", values:
|
|
910
|
-
{ name: "P95", color: "#f59e0b", values:
|
|
911
|
-
{ name: "P99", color: "#f43f5e", values:
|
|
958
|
+
{ name: "P50", color: "#38bdf8", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent50", "Percent50"))) },
|
|
959
|
+
{ name: "P75", color: "#22c55e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent75", "Percent75"))) },
|
|
960
|
+
{ name: "P95", color: "#f59e0b", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95"))) },
|
|
961
|
+
{ name: "P99", color: "#f43f5e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent99", "Percent99"))) }
|
|
912
962
|
]
|
|
913
963
|
}
|
|
914
964
|
};
|
|
@@ -1053,6 +1103,82 @@ function buildDotnetThresholdHtml(nodeStats) {
|
|
|
1053
1103
|
function buildDotnetMetricHtml(nodeStats) {
|
|
1054
1104
|
return buildDotnetTableHtml(buildDotnetMetricRows(nodeStats));
|
|
1055
1105
|
}
|
|
1106
|
+
function buildDotnetGeneratorDeliveryHtml(nodeStats) {
|
|
1107
|
+
const warnings = reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings");
|
|
1108
|
+
const stats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
|
|
1109
|
+
const topLevelSegments = reportArray(nodeStats, "schedulerSegments", "SchedulerSegments");
|
|
1110
|
+
const segments = topLevelSegments.length > 0
|
|
1111
|
+
? topLevelSegments
|
|
1112
|
+
: reportArray(stats, "segments", "Segments");
|
|
1113
|
+
const observationStats = reportObject(nodeStats, "observationDeliveryStats", "ObservationDeliveryStats");
|
|
1114
|
+
const reportingCompleteValue = reportValue(nodeStats, "reportingComplete", "ReportingComplete");
|
|
1115
|
+
const reportingComplete = reportingCompleteValue == null
|
|
1116
|
+
? "N/A"
|
|
1117
|
+
: asBool(reportingCompleteValue) ? "Yes" : "No";
|
|
1118
|
+
const parts = [];
|
|
1119
|
+
appendReportLine(parts, "<div class=\"card\">");
|
|
1120
|
+
appendReportLine(parts, "<h2>Generator Delivery</h2>");
|
|
1121
|
+
appendReportLine(parts, "<p>Application failures remain separate from generator and reporting warnings. Dropped arrivals, unavailable worker slots, and raw observation loss are not synthetic SUT errors.</p>");
|
|
1122
|
+
appendReportLine(parts, "<div class=\"card-grid\">");
|
|
1123
|
+
for (const [label, value] of [
|
|
1124
|
+
["Configured Max In Flight", asInt(reportValue(stats, "configuredMaxInFlight", "ConfiguredMaxInFlight"))],
|
|
1125
|
+
["Observed Max In Flight", asInt(reportValue(stats, "maxInFlightObserved", "MaxInFlightObserved"))],
|
|
1126
|
+
["Current In Flight", asInt(reportValue(stats, "currentInFlight", "CurrentInFlight"))],
|
|
1127
|
+
["Observation Captured", asString(reportValue(observationStats, "capturedCount64", "CapturedCount64")) || "0"],
|
|
1128
|
+
["Observation Delivered", asString(reportValue(observationStats, "deliveredCount64", "DeliveredCount64")) || "0"],
|
|
1129
|
+
["Observation Buffer Drops", asString(reportValue(observationStats, "droppedBufferCount64", "DroppedBufferCount64")) || "0"],
|
|
1130
|
+
["Observation Sink Drops", asString(reportValue(observationStats, "droppedSinkCount64", "DroppedSinkCount64")) || "0"],
|
|
1131
|
+
["Reporting Complete", reportingComplete],
|
|
1132
|
+
["Warning Groups", warnings.length]
|
|
1133
|
+
]) {
|
|
1134
|
+
appendReportLine(parts, `<div class="stat-card"><div class="stat-label">${escapeHtml(label)}</div><div class="stat-value">${escapeHtml(value)}</div></div>`);
|
|
1135
|
+
}
|
|
1136
|
+
appendReportLine(parts, "</div></div>");
|
|
1137
|
+
if (warnings.length) {
|
|
1138
|
+
appendReportLine(parts, "<div class=\"card\"><h2>Generator Warnings</h2>");
|
|
1139
|
+
parts.push(buildDotnetTableHtml(warnings, false));
|
|
1140
|
+
appendReportLine(parts, "</div>");
|
|
1141
|
+
}
|
|
1142
|
+
if (segments.length) {
|
|
1143
|
+
const rows = segments.map((segment) => ({
|
|
1144
|
+
Scenario: asString(reportValue(segment, "scenarioName", "ScenarioName")),
|
|
1145
|
+
Simulation: asString(reportValue(segment, "kind", "Kind")),
|
|
1146
|
+
Shard: `${asInt(reportValue(segment, "shardIndex", "ShardIndex"))}/${Math.max(asInt(reportValue(segment, "shardCount", "ShardCount")), 1)}`,
|
|
1147
|
+
Planned: asString(reportValue(segment, "plannedIterations64", "PlannedIterations64")),
|
|
1148
|
+
Due: asString(reportValue(segment, "dueIterations64", "DueIterations64")),
|
|
1149
|
+
Started: asString(reportValue(segment, "startedIterations64", "StartedIterations64")),
|
|
1150
|
+
Completed: asString(reportValue(segment, "completedIterations64", "CompletedIterations64")),
|
|
1151
|
+
Dropped: asString(reportValue(segment, "droppedIterations64", "DroppedIterations64")),
|
|
1152
|
+
Unreached: asString(reportValue(segment, "unreachedIterations64", "UnreachedIterations64")),
|
|
1153
|
+
"Unavailable Workers": asString(reportValue(segment, "unavailableWorkerSlots64", "UnavailableWorkerSlots64")),
|
|
1154
|
+
"Delivery %": formatReportNumber(reportValue(segment, "deliveryPercent", "DeliveryPercent")),
|
|
1155
|
+
"Accounting Complete": Boolean(reportValue(segment, "accountingComplete", "AccountingComplete"))
|
|
1156
|
+
}));
|
|
1157
|
+
appendReportLine(parts, "<div class=\"card\"><h2>Scheduler Segments</h2>");
|
|
1158
|
+
parts.push(buildDotnetTableHtml(rows, false));
|
|
1159
|
+
appendReportLine(parts, "</div>");
|
|
1160
|
+
}
|
|
1161
|
+
return parts.join("");
|
|
1162
|
+
}
|
|
1163
|
+
function hasDotnetGeneratorDeliveryData(nodeStats) {
|
|
1164
|
+
const schedulerStats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
|
|
1165
|
+
const observationStats = reportObject(nodeStats, "observationDeliveryStats", "ObservationDeliveryStats");
|
|
1166
|
+
const hasNonZeroDecimal = (value) => {
|
|
1167
|
+
const text = asString(value);
|
|
1168
|
+
return text.trim().length > 0 && text !== "0";
|
|
1169
|
+
};
|
|
1170
|
+
return reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings").length > 0
|
|
1171
|
+
|| reportArray(nodeStats, "schedulerSegments", "SchedulerSegments").length > 0
|
|
1172
|
+
|| reportArray(schedulerStats, "segments", "Segments").length > 0
|
|
1173
|
+
|| asInt(reportValue(schedulerStats, "configuredMaxInFlight", "ConfiguredMaxInFlight")) > 0
|
|
1174
|
+
|| asInt(reportValue(schedulerStats, "maxInFlightObserved", "MaxInFlightObserved")) > 0
|
|
1175
|
+
|| asInt(reportValue(schedulerStats, "currentInFlight", "CurrentInFlight")) > 0
|
|
1176
|
+
|| reportValue(nodeStats, "reportingComplete", "ReportingComplete") === false
|
|
1177
|
+
|| hasNonZeroDecimal(reportValue(observationStats, "capturedCount64", "CapturedCount64"))
|
|
1178
|
+
|| hasNonZeroDecimal(reportValue(observationStats, "deliveredCount64", "DeliveredCount64"))
|
|
1179
|
+
|| hasNonZeroDecimal(reportValue(observationStats, "droppedBufferCount64", "DroppedBufferCount64"))
|
|
1180
|
+
|| hasNonZeroDecimal(reportValue(observationStats, "droppedSinkCount64", "DroppedSinkCount64"));
|
|
1181
|
+
}
|
|
1056
1182
|
function buildDotnetGroupedCorrelationSummaryHtml(rows, groupedChartKey) {
|
|
1057
1183
|
const parts = [];
|
|
1058
1184
|
const payloads = buildGroupedCorrelationChartPayloads(rows);
|
|
@@ -1129,6 +1255,9 @@ function buildDotnetHtmlTabs(nodeStats) {
|
|
|
1129
1255
|
if (metricRows.length) {
|
|
1130
1256
|
tabs.push(["metrics", "Metrics", buildDotnetTableHtml(metricRows)]);
|
|
1131
1257
|
}
|
|
1258
|
+
if (hasDotnetGeneratorDeliveryData(nodeStats)) {
|
|
1259
|
+
tabs.push(["generator-delivery", "Generator Delivery", buildDotnetGeneratorDeliveryHtml(nodeStats)]);
|
|
1260
|
+
}
|
|
1132
1261
|
for (const plugin of reportArray(nodeStats, "pluginsData", "PluginsData")) {
|
|
1133
1262
|
const pluginName = asString(reportValue(plugin, "pluginName", "PluginName"));
|
|
1134
1263
|
const hints = buildDotnetPluginHints(plugin);
|
|
@@ -1162,7 +1291,7 @@ function buildDotnetHtmlTabs(nodeStats) {
|
|
|
1162
1291
|
body = buildDotnetGroupedCorrelationSummaryHtml(bodyRows, `grouped-correlation-${tabs.length}`);
|
|
1163
1292
|
}
|
|
1164
1293
|
else if (lowerPlugin.includes("correlation") && lowerTable.includes("ungrouped correlation rows")) {
|
|
1165
|
-
title = "Ungrouped
|
|
1294
|
+
title = "Ungrouped Correlation Summary";
|
|
1166
1295
|
body = buildDotnetUngroupedCorrelationSummaryHtml(bodyRows, `ungrouped-correlation-${tabs.length}`);
|
|
1167
1296
|
}
|
|
1168
1297
|
tabs.push([`plugin-${tabs.length}`, title, `${hints}${body}`]);
|
|
@@ -1278,9 +1407,9 @@ export function buildDotnetMarkdownReport(nodeStats) {
|
|
|
1278
1407
|
*/
|
|
1279
1408
|
export function buildDotnetHtmlReport(nodeStats) {
|
|
1280
1409
|
const tabs = buildDotnetHtmlTabs(nodeStats);
|
|
1281
|
-
const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${tabId}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
|
|
1282
|
-
const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${tabId}" class="tab">${html}</section>${REPORT_EOL}`).join("");
|
|
1283
|
-
const chartDataJson = JSON.stringify(buildDotnetChartData(nodeStats));
|
|
1410
|
+
const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${escapeHtml(tabId)}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
|
|
1411
|
+
const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${escapeHtml(tabId)}" class="tab">${html}</section>${REPORT_EOL}`).join("");
|
|
1412
|
+
const chartDataJson = escapeJsonForHtmlScript(JSON.stringify(buildDotnetChartData(nodeStats)));
|
|
1284
1413
|
const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
|
|
1285
1414
|
const template = `<!doctype html>
|
|
1286
1415
|
<html lang="en">
|
|
@@ -1372,7 +1501,7 @@ const reportLogo=document.querySelector('[data-report-logo]');
|
|
|
1372
1501
|
function applyReportTheme(theme){const normalized=theme==='dark'?'dark':'light';document.body.setAttribute('data-theme',normalized);if(reportLogo){const lightLogo=reportLogo.dataset.logoLight||reportLogo.getAttribute('src');const darkLogo=reportLogo.dataset.logoDark||reportLogo.getAttribute('src');reportLogo.setAttribute('src',normalized==='dark'?darkLogo:lightLogo);}if(reportThemeToggle){const darkActive=normalized==='dark';const nextLabel=darkActive?'light':'dark';reportThemeToggle.innerHTML=darkActive?'☀':'☾';reportThemeToggle.setAttribute('aria-pressed',darkActive?'true':'false');reportThemeToggle.setAttribute('aria-label','Switch to '+nextLabel+' theme');reportThemeToggle.setAttribute('title','Switch to '+nextLabel+' theme');}}
|
|
1373
1502
|
function show(id){btns.forEach(b=>b.classList.toggle('active',b.dataset.tab===id));tabSections.forEach(t=>t.classList.toggle('active',t.id===id));}
|
|
1374
1503
|
function formatMetric(v){if(!Number.isFinite(v))return '0';return Math.abs(v)>=100?v.toFixed(0):v.toFixed(2);}
|
|
1375
|
-
function setupCanvas(canvas){const dpr=window.devicePixelRatio||1;const w=Math.max(
|
|
1504
|
+
function setupCanvas(canvas){const dpr=window.devicePixelRatio||1;const rect=canvas.getBoundingClientRect();const w=Math.max(1,Math.round(rect.width||canvas.clientWidth||320));const h=Math.max(1,Math.round(rect.height||canvas.clientHeight||220));canvas.width=Math.max(1,Math.round(w*dpr));canvas.height=Math.max(1,Math.round(h*dpr));const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);return {ctx,w,h};}
|
|
1376
1505
|
function drawNoData(ctx,w,h,msg){ctx.fillStyle='#9fb0c3';ctx.font='13px Segoe UI';ctx.textAlign='center';ctx.fillText(msg,w/2,h/2);}
|
|
1377
1506
|
function drawBar(canvasId,points){const canvas=document.getElementById(canvasId);if(!canvas)return;const c=setupCanvas(canvas);const ctx=c.ctx,w=c.w,h=c.h;ctx.clearRect(0,0,w,h);if(!points||points.length===0){drawNoData(ctx,w,h,'No data');return;}const left=46,right=14,top=16,bottom=62;const pw=w-left-right;const ph=h-top-bottom;const max=Math.max(...points.map(p=>p.value),1);ctx.strokeStyle='#334155';ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=top+(ph*(i/4));ctx.beginPath();ctx.moveTo(left,y);ctx.lineTo(w-right,y);ctx.stroke();}const slot=pw/points.length;const bar=Math.max(8,slot*0.58);ctx.font='11px Segoe UI';for(let i=0;i<points.length;i++){const p=points[i];const x=left+i*slot+(slot-bar)/2;const bh=(p.value/max)*ph;const y=top+ph-bh;ctx.fillStyle=p.color||'#3b82f6';ctx.fillRect(x,y,bar,bh);ctx.fillStyle='#dbe6f4';ctx.textAlign='center';ctx.fillText(formatMetric(p.value),x+bar/2,Math.max(12,y-4));ctx.save();ctx.translate(x+bar/2,h-bottom+14);ctx.rotate(-0.6);ctx.fillStyle='#b5c2d3';ctx.fillText((p.label||'').slice(0,26),0,0);ctx.restore();}ctx.fillStyle='#b5c2d3';ctx.textAlign='right';for(let i=0;i<=4;i++){const value=max*(1-i/4);const y=top+(ph*(i/4))+4;ctx.fillText(formatMetric(value),left-6,y);}}
|
|
1378
1507
|
function drawPie(canvasId,points){const canvas=document.getElementById(canvasId);if(!canvas)return;const c=setupCanvas(canvas);const ctx=c.ctx,w=c.w,h=c.h;ctx.clearRect(0,0,w,h);if(!points||points.length===0){drawNoData(ctx,w,h,'No data');return;}const total=points.reduce((s,p)=>s+(p.value||0),0);if(total<=0){drawNoData(ctx,w,h,'No data');return;}const cx=w*0.35,cy=h*0.5,r=Math.min(w,h)*0.28;let angle=-Math.PI/2;for(const p of points){const val=Math.max(0,p.value||0);const delta=(val/total)*Math.PI*2;ctx.beginPath();ctx.moveTo(cx,cy);ctx.arc(cx,cy,r,angle,angle+delta);ctx.closePath();ctx.fillStyle=p.color||'#3b82f6';ctx.fill();angle+=delta;}ctx.fillStyle='#0f172a';ctx.beginPath();ctx.arc(cx,cy,r*0.54,0,Math.PI*2);ctx.fill();ctx.fillStyle='#e5eefc';ctx.font='bold 18px Segoe UI';ctx.textAlign='center';ctx.fillText(total.toString(),cx,cy+6);ctx.font='12px Segoe UI';ctx.fillStyle='#9fb0c3';ctx.fillText('requests',cx,cy+24);ctx.textAlign='left';let y=cy-r+10;for(const p of points){ctx.fillStyle=p.color||'#3b82f6';ctx.fillRect(w*0.64,y-10,12,12);ctx.fillStyle='#e6edf3';ctx.font='12px Segoe UI';const pct=total<=0?0:((p.value/total)*100);ctx.fillText(\`\${p.label}: \${p.value} (\${pct.toFixed(1)}%)\`,w*0.64+18,y);y+=20;}}
|