@loadstrike/loadstrike-sdk 1.0.30401 → 1.0.31601

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.
@@ -7,9 +7,13 @@ import { DistributedClusterAgent, DistributedClusterCoordinator, buildLoadEngine
7
7
  import { CorrelationStoreConfiguration, CrossPlatformTrackingRuntime, RedisCorrelationStore, RedisCorrelationStoreOptions, TrackingFieldSelector } from "./correlation.js";
8
8
  import { EndpointAdapterFactory, LOADSTRIKE_TRACE_ID_TRACKING_FIELD } from "./transports.js";
9
9
  import { buildDotnetCsvReport, buildDotnetHtmlReport, buildDotnetMarkdownReport, buildDotnetTxtReport } from "./reporting.js";
10
+ import { ReportHistoryCollector, ReportHistoryLifecycleCoordinator, ReportHistoryWorker, sanitizedExceptionClassChain } from "./report-history.js";
11
+ import { distributedLocalReportInput, emptyLocalReportInput } from "./local-report-input.js";
10
12
  import { PortalReportingSink, cloneReportingSinkForRun } from "./sinks.js";
11
13
  import { LoadEngineV2ExecutionBudget, buildLoadEngineV2TrafficMixSeedId, LoadStrikeHistogramV1, parseLoadEngineV2HistogramArtifact, serializeLoadEngineV2HistogramArtifact, classifyLoadEngineV2Arrival, loadEngineV2FixedArrivalCount, loadEngineV2FixedDeadlineNs, loadEngineV2LatenessToleranceNs, loadEngineV2TrafficMixLaneUnitCount, loadEngineV2TrafficMixOwnedUnits, planRampingInjectionDeadlines, planRampingConstantDeadlines, planRandomInjectionDeadlines, fnv1a32 } from "./load-engine-v2.js";
12
14
  import { DEFAULT_ITERATION_OBSERVATION_SETTINGS, IterationObservationReporter, createIterationObservation, createIterationStepObservation, utcNowNs, validateIterationObservationSettings } from "./iteration-observations.js";
15
+ import { logIterationObservationFailure, logIterationObservationRecovery } from "./iteration-observation-diagnostics.js";
16
+ import { normalizeSinkRetryBackoffMs, normalizeSinkRetryCount, sinkRetryDelayMs, waitForSinkRetryDelay } from "./sink-retry-policy.js";
13
17
  const LOAD_ENGINE_V2_SCHEDULER_DISTRIBUTIONS = Symbol("loadstrike.load-engine-v2.scheduler-distributions");
14
18
  const PORTAL_RUN_TOKEN_PROVIDER = Symbol.for("loadstrike.internal.portal-run-token-provider");
15
19
  export const LoadStrikeNodeType = {
@@ -281,6 +285,28 @@ class MeasurementAccumulator {
281
285
  moreOrEq1200: left.moreOrEq1200 + right.moreOrEq1200
282
286
  }, allRequestCount, durationMs);
283
287
  }
288
+ /**
289
+ * Projects only the cumulative fields used by the private HTML-report
290
+ * history. Legacy distributions deliberately omit latency so this cadence
291
+ * path never scans their retained per-request arrays.
292
+ */
293
+ buildReportHistoryMeasurement() {
294
+ return projectNativeReportHistoryMeasurement(this.count, this.allBytes, this.useHistogram ? this.latencyHistogram : undefined);
295
+ }
296
+ /**
297
+ * Combines only bounded native latency state plus exact counters. It does
298
+ * not materialize status rows or any public measurement DTO.
299
+ */
300
+ buildCombinedReportHistoryMeasurement(other) {
301
+ const count = this.count + other.count;
302
+ const bytes = this.allBytes + other.allBytes;
303
+ if (!this.useHistogram || !other.useHistogram) {
304
+ return projectNativeReportHistoryMeasurement(count, bytes);
305
+ }
306
+ const latency = this.latencyHistogram.clone();
307
+ latency.merge(other.latencyHistogram);
308
+ return projectNativeReportHistoryMeasurement(count, bytes, latency);
309
+ }
284
310
  histogramSnapshot() {
285
311
  return {
286
312
  count: this.count,
@@ -294,6 +320,24 @@ class MeasurementAccumulator {
294
320
  };
295
321
  }
296
322
  }
323
+ function projectNativeReportHistoryMeasurement(count, bytes, latency) {
324
+ const measurement = {
325
+ count,
326
+ bytes,
327
+ approximate: latency?.maxRelativeError !== undefined
328
+ && latency.maxRelativeError > 0
329
+ };
330
+ if (!latency || latency.count === 0n) {
331
+ return measurement;
332
+ }
333
+ return {
334
+ ...measurement,
335
+ percent50Ms: Number(latency.percentile(0.5)) / 1000,
336
+ percent75Ms: Number(latency.percentile(0.75)) / 1000,
337
+ percent95Ms: Number(latency.percentile(0.95)) / 1000,
338
+ percent99Ms: Number(latency.percentile(0.99)) / 1000
339
+ };
340
+ }
297
341
  function buildHistogramMeasurement(snapshot, allRequestCount, durationMs) {
298
342
  const count = snapshot.count;
299
343
  const totalDurationMs = Math.max(durationMs, 0);
@@ -632,6 +676,21 @@ class ScenarioStatsAccumulator {
632
676
  step.record(reply, observedLatencyMs);
633
677
  return step.sortIndex;
634
678
  }
679
+ /**
680
+ * Captures the bounded, private history view without building scenario
681
+ * steps, status rows, plugins, aliases, or any other public result shape.
682
+ */
683
+ buildReportHistorySnapshot() {
684
+ const ok = this.ok.buildReportHistoryMeasurement();
685
+ const failed = this.fail.buildReportHistoryMeasurement();
686
+ return {
687
+ scenarioName: this.scenarioName,
688
+ sortIndex: this.sortIndex,
689
+ all: this.ok.buildCombinedReportHistoryMeasurement(this.fail),
690
+ ...(ok.count > 0 ? { ok } : {}),
691
+ ...(failed.count > 0 ? { failed } : {})
692
+ };
693
+ }
635
694
  /**
636
695
  * Builds the configured payload or helper object.
637
696
  * Use this when all builder inputs are ready to be materialized.
@@ -3427,8 +3486,8 @@ export class LoadStrikeRunner {
3427
3486
  }));
3428
3487
  const sinkErrors = [];
3429
3488
  const policyErrors = [];
3430
- const sinkRetryCount = Math.max(this.options.sinkRetryCount ?? 2, 0);
3431
- const sinkRetryBackoffMs = Math.max(this.options.sinkRetryBackoffMs ?? 25, 0);
3489
+ const sinkRetryCount = normalizeSinkRetryCount(this.options.sinkRetryCount);
3490
+ const sinkRetryBackoffMs = normalizeSinkRetryBackoffMs(this.options.sinkRetryBackoffMs);
3432
3491
  const policies = this.options.runtimePolicies ?? [];
3433
3492
  const runtimePolicyErrorMode = normalizedRuntimePolicyErrorMode(this.options.runtimePolicyErrorMode);
3434
3493
  const plugins = this.options.reportingSinks === undefined && this.options.workerPlugins === undefined &&
@@ -3479,6 +3538,58 @@ export class LoadStrikeRunner {
3479
3538
  licenseSession = await licenseClient.acquireLicenseLease(licensePayload);
3480
3539
  const loggerSetup = createLoggerSetup(this.options.loggerConfig, this.options.minimumLogLevel, this.options, testInfo, nodeInfo);
3481
3540
  const runLogger = loggerSetup.logger;
3541
+ const htmlReportHistoryRequested = (this.options.reportsEnabled ?? true)
3542
+ && normalizeReportFormats(this.options.reportFormats ?? ["html", "txt", "csv", "md"]).includes("html");
3543
+ const distributedReportHistory = isDistributedReportHistoryExecution(clusterMode, this.options);
3544
+ let localReportInput = htmlReportHistoryRequested
3545
+ && distributedReportHistory
3546
+ ? distributedLocalReportInput()
3547
+ : emptyLocalReportInput();
3548
+ let reportHistoryCollector;
3549
+ let reportHistoryLifecycle;
3550
+ if (htmlReportHistoryRequested && !distributedReportHistory) {
3551
+ const warnReportHistoryFailure = (category, error) => {
3552
+ const exceptionClasses = error === undefined
3553
+ ? ""
3554
+ : `; exception_classes=${sanitizedExceptionClassChain(error)}`;
3555
+ try {
3556
+ runLogger.warn(`LoadStrike report history unavailable: ${category}${exceptionClasses}.`);
3557
+ }
3558
+ catch {
3559
+ // Report-only diagnostics are best-effort.
3560
+ }
3561
+ };
3562
+ reportHistoryCollector = new ReportHistoryCollector({
3563
+ scenarioCount: selectedScenarios.length,
3564
+ onCaptureError: (error) => warnReportHistoryFailure("capture_failure", error)
3565
+ });
3566
+ if (!reportHistoryCollector.available) {
3567
+ warnReportHistoryFailure("budget_pressure");
3568
+ }
3569
+ const snapshotFactory = () => Array.from(scenarioAccumulators.values())
3570
+ .map((value) => value.buildReportHistorySnapshot())
3571
+ .sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0)
3572
+ || left.scenarioName.localeCompare(right.scenarioName));
3573
+ const reportHistoryWorker = new ReportHistoryWorker({
3574
+ collector: reportHistoryCollector,
3575
+ cadenceSeconds: resolvedReportHistoryCadenceSeconds(this.options.reportingIntervalSeconds ?? 5),
3576
+ snapshotFactory,
3577
+ onFailure: (category, exceptionClasses) => {
3578
+ try {
3579
+ runLogger.warn(`LoadStrike report history unavailable: ${category}; exception_classes=${exceptionClasses}.`);
3580
+ }
3581
+ catch {
3582
+ // Report-only diagnostics are best-effort.
3583
+ }
3584
+ }
3585
+ });
3586
+ reportHistoryLifecycle = new ReportHistoryLifecycleCoordinator({
3587
+ scenarioCount: selectedScenarios.length,
3588
+ start: () => reportHistoryWorker.start(),
3589
+ stopAndFinalize: () => reportHistoryWorker.stopAndFinalize(),
3590
+ stopWithoutFinalizing: () => reportHistoryWorker.stopWithoutFinalizing()
3591
+ });
3592
+ }
3482
3593
  const scenarioStartInfos = selectedScenarios.map((scenario, index) => {
3483
3594
  const startInfo = {
3484
3595
  scenarioName: scenario.name,
@@ -3522,14 +3633,14 @@ export class LoadStrikeRunner {
3522
3633
  await init(baseContext, this.options.infraConfig ?? {});
3523
3634
  }
3524
3635
  }
3525
- await this.initializeSinks(sinkStates, baseContext, this.options.infraConfig ?? {}, sinkRetryCount, sinkRetryBackoffMs, sinkErrors);
3636
+ await this.initializeSinks(sinkStates, baseContext, this.options.infraConfig ?? {}, sinkRetryCount, sinkRetryBackoffMs, sinkErrors, testInfo.sessionId, runLogger);
3526
3637
  for (const plugin of plugins) {
3527
3638
  const start = resolveWorkerPluginStart(plugin);
3528
3639
  if (start) {
3529
3640
  await start(sessionInfo);
3530
3641
  }
3531
3642
  }
3532
- await this.startSinks(sinkStates, sessionInfo, sinkRetryCount, sinkRetryBackoffMs, sinkErrors);
3643
+ await this.startSinks(sinkStates, sessionInfo, sinkRetryCount, sinkRetryBackoffMs, sinkErrors, testInfo.sessionId, runLogger);
3533
3644
  const iterationObservationRunId = String(this.internalOptions.iterationObservationRunId
3534
3645
  ?? sessionInfo.portalReportingRunId
3535
3646
  ?? sessionInfo.PortalReportingRunId
@@ -3560,7 +3671,10 @@ export class LoadStrikeRunner {
3560
3671
  expectedResultOwnerCount64: iterationObservationExpectedResultOwnerCount64,
3561
3672
  processGroup: iterationObservationProcessGroup,
3562
3673
  settings: resolveIterationObservationSettings(this.options),
3563
- sinks: reporterIterationObservationSinks
3674
+ sinks: reporterIterationObservationSinks,
3675
+ sinkRetryCount,
3676
+ sinkRetryBackoffMs,
3677
+ logger: runLogger
3564
3678
  });
3565
3679
  const emitRealtimeSnapshot = async () => {
3566
3680
  if (realtimeInFlight) {
@@ -3572,7 +3686,7 @@ export class LoadStrikeRunner {
3572
3686
  .map((value) => value.build(scenarioDurationsMs.get(value.scenarioName) ?? Math.max(Date.now() - started.getTime(), 0)))
3573
3687
  .sort((left, right) => left.sortIndex - right.sortIndex);
3574
3688
  const metricsSnapshot = collectMetricStats(allRegisteredMetrics, Date.now() - started.getTime());
3575
- await this.emitRealtimeStats(sinkStates, snapshot, metricsSnapshot, sinkRetryCount, sinkRetryBackoffMs, sinkErrors);
3689
+ await this.emitRealtimeStats(sinkStates, snapshot, metricsSnapshot, sinkRetryCount, sinkRetryBackoffMs, sinkErrors, testInfo.sessionId, runLogger);
3576
3690
  if (toBoolean(this.options.displayConsoleMetrics, true)) {
3577
3691
  const requestCount = snapshot.reduce((sum, value) => sum + value.allRequestCount, 0);
3578
3692
  const okCount = snapshot.reduce((sum, value) => sum + value.allOkCount, 0);
@@ -3585,7 +3699,7 @@ export class LoadStrikeRunner {
3585
3699
  realtimeInFlight = false;
3586
3700
  }
3587
3701
  };
3588
- const reportingIntervalMs = Math.max(Math.trunc((this.options.reportingIntervalSeconds ?? 5) * 1000), 1);
3702
+ const reportingIntervalMs = boundedReportingIntervalMs(this.options.reportingIntervalSeconds ?? 5);
3589
3703
  const triggerRealtimeSnapshot = () => {
3590
3704
  if (realtimeCurrent) {
3591
3705
  return;
@@ -3672,6 +3786,7 @@ export class LoadStrikeRunner {
3672
3786
  iterationObservationRunId,
3673
3787
  iterationObservationResultOwnerId,
3674
3788
  iterationObservationProcessGroup,
3789
+ reportHistoryLifecycle,
3675
3790
  executeScenarioInvocation: (targetScenario, context, operation) => this.executeScenarioInvocation(targetScenario, context, operation),
3676
3791
  invokeBeforeScenario: (runtimePolicies, scenarioName) => this.invokeBeforeScenario(runtimePolicies, scenarioName, policyErrors, runtimePolicyErrorMode),
3677
3792
  invokeAfterScenario: (runtimePolicies, scenarioName, stats) => this.invokeAfterScenario(runtimePolicies, scenarioName, stats, policyErrors, runtimePolicyErrorMode),
@@ -3759,12 +3874,17 @@ export class LoadStrikeRunner {
3759
3874
  ...iterationObservationReporter.buildWarnings()
3760
3875
  ];
3761
3876
  result.pluginsData = mergePluginData(result.pluginsData, await this.collectPluginData(plugins, attachRunResultAliases(result), pluginLifecycleErrors));
3877
+ if (reportHistoryCollector) {
3878
+ localReportInput = {
3879
+ history: reportHistoryCollector.toProjection()
3880
+ };
3881
+ }
3762
3882
  const finalizedResult = attachRunResultAliases(result);
3763
3883
  finalizedResult.logFiles = mergeStringArrays(finalizedResult.logFiles, loggerSetup.logFiles);
3764
- finalizedResult.reportFiles = this.writeReports(finalizedResult);
3884
+ finalizedResult.reportFiles = this.writeReports(finalizedResult, localReportInput);
3765
3885
  finalizedResult.disabledSinks = sinkStates.filter((x) => x.disabled).map((x) => x.name);
3766
- await this.emitRunResult(sinkStates, finalizedResult, sinkRetryCount, sinkRetryBackoffMs, sinkErrors);
3767
- await this.stopSinks(sinkStates, sinkRetryCount, sinkRetryBackoffMs, sinkErrors);
3886
+ await this.emitRunResult(sinkStates, finalizedResult, sinkRetryCount, sinkRetryBackoffMs, sinkErrors, testInfo.sessionId, runLogger);
3887
+ await this.stopSinks(sinkStates, sinkRetryCount, sinkRetryBackoffMs, sinkErrors, testInfo.sessionId, runLogger);
3768
3888
  sinksStopped = true;
3769
3889
  finalizedResult.disabledSinks = sinkStates.filter((x) => x.disabled).map((x) => x.name);
3770
3890
  finalizedResult.sinkErrors = sinkErrors
@@ -3772,12 +3892,13 @@ export class LoadStrikeRunner {
3772
3892
  return finalizedResult;
3773
3893
  }
3774
3894
  finally {
3895
+ reportHistoryLifecycle?.stopWithoutFinalizing();
3775
3896
  await stopRealtimeReporting();
3776
3897
  if (!iterationObservationsFinalized) {
3777
3898
  await iterationObservationReporter.sealAndDrain().catch(() => { });
3778
3899
  }
3779
3900
  if (!sinksStopped) {
3780
- await this.stopSinks(sinkStates, sinkRetryCount, sinkRetryBackoffMs, sinkErrors);
3901
+ await this.stopSinks(sinkStates, sinkRetryCount, sinkRetryBackoffMs, sinkErrors, testInfo.sessionId, runLogger);
3781
3902
  }
3782
3903
  if (!pluginsStopped) {
3783
3904
  await this.stopPlugins(plugins, pluginLifecycleErrors, runLogger);
@@ -4085,9 +4206,9 @@ export class LoadStrikeRunner {
4085
4206
  }
4086
4207
  return filtered;
4087
4208
  }
4088
- async initializeSinks(sinkStates, context, infraConfig, retryCount, retryBackoffMs, sinkErrors) {
4209
+ async initializeSinks(sinkStates, context, infraConfig, retryCount, retryBackoffMs, sinkErrors, runId, logger) {
4089
4210
  for (const state of sinkStates) {
4090
- await this.invokeSinkAction(state, "init", retryCount, retryBackoffMs, sinkErrors, false, true, async () => {
4211
+ await this.invokeSinkAction(state, "init", retryCount, retryBackoffMs, sinkErrors, runId, logger, false, true, async () => {
4091
4212
  const init = resolveSinkInit(state.sink);
4092
4213
  if (init) {
4093
4214
  await init(context, infraConfig);
@@ -4095,9 +4216,9 @@ export class LoadStrikeRunner {
4095
4216
  });
4096
4217
  }
4097
4218
  }
4098
- async startSinks(sinkStates, session, retryCount, retryBackoffMs, sinkErrors) {
4219
+ async startSinks(sinkStates, session, retryCount, retryBackoffMs, sinkErrors, runId, logger) {
4099
4220
  for (const state of sinkStates) {
4100
- await this.invokeSinkAction(state, "start", retryCount, retryBackoffMs, sinkErrors, false, true, async () => {
4221
+ await this.invokeSinkAction(state, "start", retryCount, retryBackoffMs, sinkErrors, runId, logger, false, true, async () => {
4101
4222
  const start = resolveSinkStart(state.sink);
4102
4223
  if (start) {
4103
4224
  await start(session);
@@ -4105,9 +4226,9 @@ export class LoadStrikeRunner {
4105
4226
  });
4106
4227
  }
4107
4228
  }
4108
- async emitRealtimeStats(sinkStates, scenarioStats, metrics, retryCount, retryBackoffMs, sinkErrors) {
4229
+ async emitRealtimeStats(sinkStates, scenarioStats, metrics, retryCount, retryBackoffMs, sinkErrors, runId, logger) {
4109
4230
  for (const state of sinkStates) {
4110
- await this.invokeSinkAction(state, "realtime", retryCount, retryBackoffMs, sinkErrors, false, true, async () => {
4231
+ await this.invokeSinkAction(state, "realtime", retryCount, retryBackoffMs, sinkErrors, runId, logger, false, true, async () => {
4111
4232
  const saveRealtimeStats = resolveSinkSaveRealtimeStats(state.sink);
4112
4233
  if (saveRealtimeStats) {
4113
4234
  await saveRealtimeStats(scenarioStats);
@@ -4119,10 +4240,9 @@ export class LoadStrikeRunner {
4119
4240
  });
4120
4241
  }
4121
4242
  }
4122
- async stopSinks(sinkStates, retryCount, retryBackoffMs, sinkErrors) {
4123
- const shutdownRetryCount = 0;
4243
+ async stopSinks(sinkStates, retryCount, retryBackoffMs, sinkErrors, runId, logger) {
4124
4244
  for (const state of sinkStates) {
4125
- await this.invokeSinkAction(state, "stop", shutdownRetryCount, retryBackoffMs, sinkErrors, true, true, async () => {
4245
+ await this.invokeSinkAction(state, "stop", retryCount, retryBackoffMs, sinkErrors, runId, logger, true, true, async () => {
4126
4246
  const stop = resolveSinkStop(state.sink);
4127
4247
  if (stop) {
4128
4248
  await stop();
@@ -4130,15 +4250,15 @@ export class LoadStrikeRunner {
4130
4250
  });
4131
4251
  const dispose = resolveSinkDispose(state.sink);
4132
4252
  if (dispose) {
4133
- await this.invokeSinkAction(state, "dispose", shutdownRetryCount, retryBackoffMs, sinkErrors, true, true, async () => {
4253
+ await this.invokeSinkAction(state, "dispose", retryCount, retryBackoffMs, sinkErrors, runId, logger, true, true, async () => {
4134
4254
  await dispose();
4135
4255
  });
4136
4256
  }
4137
4257
  }
4138
4258
  }
4139
- async emitRunResult(sinkStates, result, retryCount, retryBackoffMs, sinkErrors) {
4259
+ async emitRunResult(sinkStates, result, retryCount, retryBackoffMs, sinkErrors, runId, logger) {
4140
4260
  for (const state of sinkStates) {
4141
- await this.invokeSinkAction(state, "run-result", retryCount, retryBackoffMs, sinkErrors, false, false, async () => {
4261
+ await this.invokeSinkAction(state, "run-result", retryCount, retryBackoffMs, sinkErrors, runId, logger, false, false, async () => {
4142
4262
  const saveRunResult = resolveSinkSaveRunResult(state.sink);
4143
4263
  if (saveRunResult) {
4144
4264
  await saveRunResult(result);
@@ -4146,23 +4266,49 @@ export class LoadStrikeRunner {
4146
4266
  });
4147
4267
  }
4148
4268
  }
4149
- async invokeSinkAction(state, phase, retryCount, retryBackoffMs, sinkErrors, ignoreDisabled, disableOnFailure, action) {
4269
+ async invokeSinkAction(state, phase, retryCount, retryBackoffMs, sinkErrors, runId, logger, ignoreDisabled, disableOnFailure, action) {
4150
4270
  if (state.disabled && !ignoreDisabled) {
4151
4271
  return;
4152
4272
  }
4153
- let attempts = 0;
4154
- while (attempts <= retryCount) {
4155
- attempts += 1;
4273
+ const maximumAttempts = normalizeSinkRetryCount(retryCount) + 1;
4274
+ const backoffMs = normalizeSinkRetryBackoffMs(retryBackoffMs);
4275
+ for (let attempts = 1; attempts <= maximumAttempts; attempts += 1) {
4156
4276
  try {
4157
4277
  await action();
4278
+ if (attempts > 1) {
4279
+ logIterationObservationRecovery(logger, {
4280
+ sinkName: state.name,
4281
+ operation: "reporting-sink-action",
4282
+ phase,
4283
+ runId,
4284
+ resultOwnerId: "",
4285
+ observationCount: 0,
4286
+ attempt: attempts,
4287
+ maximumAttempts,
4288
+ nextDelayMs: 0
4289
+ });
4290
+ }
4158
4291
  return;
4159
4292
  }
4160
4293
  catch (error) {
4161
- if (attempts > retryCount) {
4294
+ const exhausted = attempts >= maximumAttempts;
4295
+ const nextDelayMs = exhausted ? 0 : sinkRetryDelayMs(backoffMs, attempts);
4296
+ logIterationObservationFailure(logger, exhausted ? "error" : "warn", {
4297
+ sinkName: state.name,
4298
+ operation: "reporting-sink-action",
4299
+ phase,
4300
+ runId,
4301
+ resultOwnerId: "",
4302
+ observationCount: 0,
4303
+ attempt: attempts,
4304
+ maximumAttempts,
4305
+ nextDelayMs
4306
+ }, error);
4307
+ if (exhausted) {
4162
4308
  sinkErrors.push({
4163
4309
  sinkName: state.name,
4164
4310
  phase,
4165
- message: String(error ?? "sink action failed"),
4311
+ message: "The reporting sink action failed after retries.",
4166
4312
  attempts
4167
4313
  });
4168
4314
  if (disableOnFailure) {
@@ -4170,9 +4316,7 @@ export class LoadStrikeRunner {
4170
4316
  }
4171
4317
  return;
4172
4318
  }
4173
- if (retryBackoffMs > 0) {
4174
- await sleep(retryBackoffMs * attempts);
4175
- }
4319
+ await waitForSinkRetryDelay(nextDelayMs);
4176
4320
  }
4177
4321
  }
4178
4322
  }
@@ -4226,7 +4370,7 @@ export class LoadStrikeRunner {
4226
4370
  message: String(error ?? "runtime policy callback failed")
4227
4371
  });
4228
4372
  }
4229
- writeReports(result) {
4373
+ writeReports(result, localReportInput = emptyLocalReportInput()) {
4230
4374
  const reportsEnabled = this.options.reportsEnabled ?? true;
4231
4375
  if (!reportsEnabled) {
4232
4376
  return [];
@@ -4251,7 +4395,7 @@ export class LoadStrikeRunner {
4251
4395
  writeFileSync(path, buildDotnetMarkdownReport(nodeStats), "utf8");
4252
4396
  }
4253
4397
  else if (format === "html") {
4254
- writeFileSync(path, buildDotnetHtmlReport(nodeStats), "utf8");
4398
+ writeFileSync(path, buildDotnetHtmlReport(nodeStats, localReportInput), "utf8");
4255
4399
  }
4256
4400
  written.push(path);
4257
4401
  }
@@ -4284,6 +4428,19 @@ function hasPluginRows(value) {
4284
4428
  }
4285
4429
  return value.tables.some((table) => Array.isArray(table.rows) && table.rows.length > 0);
4286
4430
  }
4431
+ function boundedReportingIntervalMs(seconds) {
4432
+ if (!Number.isFinite(seconds) || seconds <= 0) {
4433
+ return 1;
4434
+ }
4435
+ const milliseconds = seconds * 1000;
4436
+ if (!Number.isFinite(milliseconds) || milliseconds >= 2147483647) {
4437
+ return 2147483647;
4438
+ }
4439
+ return Math.max(Math.trunc(milliseconds), 1);
4440
+ }
4441
+ function resolvedReportHistoryCadenceSeconds(seconds) {
4442
+ return boundedReportingIntervalMs(seconds) / 1000;
4443
+ }
4287
4444
  async function executeV2FixedArrivals(args) {
4288
4445
  const { rate, intervalNs, totalArrivals, budget, cancellationToken, shouldStopNow, nextInstanceInfo, runBombingInvocation, logger, scenarioName, deadlineOffsetsNs, tolerancesNs, ownedOrdinals, shardIndex = 0, shardCount = 1, telemetry, segment } = args;
4289
4446
  const segmentStartNs = process.hrtime.bigint();
@@ -4420,7 +4577,15 @@ function maxBigInt(left, right) {
4420
4577
  return left > right ? left : right;
4421
4578
  }
4422
4579
  async function executeScenarioRuntime(args) {
4423
- const { scenario, scenarioIndex, scenarioCount, options, logger, nodeInfo, testInfo, policies, restartIterationMaxAttempts, allRegisteredMetrics, scenarioRuntimes, stepRuntimes, scenarioAccumulators, scenarioDurationsMs, stopTestState, testAbortController, loadEngineV2Budget, loadEngineV2Telemetry, iterationObservationReporter, iterationObservationRunId = testInfo.sessionId, iterationObservationResultOwnerId = "", iterationObservationProcessGroup = 0, executeScenarioInvocation, invokeBeforeScenario, invokeAfterScenario, invokeBeforeStep, invokeAfterStep } = args;
4580
+ const { scenario, scenarioIndex, scenarioCount, options, logger, nodeInfo, testInfo, policies, restartIterationMaxAttempts, allRegisteredMetrics, scenarioRuntimes, stepRuntimes, scenarioAccumulators, scenarioDurationsMs, stopTestState, testAbortController, loadEngineV2Budget, loadEngineV2Telemetry, iterationObservationReporter, iterationObservationRunId = testInfo.sessionId, iterationObservationResultOwnerId = "", iterationObservationProcessGroup = 0, reportHistoryLifecycle, executeScenarioInvocation, invokeBeforeScenario, invokeAfterScenario, invokeBeforeStep, invokeAfterStep } = args;
4581
+ let reportHistoryExecutionEnded = false;
4582
+ const endReportHistoryExecution = async () => {
4583
+ if (reportHistoryExecutionEnded) {
4584
+ return;
4585
+ }
4586
+ reportHistoryExecutionEnded = true;
4587
+ await reportHistoryLifecycle?.scenarioExecutionEnded();
4588
+ };
4424
4589
  const scenarioStartedMs = Date.now();
4425
4590
  const scenarioContextData = {};
4426
4591
  const registeredMetrics = [];
@@ -4581,7 +4746,18 @@ async function executeScenarioRuntime(args) {
4581
4746
  attachScenarioContextAliases(context);
4582
4747
  const startedUtcNs = utcNowNs();
4583
4748
  const startedAtNs = process.hrtime.bigint();
4584
- const reply = await executeScenarioInvocation(scenario, context, operation);
4749
+ let policyFailure;
4750
+ let reply;
4751
+ try {
4752
+ reply = await executeScenarioInvocation(scenario, context, operation);
4753
+ }
4754
+ catch (error) {
4755
+ if (!(error instanceof RuntimePolicyCallbackError)) {
4756
+ throw error;
4757
+ }
4758
+ policyFailure = error;
4759
+ reply = LoadStrikeResponse.fail("runtime_policy_error", "", 0);
4760
+ }
4585
4761
  const observedLatencyNs = maxBigInt(process.hrtime.bigint() - startedAtNs, 0n);
4586
4762
  const completedUtcNs = startedUtcNs + observedLatencyNs;
4587
4763
  const observedLatencyMs = Number(observedLatencyNs) / 1000000;
@@ -4596,7 +4772,8 @@ async function executeScenarioRuntime(args) {
4596
4772
  observedLatencyUs: observedLatencyNs / 1000n,
4597
4773
  reportedLatencyUs: normalizeRawObservationLatencyMicroseconds(resolveRecordedLatency(reply.customLatencyMs, observedLatencyMs)),
4598
4774
  steps: attemptSteps,
4599
- recordedSteps
4775
+ recordedSteps,
4776
+ ...(policyFailure ? { policyFailure } : {})
4600
4777
  };
4601
4778
  };
4602
4779
  const captureAttemptObservation = (operation, globalOrdinal, attemptIndex, isFinalAttempt, attempt, simulationIndex, simulationKind, iterationId, globalSecondaryOrdinal = 0n) => {
@@ -4645,22 +4822,18 @@ async function executeScenarioRuntime(args) {
4645
4822
  const maxAttempts = 1 + (scenario.shouldRestartIterationOnFail() ? restartIterationMaxAttempts : 0);
4646
4823
  while (attempts < maxAttempts && !shouldStopNow()) {
4647
4824
  attempts += 1;
4648
- let attempt;
4649
- try {
4650
- attempt = await runSingleInvocation("Bombing", instanceData, instanceNumber, instanceId, false);
4651
- }
4652
- catch (error) {
4653
- if (error instanceof RuntimePolicyCallbackError) {
4654
- stopScenario = true;
4655
- scenarioAbortController.abort(error);
4656
- }
4657
- throw error;
4658
- }
4659
- const shouldRetry = !attempt.reply.isSuccess
4825
+ const attempt = await runSingleInvocation("Bombing", instanceData, instanceNumber, instanceId, false);
4826
+ const shouldRetry = !attempt.policyFailure
4827
+ && !attempt.reply.isSuccess
4660
4828
  && scenario.shouldRestartIterationOnFail()
4661
4829
  && attempts < maxAttempts
4662
4830
  && !shouldStopNow();
4663
4831
  captureAttemptObservation("Bombing", globalOrdinal, attempts - 1, !shouldRetry, attempt, simulationIndex, simulationKind, iterationId, explicitSecondaryOrdinal);
4832
+ if (attempt.policyFailure) {
4833
+ stopScenario = true;
4834
+ scenarioAbortController.abort(attempt.policyFailure);
4835
+ throw attempt.policyFailure;
4836
+ }
4664
4837
  if (!shouldRetry) {
4665
4838
  for (const step of attempt.recordedSteps) {
4666
4839
  recordStepReply(step.stepName, step.reply, step.observedLatencyMs, step.sortIndex);
@@ -4687,6 +4860,11 @@ async function executeScenarioRuntime(args) {
4687
4860
  const globalOrdinal = nextObservationOrdinal();
4688
4861
  const attempt = await runSingleInvocation("WarmUp", instanceInfo.instanceData, instanceInfo.instanceNumber, instanceInfo.instanceId, false);
4689
4862
  captureAttemptObservation("WarmUp", globalOrdinal, 0, true, attempt, -1, "SingleInvocation");
4863
+ if (attempt.policyFailure) {
4864
+ stopScenario = true;
4865
+ scenarioAbortController.abort(attempt.policyFailure);
4866
+ throw attempt.policyFailure;
4867
+ }
4690
4868
  }
4691
4869
  };
4692
4870
  const executeV2TimedConstant = async (copies, durationNs, ramping, runSimulationInvocation, segment) => {
@@ -5228,6 +5406,7 @@ async function executeScenarioRuntime(args) {
5228
5406
  })));
5229
5407
  await invokeBeforeScenario(policies, scenario.name);
5230
5408
  await runWarmUpAsync();
5409
+ reportHistoryLifecycle?.scenarioBombingStarted();
5231
5410
  accumulator.setCurrentOperation("Bombing");
5232
5411
  const simulations = scenario.getSimulations();
5233
5412
  if (!simulations.length) {
@@ -5261,6 +5440,7 @@ async function executeScenarioRuntime(args) {
5261
5440
  }
5262
5441
  }
5263
5442
  accumulator.setCurrentOperation(stopScenario || stopTestState.value ? "Stop" : "Complete");
5443
+ await endReportHistoryExecution();
5264
5444
  await invokeAfterScenario(policies, scenario.name, runtime);
5265
5445
  }
5266
5446
  catch (error) {
@@ -5277,6 +5457,7 @@ async function executeScenarioRuntime(args) {
5277
5457
  }
5278
5458
  }
5279
5459
  finally {
5460
+ await endReportHistoryExecution();
5280
5461
  scenarioDurationsMs.set(scenario.name, Math.max(Date.now() - scenarioStartedMs, 0));
5281
5462
  try {
5282
5463
  await scenario.invokeClean({
@@ -5357,10 +5538,17 @@ async function waitForScenarioTasks(tasks, scenarioName, timeoutSeconds, logger,
5357
5538
  throwPolicyFailure(await settled);
5358
5539
  return;
5359
5540
  }
5360
- const completed = await Promise.race([
5361
- settled.then(() => true),
5362
- delayWithAbort(Math.trunc(timeoutSeconds * 1000), signal).then(() => false)
5363
- ]);
5541
+ const timeoutController = new AbortController();
5542
+ let completed;
5543
+ try {
5544
+ completed = await Promise.race([
5545
+ settled.then(() => true),
5546
+ delayWithAbort(Math.trunc(timeoutSeconds * 1000), combineAbortSignals(signal, timeoutController.signal)).then(() => false)
5547
+ ]);
5548
+ }
5549
+ finally {
5550
+ timeoutController.abort();
5551
+ }
5364
5552
  if (!completed) {
5365
5553
  if (signal.reason instanceof RuntimePolicyCallbackError) {
5366
5554
  throw signal.reason;
@@ -7203,6 +7391,11 @@ function resolveClusterExecutionMode(options) {
7203
7391
  }
7204
7392
  return "single";
7205
7393
  }
7394
+ function isDistributedReportHistoryExecution(clusterMode, options) {
7395
+ return clusterMode !== "single"
7396
+ || options.loadEngineV2SegmentLifecycleOverride !== undefined
7397
+ || Math.max(Math.trunc(options.clusterShardCount ?? 1), 1) > 1;
7398
+ }
7206
7399
  function buildEmptyNodeStats(args) {
7207
7400
  return attachNodeStatsAliases({
7208
7401
  startedUtc: args.startedUtc,
@@ -9422,11 +9615,23 @@ function readRuntimeTrackingId(payload, selector) {
9422
9615
  return null;
9423
9616
  }
9424
9617
  let current = body;
9425
- for (const segment of selector.slice("json:".length).trim().replace(/^\$\./, "").split(".").filter(Boolean)) {
9618
+ const path = selector.slice("json:".length).trim().replace(/^\$\./, "");
9619
+ let segments;
9620
+ try {
9621
+ segments = runtimeSafeJsonPathSegments(path);
9622
+ }
9623
+ catch {
9624
+ return null;
9625
+ }
9626
+ for (const segment of segments) {
9426
9627
  if (!current || typeof current !== "object" || Array.isArray(current)) {
9427
9628
  return null;
9428
9629
  }
9429
- current = current[segment];
9630
+ const record = current;
9631
+ if (!Object.prototype.hasOwnProperty.call(record, segment)) {
9632
+ return null;
9633
+ }
9634
+ current = runtimeReadOwnJsonProperty(record, segment);
9430
9635
  }
9431
9636
  return current == null ? null : String(current);
9432
9637
  }
@@ -9450,23 +9655,57 @@ function runtimeParseBodyAsObject(body) {
9450
9655
  }
9451
9656
  }
9452
9657
  function setRuntimeJsonPathValue(body, path, value) {
9453
- const target = body && typeof body === "object" && !Array.isArray(body)
9454
- ? { ...body }
9455
- : {};
9456
- const segments = path.split(".").filter(Boolean);
9658
+ const target = runtimeCloneJsonRecord(body);
9659
+ const segments = runtimeSafeJsonPathSegments(path);
9457
9660
  if (!segments.length) {
9458
9661
  return target;
9459
9662
  }
9460
9663
  let current = target;
9461
9664
  for (let i = 0; i < segments.length - 1; i += 1) {
9462
9665
  const segment = segments[i];
9463
- const next = current[segment];
9666
+ const next = runtimeReadOwnJsonProperty(current, segment);
9667
+ let child;
9464
9668
  if (!next || typeof next !== "object" || Array.isArray(next)) {
9465
- current[segment] = {};
9669
+ child = {};
9466
9670
  }
9467
- current = current[segment];
9671
+ else {
9672
+ child = runtimeCloneJsonRecord(next);
9673
+ }
9674
+ runtimeDefineJsonProperty(current, segment, child);
9675
+ current = child;
9676
+ }
9677
+ runtimeDefineJsonProperty(current, segments[segments.length - 1], value);
9678
+ return target;
9679
+ }
9680
+ const FORBIDDEN_RUNTIME_JSON_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
9681
+ function runtimeSafeJsonPathSegments(path) {
9682
+ const segments = path.split(".").filter(Boolean);
9683
+ const forbidden = segments.find((segment) => FORBIDDEN_RUNTIME_JSON_PATH_SEGMENTS.has(segment));
9684
+ if (forbidden) {
9685
+ throw new Error(`Tracking selector contains forbidden JSON path segment '${forbidden}'.`);
9686
+ }
9687
+ return segments;
9688
+ }
9689
+ function runtimeDefineJsonProperty(target, key, value) {
9690
+ Object.defineProperty(target, key, {
9691
+ configurable: true,
9692
+ enumerable: true,
9693
+ value,
9694
+ writable: true
9695
+ });
9696
+ }
9697
+ function runtimeReadOwnJsonProperty(target, key) {
9698
+ const descriptor = Object.getOwnPropertyDescriptor(target, key);
9699
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
9700
+ }
9701
+ function runtimeCloneJsonRecord(value) {
9702
+ const target = {};
9703
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
9704
+ return target;
9705
+ }
9706
+ for (const [key, entry] of Object.entries(value)) {
9707
+ runtimeDefineJsonProperty(target, key, entry);
9468
9708
  }
9469
- current[segments[segments.length - 1]] = value;
9470
9709
  return target;
9471
9710
  }
9472
9711
  function asTrackingRecord(value) {
@@ -9476,8 +9715,8 @@ function asTrackingRecord(value) {
9476
9715
  }
9477
9716
  function pickTrackingValue(source, ...keys) {
9478
9717
  for (const key of keys) {
9479
- if (key in source) {
9480
- return source[key];
9718
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
9719
+ return runtimeReadOwnJsonProperty(source, key);
9481
9720
  }
9482
9721
  }
9483
9722
  return undefined;
@@ -9731,26 +9970,10 @@ function createDefaultLogger(logFilePath) {
9731
9970
  function wrapLoggerWithMinimumLevel(baseLogger, minimumLogLevel) {
9732
9971
  const threshold = logLevelOrder(minimumLogLevel);
9733
9972
  return {
9734
- debug: (message) => {
9735
- if (threshold <= 0) {
9736
- baseLogger.debug(message);
9737
- }
9738
- },
9739
- info: (message) => {
9740
- if (threshold <= 1) {
9741
- baseLogger.info(message);
9742
- }
9743
- },
9744
- warn: (message) => {
9745
- if (threshold <= 2) {
9746
- baseLogger.warn(message);
9747
- }
9748
- },
9749
- error: (message) => {
9750
- if (threshold <= 3) {
9751
- baseLogger.error(message);
9752
- }
9753
- }
9973
+ debug: (message) => threshold <= 0 ? baseLogger.debug(message) : undefined,
9974
+ info: (message) => threshold <= 1 ? baseLogger.info(message) : undefined,
9975
+ warn: (message) => threshold <= 2 ? baseLogger.warn(message) : undefined,
9976
+ error: (message) => threshold <= 3 ? baseLogger.error(message) : undefined
9754
9977
  };
9755
9978
  }
9756
9979
  function formatDefaultLoggerLine(level, message) {
@@ -11004,6 +11227,7 @@ function buildGroupedCorrelationRows(rows) {
11004
11227
  LatencyMinMs: latencySamples.length ? formatOptionalLatency(latencySamples[0]) : "",
11005
11228
  LatencyMeanMs: latencySamples.length ? formatOptionalLatency(latencySamples.reduce((sum, value) => sum + value, 0) / latencySamples.length) : "",
11006
11229
  LatencyP50Ms: latencySamples.length ? formatOptionalLatency(percentile(latencySamples, 0.5)) : "",
11230
+ LatencyP75Ms: latencySamples.length ? formatOptionalLatency(percentile(latencySamples, 0.75)) : "",
11007
11231
  LatencyP80Ms: latencySamples.length ? formatOptionalLatency(percentile(latencySamples, 0.8)) : "",
11008
11232
  LatencyP85Ms: latencySamples.length ? formatOptionalLatency(percentile(latencySamples, 0.85)) : "",
11009
11233
  LatencyP90Ms: latencySamples.length ? formatOptionalLatency(percentile(latencySamples, 0.9)) : "",
@@ -11216,6 +11440,8 @@ export const __loadstrikeTestExports = {
11216
11440
  buildThresholdCheckExpression,
11217
11441
  buildTrackingLeaseKey,
11218
11442
  buildTrackingRunNamespace,
11443
+ boundedReportingIntervalMs,
11444
+ resolvedReportHistoryCadenceSeconds,
11219
11445
  clusterNodeResultToNodeStats,
11220
11446
  combineAbortSignals,
11221
11447
  computeScenarioRequestCount,
@@ -11235,6 +11461,7 @@ export const __loadstrikeTestExports = {
11235
11461
  formatUtcReportTimestamp,
11236
11462
  hasPluginRows,
11237
11463
  inferRuntimeLegacyHttpResponseSource,
11464
+ isDistributedReportHistoryExecution,
11238
11465
  isComparisonFailed,
11239
11466
  loadJsonObject,
11240
11467
  logLevelOrder,
@@ -11264,6 +11491,7 @@ export const __loadstrikeTestExports = {
11264
11491
  parseStrictBooleanToken,
11265
11492
  percentile,
11266
11493
  pickOptionalTrackingSelectorString,
11494
+ pickTrackingValue,
11267
11495
  pickTrackingNumber,
11268
11496
  produceOrConsumeTrackingPayload,
11269
11497
  readConfiguredSinkName,