@loadstrike/loadstrike-sdk 1.0.31001 → 1.0.32601
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 +16 -2
- package/dist/cjs/internal/prometheus-remote-write.js +37 -0
- package/dist/cjs/internal/reporting-sink-http-error.js +17 -0
- package/dist/cjs/internal/vendor-metric-payloads.js +390 -0
- package/dist/cjs/iteration-observations.js +24 -8
- package/dist/cjs/local-report-input.js +21 -0
- package/dist/cjs/local.js +48 -63
- package/dist/cjs/report-history.js +421 -0
- package/dist/cjs/reporting-containment.js +242 -0
- package/dist/cjs/reporting-svg.js +116 -0
- package/dist/cjs/reporting.js +413 -136
- package/dist/cjs/runtime.js +237 -8
- package/dist/cjs/sinks.js +1337 -38
- package/dist/cjs/transports.js +1339 -151
- package/dist/esm/internal/prometheus-remote-write.js +31 -0
- package/dist/esm/internal/reporting-sink-http-error.js +13 -0
- package/dist/esm/internal/vendor-metric-payloads.js +382 -0
- package/dist/esm/iteration-observations.js +24 -8
- package/dist/esm/local-report-input.js +17 -0
- package/dist/esm/local.js +49 -64
- package/dist/esm/report-history.js +413 -0
- package/dist/esm/reporting-containment.js +238 -0
- package/dist/esm/reporting-svg.js +113 -0
- package/dist/esm/reporting.js +413 -136
- package/dist/esm/runtime.js +239 -10
- package/dist/esm/sinks.js +1334 -35
- package/dist/esm/transports.js +1335 -151
- package/dist/types/contracts.d.ts +1 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/internal/prometheus-remote-write.d.ts +2 -0
- package/dist/types/internal/reporting-sink-http-error.d.ts +6 -0
- package/dist/types/internal/vendor-metric-payloads.d.ts +48 -0
- package/dist/types/local-report-input.d.ts +6 -0
- package/dist/types/local.d.ts +0 -6
- package/dist/types/report-history.d.ts +124 -0
- package/dist/types/reporting-containment.d.ts +2 -0
- package/dist/types/reporting-svg.d.ts +2 -0
- package/dist/types/reporting.d.ts +6 -3
- package/dist/types/runtime.d.ts +24 -0
- package/dist/types/sinks.d.ts +134 -17
- package/dist/types/transports.d.ts +2 -0
- package/package.json +9 -3
- package/dist/cjs/internal-build.js +0 -4
- package/dist/esm/internal-build.js +0 -1
- package/dist/types/internal-build.d.ts +0 -1
package/dist/esm/local.js
CHANGED
|
@@ -15,10 +15,9 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as childProcess from "node:child_process";
|
|
16
16
|
import { createHash, createVerify, randomUUID } from "node:crypto";
|
|
17
17
|
import { CorrelationStoreConfiguration, CrossPlatformTrackingRuntime, RedisCorrelationStoreOptions, RedisCorrelationStore, TrackingFieldSelector } from "./correlation.js";
|
|
18
|
-
import { EndpointAdapterFactory, LOADSTRIKE_TRACE_ID_TRACKING_FIELD } from "./transports.js";
|
|
19
|
-
import {
|
|
18
|
+
import { EndpointAdapterFactory, LOADSTRIKE_TRACE_ID_TRACKING_FIELD, validateNativeEndpointExecutionSupport } from "./transports.js";
|
|
19
|
+
import { assertNoUnsupportedReportingInfraConfig, assertNoUnsupportedReportingSinkGraph } from "./reporting-containment.js";
|
|
20
20
|
const DEFAULT_LICENSING_API_BASE_URL = "https://licensing.loadstrike.com";
|
|
21
|
-
let developmentLicensingApiBaseUrlOverride;
|
|
22
21
|
const BUILT_IN_WORKER_PLUGIN_NAMES = new Set([
|
|
23
22
|
"loadstrike failed responses",
|
|
24
23
|
"loadstrike correlation"
|
|
@@ -72,7 +71,7 @@ export class LoadStrikeLocalClient {
|
|
|
72
71
|
_LoadStrikeLocalClient_signingKeyCache.set(this, new Map());
|
|
73
72
|
_LoadStrikeLocalClient_heartbeatDrains.set(this, new WeakMap());
|
|
74
73
|
assertNoDisableLicenseEnforcementOption(options, "LoadStrikeLocalClient");
|
|
75
|
-
__classPrivateFieldSet(this, _LoadStrikeLocalClient_licensingApiBaseUrl,
|
|
74
|
+
__classPrivateFieldSet(this, _LoadStrikeLocalClient_licensingApiBaseUrl, DEFAULT_LICENSING_API_BASE_URL, "f");
|
|
76
75
|
this.licenseValidationTimeoutMs = normalizeTimeoutMs(options.licenseValidationTimeoutMs);
|
|
77
76
|
}
|
|
78
77
|
portalReportingIngestUrl() {
|
|
@@ -80,6 +79,7 @@ export class LoadStrikeLocalClient {
|
|
|
80
79
|
}
|
|
81
80
|
async run(request) {
|
|
82
81
|
const sanitized = sanitizeRequest(request);
|
|
82
|
+
validateContainmentRequest(sanitized);
|
|
83
83
|
const licenseSession = await this.acquireLicenseLease(sanitized);
|
|
84
84
|
try {
|
|
85
85
|
const createdUtc = new Date();
|
|
@@ -534,66 +534,27 @@ function sanitizeRequest(request) {
|
|
|
534
534
|
RunArgs: runArgs
|
|
535
535
|
};
|
|
536
536
|
}
|
|
537
|
-
function
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const normalized = normalizeLicensingApiBaseUrl(value);
|
|
548
|
-
if (!isLoopbackHttpBaseUrl(normalized)) {
|
|
549
|
-
throw new TypeError("Internal development licensing API overrides must use a loopback http URL.");
|
|
537
|
+
function validateContainmentRequest(request) {
|
|
538
|
+
const scenarios = Array.isArray(request.Scenarios) ? request.Scenarios : [];
|
|
539
|
+
for (const scenarioValue of scenarios) {
|
|
540
|
+
const scenario = asRecord(scenarioValue);
|
|
541
|
+
const tracking = asRecord(pickValue(scenario, "Tracking", "tracking"));
|
|
542
|
+
for (const field of ["Source", "Destination"]) {
|
|
543
|
+
const endpoint = asRecord(pickValue(tracking, field, field.toLowerCase()));
|
|
544
|
+
if (Object.keys(endpoint).length > 0) {
|
|
545
|
+
validateNativeEndpointExecutionSupport(endpoint);
|
|
546
|
+
}
|
|
550
547
|
}
|
|
551
|
-
developmentLicensingApiBaseUrlOverride = normalized;
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
if (value != null) {
|
|
555
|
-
assertInternalTestHarnessLicensingOverride();
|
|
556
|
-
}
|
|
557
|
-
developmentLicensingApiBaseUrlOverride = undefined;
|
|
558
|
-
}
|
|
559
|
-
function assertInternalTestHarnessLicensingOverride() {
|
|
560
|
-
if (isInternalTestHarnessCall()) {
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
throw new TypeError("Internal development licensing API override is available only to LoadStrike SDK tests.");
|
|
564
|
-
}
|
|
565
|
-
function isInternalTestHarnessCall() {
|
|
566
|
-
const stack = String(new Error().stack ?? "").replace(/\\/g, "/").toLowerCase();
|
|
567
|
-
return stack.includes("/sdk/ts/tests/");
|
|
568
|
-
}
|
|
569
|
-
function isLoopbackHttpBaseUrl(value) {
|
|
570
|
-
let parsed;
|
|
571
|
-
try {
|
|
572
|
-
parsed = new URL(value);
|
|
573
|
-
}
|
|
574
|
-
catch {
|
|
575
|
-
return false;
|
|
576
|
-
}
|
|
577
|
-
if (parsed.protocol !== "http:") {
|
|
578
|
-
return false;
|
|
579
548
|
}
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
const envToken = String(process.env.LOADSTRIKE_INTERNAL_BLACKBOX_API_BASE_URL_TOKEN ?? "").trim();
|
|
589
|
-
if (!envToken || envToken !== compiledToken) {
|
|
590
|
-
return undefined;
|
|
591
|
-
}
|
|
592
|
-
const candidate = normalizeLicensingApiBaseUrl(process.env.LOADSTRIKE_INTERNAL_BLACKBOX_API_BASE_URL);
|
|
593
|
-
if (!isLoopbackHttpBaseUrl(candidate)) {
|
|
594
|
-
return undefined;
|
|
549
|
+
const context = asRecord(request.Context);
|
|
550
|
+
const reportingSinks = asList(pickValue(context, "ReportingSinks", "reportingSinks"));
|
|
551
|
+
for (const [key, value] of Object.entries(context)) {
|
|
552
|
+
const normalizedKey = key.trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
553
|
+
if (normalizedKey === "infraconfig") {
|
|
554
|
+
assertNoUnsupportedReportingInfraConfig(value, reportingSinks);
|
|
555
|
+
}
|
|
595
556
|
}
|
|
596
|
-
|
|
557
|
+
assertNoUnsupportedReportingSinkGraph(reportingSinks);
|
|
597
558
|
}
|
|
598
559
|
function normalizeTimeoutMs(value) {
|
|
599
560
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
@@ -1301,6 +1262,20 @@ function mapEndpointSpec(spec, useLoadStrikeTraceIdHeader = false) {
|
|
|
1301
1262
|
redisStreams: asRecord(pickValue(spec, "RedisStreams", "redisStreams")),
|
|
1302
1263
|
azureEventHubs: asRecord(pickValue(spec, "AzureEventHubs", "azureEventHubs")),
|
|
1303
1264
|
pushDiffusion: asRecord(pickValue(spec, "PushDiffusion", "pushDiffusion")),
|
|
1265
|
+
grpc: mapEndpointProtocolOptions(spec, ["Grpc", "grpc"], [
|
|
1266
|
+
"Target", "target", "ServiceName", "serviceName", "MethodName", "methodName",
|
|
1267
|
+
"MethodType", "methodType", "Deadline", "deadline", "DeadlineSeconds", "deadlineSeconds",
|
|
1268
|
+
"DeadlineMs", "deadlineMs", "Metadata", "metadata", "Produce", "produce", "Consume", "consume",
|
|
1269
|
+
"ProduceAsync", "produceAsync", "ConsumeAsync", "consumeAsync", "ConnectionMetadata", "connectionMetadata",
|
|
1270
|
+
"NativeClient", "nativeClient"
|
|
1271
|
+
]),
|
|
1272
|
+
webSocket: mapEndpointProtocolOptions(spec, ["WebSocket", "webSocket"], [
|
|
1273
|
+
"Url", "url", "Subprotocols", "subprotocols", "ConnectTimeout", "connectTimeout",
|
|
1274
|
+
"ConnectTimeoutSeconds", "connectTimeoutSeconds", "ConnectTimeoutMs", "connectTimeoutMs",
|
|
1275
|
+
"CloseTimeout", "closeTimeout", "CloseTimeoutSeconds", "closeTimeoutSeconds", "CloseTimeoutMs", "closeTimeoutMs",
|
|
1276
|
+
"Produce", "produce", "Consume", "consume", "ProduceAsync", "produceAsync", "ConsumeAsync", "consumeAsync",
|
|
1277
|
+
"ConnectionMetadata", "connectionMetadata", "NativeClient", "nativeClient"
|
|
1278
|
+
]),
|
|
1304
1279
|
delegate: (typeof delegateProduce === "function"
|
|
1305
1280
|
|| typeof delegateConsume === "function"
|
|
1306
1281
|
|| typeof delegateProduceAsync === "function"
|
|
@@ -1323,6 +1298,19 @@ function mapEndpointSpec(spec, useLoadStrikeTraceIdHeader = false) {
|
|
|
1323
1298
|
: undefined
|
|
1324
1299
|
};
|
|
1325
1300
|
}
|
|
1301
|
+
function mapEndpointProtocolOptions(spec, nestedKeys, flatKeys) {
|
|
1302
|
+
const nested = asRecord(pickValue(spec, ...nestedKeys));
|
|
1303
|
+
if (Object.keys(nested).length > 0) {
|
|
1304
|
+
return nested;
|
|
1305
|
+
}
|
|
1306
|
+
const options = {};
|
|
1307
|
+
for (const key of flatKeys) {
|
|
1308
|
+
if (Object.prototype.hasOwnProperty.call(spec, key)) {
|
|
1309
|
+
options[key] = spec[key];
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return options;
|
|
1313
|
+
}
|
|
1326
1314
|
function normalizePayload(payload, endpoint, index) {
|
|
1327
1315
|
const resolvedBody = payload?.body ?? endpoint.messagePayload;
|
|
1328
1316
|
const normalized = {
|
|
@@ -2279,8 +2267,6 @@ export const __loadstrikeTestExports = {
|
|
|
2279
2267
|
mapEnvironmentClassification,
|
|
2280
2268
|
mapEndpointSpec,
|
|
2281
2269
|
normalizeEndpointPayloadValue,
|
|
2282
|
-
normalizeLicensingApiBaseUrl,
|
|
2283
|
-
resolveLicensingApiBaseUrl,
|
|
2284
2270
|
normalizeNodeType,
|
|
2285
2271
|
normalizePayload,
|
|
2286
2272
|
normalizeStringArray,
|
|
@@ -2299,7 +2285,6 @@ export const __loadstrikeTestExports = {
|
|
|
2299
2285
|
sanitizeLooseJson,
|
|
2300
2286
|
sanitizeRequest,
|
|
2301
2287
|
setJsonPathValue,
|
|
2302
|
-
setDevelopmentLicensingApiBaseUrlOverride,
|
|
2303
2288
|
sleep,
|
|
2304
2289
|
stringOrDefault,
|
|
2305
2290
|
toBoolean,
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
export const REPORT_HISTORY_MAX_POINTS = 2048;
|
|
2
|
+
export const REPORT_HISTORY_MAX_SCALAR_VALUES = 262144;
|
|
3
|
+
/**
|
|
4
|
+
* Bounded report-only cumulative telemetry. This type is intentionally not
|
|
5
|
+
* exported from the package entry point and never participates in public run,
|
|
6
|
+
* sink, observation, or cluster payloads.
|
|
7
|
+
*/
|
|
8
|
+
export class ReportHistoryCollector {
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.points = [];
|
|
11
|
+
this.terminalCaptured = false;
|
|
12
|
+
const scenarioCount = Math.max(1, Math.trunc(options.scenarioCount));
|
|
13
|
+
const scalarValuesPerPoint = scenarioCount * 16;
|
|
14
|
+
const budgetPointLimit = Math.floor(REPORT_HISTORY_MAX_SCALAR_VALUES / scalarValuesPerPoint);
|
|
15
|
+
if (!Number.isSafeInteger(scalarValuesPerPoint) || budgetPointLimit < 2) {
|
|
16
|
+
this.pointLimit = 0;
|
|
17
|
+
this.reasonCategory = "budget_pressure";
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const requestedLimit = options.maxPoints === undefined
|
|
21
|
+
? REPORT_HISTORY_MAX_POINTS
|
|
22
|
+
: Math.max(2, Math.trunc(options.maxPoints));
|
|
23
|
+
this.pointLimit = Math.min(REPORT_HISTORY_MAX_POINTS, requestedLimit, Math.max(2, budgetPointLimit));
|
|
24
|
+
}
|
|
25
|
+
this.nowNs = options.nowNs ?? (() => process.hrtime.bigint());
|
|
26
|
+
this.onCaptureError = options.onCaptureError;
|
|
27
|
+
}
|
|
28
|
+
get available() {
|
|
29
|
+
return this.reasonCategory === undefined;
|
|
30
|
+
}
|
|
31
|
+
start(startNs = this.nowNs()) {
|
|
32
|
+
if (!this.available || this.startNs !== undefined) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.startNs = startNs;
|
|
36
|
+
}
|
|
37
|
+
capture(snapshotFactory) {
|
|
38
|
+
return this.captureSafely(snapshotFactory, false);
|
|
39
|
+
}
|
|
40
|
+
finalize(snapshotFactory) {
|
|
41
|
+
return this.captureSafely(snapshotFactory, true);
|
|
42
|
+
}
|
|
43
|
+
disable(reasonCategory) {
|
|
44
|
+
this.reasonCategory = reasonCategory;
|
|
45
|
+
this.points = [];
|
|
46
|
+
}
|
|
47
|
+
toProjection() {
|
|
48
|
+
if (this.reasonCategory !== undefined) {
|
|
49
|
+
return {
|
|
50
|
+
status: "unavailable",
|
|
51
|
+
reasonCategory: this.reasonCategory,
|
|
52
|
+
points: []
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
status: "available",
|
|
57
|
+
points: this.points.map(clonePoint)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
captureSafely(snapshotFactory, terminal) {
|
|
61
|
+
if (!this.available || (this.terminalCaptured && !terminal)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
if (this.startNs === undefined) {
|
|
66
|
+
this.start();
|
|
67
|
+
}
|
|
68
|
+
const nowNs = this.nowNs();
|
|
69
|
+
const elapsedNs = nowNs > this.startNs ? nowNs - this.startNs : 0n;
|
|
70
|
+
const elapsedSeconds = Number(elapsedNs) / 1000000000;
|
|
71
|
+
const scenarios = [...snapshotFactory()]
|
|
72
|
+
.map(projectScenario)
|
|
73
|
+
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0)
|
|
74
|
+
|| left.scenarioName.localeCompare(right.scenarioName));
|
|
75
|
+
const point = {
|
|
76
|
+
elapsedSeconds: finiteNonNegative(elapsedSeconds) ?? 0,
|
|
77
|
+
terminal,
|
|
78
|
+
scenarios
|
|
79
|
+
};
|
|
80
|
+
if (terminal) {
|
|
81
|
+
this.points = this.points.filter((existing) => existing.elapsedSeconds <= point.elapsedSeconds);
|
|
82
|
+
this.terminalCaptured = true;
|
|
83
|
+
}
|
|
84
|
+
const existingIndex = this.points.findIndex((existing) => existing.elapsedSeconds === point.elapsedSeconds);
|
|
85
|
+
if (existingIndex >= 0) {
|
|
86
|
+
if (terminal || !this.points[existingIndex].terminal) {
|
|
87
|
+
this.points[existingIndex] = point;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (point.elapsedSeconds > 0 || terminal) {
|
|
91
|
+
this.points.push(point);
|
|
92
|
+
this.points.sort((left, right) => left.elapsedSeconds - right.elapsedSeconds);
|
|
93
|
+
}
|
|
94
|
+
this.compact();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
this.disable("capture_failure");
|
|
99
|
+
try {
|
|
100
|
+
this.onCaptureError?.(error);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Report diagnostics are best-effort and must never affect the run.
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
compact() {
|
|
109
|
+
if (this.pointLimit === 2 && this.points.length > 2) {
|
|
110
|
+
this.points = [this.points[0], this.points[this.points.length - 1]];
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
while (this.points.length > this.pointLimit) {
|
|
114
|
+
const newest = this.points[this.points.length - 1];
|
|
115
|
+
const thinned = [this.points[0]];
|
|
116
|
+
for (let index = 1; index < this.points.length - 1; index += 2) {
|
|
117
|
+
thinned.push(this.points[index]);
|
|
118
|
+
}
|
|
119
|
+
if (thinned[thinned.length - 1] !== newest) {
|
|
120
|
+
thinned.push(newest);
|
|
121
|
+
}
|
|
122
|
+
this.points = thinned;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Coordinates one private history worker across every local scenario. The
|
|
128
|
+
* first measured phase establishes the history clock, and the last scenario
|
|
129
|
+
* leaving execution records the terminal point before scenario cleanup.
|
|
130
|
+
*/
|
|
131
|
+
export class ReportHistoryLifecycleCoordinator {
|
|
132
|
+
constructor(worker) {
|
|
133
|
+
this.worker = worker;
|
|
134
|
+
this.measuredLoadStarted = false;
|
|
135
|
+
this.endedScenarios = 0;
|
|
136
|
+
this.completed = false;
|
|
137
|
+
this.terminalBoundaryReleased = false;
|
|
138
|
+
this.scenarioCount = Math.max(1, Math.trunc(worker.scenarioCount ?? 1));
|
|
139
|
+
this.terminalBoundary = new Promise((resolve) => {
|
|
140
|
+
this.releaseTerminalBoundary = resolve;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
scenarioBombingStarted() {
|
|
144
|
+
if (this.completed || this.measuredLoadStarted) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.measuredLoadStarted = true;
|
|
148
|
+
try {
|
|
149
|
+
this.worker.start();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// The private report-only worker cannot fail the measured run.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async scenarioExecutionEnded() {
|
|
156
|
+
if (this.completed) {
|
|
157
|
+
await this.terminalBoundary;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.endedScenarios += 1;
|
|
161
|
+
if (this.endedScenarios < this.scenarioCount) {
|
|
162
|
+
await this.terminalBoundary;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.completed = true;
|
|
166
|
+
try {
|
|
167
|
+
if (this.measuredLoadStarted) {
|
|
168
|
+
this.worker.stopAndFinalize();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// The private report-only worker cannot fail scenario completion.
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
this.releaseBoundary();
|
|
176
|
+
}
|
|
177
|
+
await this.terminalBoundary;
|
|
178
|
+
}
|
|
179
|
+
stopWithoutFinalizing() {
|
|
180
|
+
if (this.completed) {
|
|
181
|
+
this.releaseBoundary();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.completed = true;
|
|
185
|
+
try {
|
|
186
|
+
if (this.measuredLoadStarted) {
|
|
187
|
+
this.worker.stopWithoutFinalizing();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// The private report-only worker cannot fail run cleanup.
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
this.releaseBoundary();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
releaseBoundary() {
|
|
198
|
+
if (!this.terminalBoundaryReleased) {
|
|
199
|
+
this.terminalBoundaryReleased = true;
|
|
200
|
+
this.releaseTerminalBoundary();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Owns the report timer. Every cadence calculation, timeout callback, snapshot,
|
|
206
|
+
* and projection is exception-bounded so report history can never fail a run.
|
|
207
|
+
*/
|
|
208
|
+
export class ReportHistoryWorker {
|
|
209
|
+
constructor(options) {
|
|
210
|
+
this.options = options;
|
|
211
|
+
this.cadenceNs = 0n;
|
|
212
|
+
this.nextDeadlineNs = 0n;
|
|
213
|
+
this.running = false;
|
|
214
|
+
this.nowNs = options.nowNs ?? (() => process.hrtime.bigint());
|
|
215
|
+
}
|
|
216
|
+
get started() {
|
|
217
|
+
return this.running;
|
|
218
|
+
}
|
|
219
|
+
start() {
|
|
220
|
+
if (this.running || !this.options.collector.available) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
this.cadenceNs = cadenceNanoseconds(this.options.cadenceSeconds);
|
|
225
|
+
const nowNs = this.nowNs();
|
|
226
|
+
this.options.collector.start(nowNs);
|
|
227
|
+
this.nextDeadlineNs = nowNs + this.cadenceNs;
|
|
228
|
+
this.running = true;
|
|
229
|
+
this.scheduleNext();
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
this.fail(error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
stopAndFinalize() {
|
|
236
|
+
if (!this.running) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.running = false;
|
|
240
|
+
if (this.timer !== undefined) {
|
|
241
|
+
clearTimeout(this.timer);
|
|
242
|
+
this.timer = undefined;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
this.options.collector.finalize(this.options.snapshotFactory);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
this.fail(error);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
stopWithoutFinalizing() {
|
|
252
|
+
this.running = false;
|
|
253
|
+
if (this.timer !== undefined) {
|
|
254
|
+
clearTimeout(this.timer);
|
|
255
|
+
this.timer = undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
scheduleNext() {
|
|
259
|
+
try {
|
|
260
|
+
if (!this.running || !this.options.collector.available) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const remainingNs = this.nextDeadlineNs - this.nowNs();
|
|
264
|
+
const delayMs = safeTimeoutDelayMs(remainingNs);
|
|
265
|
+
this.timer = setTimeout(() => this.onTimer(), delayMs);
|
|
266
|
+
if (typeof this.timer.unref === "function") {
|
|
267
|
+
this.timer.unref();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
this.fail(error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
onTimer() {
|
|
275
|
+
try {
|
|
276
|
+
this.timer = undefined;
|
|
277
|
+
if (!this.running || !this.options.collector.available) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const nowNs = this.nowNs();
|
|
281
|
+
if (nowNs < this.nextDeadlineNs) {
|
|
282
|
+
this.scheduleNext();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
this.options.collector.capture(this.options.snapshotFactory);
|
|
286
|
+
if (!this.options.collector.available) {
|
|
287
|
+
this.running = false;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
do {
|
|
291
|
+
this.nextDeadlineNs += this.cadenceNs;
|
|
292
|
+
} while (this.nextDeadlineNs <= nowNs);
|
|
293
|
+
this.scheduleNext();
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
this.fail(error);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
fail(error) {
|
|
300
|
+
this.stopWithoutFinalizing();
|
|
301
|
+
this.options.collector.disable("capture_failure");
|
|
302
|
+
try {
|
|
303
|
+
this.options.onFailure?.("capture_failure", sanitizedExceptionClassChain(error));
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// Logging a report-only warning is best-effort.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export function sanitizedExceptionClassChain(error) {
|
|
311
|
+
const classes = [];
|
|
312
|
+
const seen = new Set();
|
|
313
|
+
let current = error;
|
|
314
|
+
while (current !== undefined && current !== null && classes.length < 8 && !seen.has(current)) {
|
|
315
|
+
seen.add(current);
|
|
316
|
+
const rawName = typeof current === "object"
|
|
317
|
+
? String(current.constructor?.name ?? "Error")
|
|
318
|
+
: typeof current;
|
|
319
|
+
const safeName = rawName.replace(/[^A-Za-z0-9_.$-]/g, "_").slice(0, 64) || "Error";
|
|
320
|
+
classes.push(safeName);
|
|
321
|
+
current = typeof current === "object"
|
|
322
|
+
? current.cause
|
|
323
|
+
: undefined;
|
|
324
|
+
}
|
|
325
|
+
return classes.join(" -> ") || "Error";
|
|
326
|
+
}
|
|
327
|
+
function cadenceNanoseconds(seconds) {
|
|
328
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
329
|
+
throw new RangeError("Invalid report history cadence.");
|
|
330
|
+
}
|
|
331
|
+
const safelyRepresentableSeconds = Math.min(seconds, Number.MAX_SAFE_INTEGER);
|
|
332
|
+
const wholeSeconds = Math.trunc(safelyRepresentableSeconds);
|
|
333
|
+
const fractionalNanoseconds = Math.round(Math.max(0, safelyRepresentableSeconds - wholeSeconds) * 1000000000);
|
|
334
|
+
const cadence = BigInt(wholeSeconds) * 1000000000n + BigInt(fractionalNanoseconds);
|
|
335
|
+
if (cadence <= 0n) {
|
|
336
|
+
throw new RangeError("Report history cadence is below one nanosecond.");
|
|
337
|
+
}
|
|
338
|
+
return cadence;
|
|
339
|
+
}
|
|
340
|
+
function safeTimeoutDelayMs(remainingNs) {
|
|
341
|
+
if (remainingNs <= 0n) {
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
344
|
+
const milliseconds = (remainingNs + 999999n) / 1000000n;
|
|
345
|
+
return Number(milliseconds > 2147483647n ? 2147483647n : milliseconds);
|
|
346
|
+
}
|
|
347
|
+
export function deriveScenarioRates(points) {
|
|
348
|
+
const scenarioNames = [...new Set(points.flatMap((point) => point.scenarios.map((scenario) => scenario.scenarioName)))];
|
|
349
|
+
return scenarioNames.map((scenarioName) => {
|
|
350
|
+
let previousElapsed = 0;
|
|
351
|
+
let previousCount = 0;
|
|
352
|
+
return {
|
|
353
|
+
scenarioName,
|
|
354
|
+
values: points.map((point) => {
|
|
355
|
+
const scenario = point.scenarios.find((candidate) => candidate.scenarioName === scenarioName);
|
|
356
|
+
const currentCount = scenario?.all?.count
|
|
357
|
+
?? ((scenario?.ok?.count ?? 0) + (scenario?.failed?.count ?? 0));
|
|
358
|
+
const duration = point.elapsedSeconds - previousElapsed;
|
|
359
|
+
const rate = duration > 0
|
|
360
|
+
? Math.max(0, currentCount - previousCount) / duration
|
|
361
|
+
: null;
|
|
362
|
+
previousElapsed = point.elapsedSeconds;
|
|
363
|
+
previousCount = currentCount;
|
|
364
|
+
return rate === null || !Number.isFinite(rate) ? null : rate;
|
|
365
|
+
})
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
function projectScenario(source) {
|
|
370
|
+
return {
|
|
371
|
+
scenarioName: String(source.scenarioName ?? ""),
|
|
372
|
+
sortIndex: finiteNonNegativeInteger(source.sortIndex),
|
|
373
|
+
...(source.all ? { all: projectMeasurement(source.all) } : {}),
|
|
374
|
+
...(source.ok ? { ok: projectMeasurement(source.ok) } : {}),
|
|
375
|
+
...(source.failed ? { failed: projectMeasurement(source.failed) } : {})
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function projectMeasurement(source) {
|
|
379
|
+
return {
|
|
380
|
+
count: finiteNonNegativeInteger(source.count) ?? 0,
|
|
381
|
+
bytes: finiteNonNegative(source.bytes) ?? 0,
|
|
382
|
+
approximate: source.approximate === true,
|
|
383
|
+
...optionalFinite("percent50Ms", source.percent50Ms),
|
|
384
|
+
...optionalFinite("percent75Ms", source.percent75Ms),
|
|
385
|
+
...optionalFinite("percent95Ms", source.percent95Ms),
|
|
386
|
+
...optionalFinite("percent99Ms", source.percent99Ms)
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function optionalFinite(key, value) {
|
|
390
|
+
const normalized = finiteNonNegative(value);
|
|
391
|
+
return normalized === undefined ? {} : { [key]: normalized };
|
|
392
|
+
}
|
|
393
|
+
function finiteNonNegative(value) {
|
|
394
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
395
|
+
? value
|
|
396
|
+
: undefined;
|
|
397
|
+
}
|
|
398
|
+
function finiteNonNegativeInteger(value) {
|
|
399
|
+
const normalized = finiteNonNegative(value);
|
|
400
|
+
return normalized === undefined ? undefined : Math.trunc(normalized);
|
|
401
|
+
}
|
|
402
|
+
function clonePoint(point) {
|
|
403
|
+
return {
|
|
404
|
+
elapsedSeconds: point.elapsedSeconds,
|
|
405
|
+
terminal: point.terminal,
|
|
406
|
+
scenarios: point.scenarios.map((scenario) => ({
|
|
407
|
+
...scenario,
|
|
408
|
+
...(scenario.all ? { all: { ...scenario.all } } : {}),
|
|
409
|
+
...(scenario.ok ? { ok: { ...scenario.ok } } : {}),
|
|
410
|
+
...(scenario.failed ? { failed: { ...scenario.failed } } : {})
|
|
411
|
+
}))
|
|
412
|
+
};
|
|
413
|
+
}
|