@loadstrike/loadstrike-sdk 1.0.23201 → 1.0.23601

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.
@@ -2487,7 +2487,7 @@ class LoadStrikeRunner {
2487
2487
  const scenarioAccumulators = new Map();
2488
2488
  const scenarioDurationsMs = new Map();
2489
2489
  const stepStats = new Map();
2490
- const sinks = this.options.reportingSinks ?? [];
2490
+ const sinks = (this.options.reportingSinks ?? []).map((sink) => (0, sinks_js_1.cloneReportingSinkForRun)(sink));
2491
2491
  const sinkStates = sinks.map((sink, index) => ({
2492
2492
  sink,
2493
2493
  disabled: false,
@@ -2521,6 +2521,7 @@ class LoadStrikeRunner {
2521
2521
  let sinksStopped = false;
2522
2522
  let realtimeTimer = null;
2523
2523
  let realtimeInFlight = false;
2524
+ let realtimeCurrent = null;
2524
2525
  let licenseClient = null;
2525
2526
  let licensePayload = null;
2526
2527
  let licenseSession = null;
@@ -2616,9 +2617,38 @@ class LoadStrikeRunner {
2616
2617
  }
2617
2618
  };
2618
2619
  const reportingIntervalMs = Math.max(Math.trunc((this.options.reportingIntervalSeconds ?? 5) * 1000), 1);
2620
+ const triggerRealtimeSnapshot = () => {
2621
+ if (realtimeCurrent) {
2622
+ return;
2623
+ }
2624
+ const current = emitRealtimeSnapshot()
2625
+ .catch((error) => {
2626
+ sinkErrors.push({
2627
+ sinkName: "realtime",
2628
+ phase: "realtime",
2629
+ message: String(error ?? "realtime reporting failed"),
2630
+ attempts: 1
2631
+ });
2632
+ })
2633
+ .finally(() => {
2634
+ if (realtimeCurrent === current) {
2635
+ realtimeCurrent = null;
2636
+ }
2637
+ });
2638
+ realtimeCurrent = current;
2639
+ };
2640
+ const stopRealtimeReporting = async () => {
2641
+ if (realtimeTimer) {
2642
+ clearInterval(realtimeTimer);
2643
+ realtimeTimer = null;
2644
+ }
2645
+ if (realtimeCurrent) {
2646
+ await realtimeCurrent;
2647
+ }
2648
+ };
2619
2649
  if (sinkStates.length > 0 || toBoolean(this.options.displayConsoleMetrics, true)) {
2620
2650
  realtimeTimer = setInterval(() => {
2621
- void emitRealtimeSnapshot();
2651
+ triggerRealtimeSnapshot();
2622
2652
  }, reportingIntervalMs);
2623
2653
  if (typeof realtimeTimer.unref === "function") {
2624
2654
  realtimeTimer.unref();
@@ -2706,6 +2736,7 @@ class LoadStrikeRunner {
2706
2736
  failedCorrelationRows: buildDetailedFailedCorrelationRows()
2707
2737
  };
2708
2738
  }
2739
+ await stopRealtimeReporting();
2709
2740
  result.pluginsData = mergePluginData(result.pluginsData, await this.collectPluginData(plugins, attachRunResultAliases(result), pluginLifecycleErrors));
2710
2741
  const finalizedResult = attachRunResultAliases(result);
2711
2742
  finalizedResult.logFiles = mergeStringArrays(finalizedResult.logFiles, loggerSetup.logFiles);
@@ -2720,9 +2751,7 @@ class LoadStrikeRunner {
2720
2751
  return finalizedResult;
2721
2752
  }
2722
2753
  finally {
2723
- if (realtimeTimer) {
2724
- clearInterval(realtimeTimer);
2725
- }
2754
+ await stopRealtimeReporting();
2726
2755
  if (!sinksStopped) {
2727
2756
  await this.stopSinks(sinkStates, sinkRetryCount, sinkRetryBackoffMs, sinkErrors);
2728
2757
  }
@@ -3043,14 +3072,14 @@ class LoadStrikeRunner {
3043
3072
  }
3044
3073
  catch (error) {
3045
3074
  if (attempts > retryCount) {
3075
+ sinkErrors.push({
3076
+ sinkName: state.name,
3077
+ phase,
3078
+ message: String(error ?? "sink action failed"),
3079
+ attempts
3080
+ });
3046
3081
  if (disableOnFailure) {
3047
3082
  state.disabled = true;
3048
- sinkErrors.push({
3049
- sinkName: state.name,
3050
- phase,
3051
- message: String(error ?? "sink action failed"),
3052
- attempts
3053
- });
3054
3083
  }
3055
3084
  return;
3056
3085
  }
@@ -4112,7 +4141,8 @@ function attachSessionStartInfoAliases(session) {
4112
4141
  ScenarioNames: "scenarioNames",
4113
4142
  Scenarios: "scenarios",
4114
4143
  RunToken: "runToken",
4115
- PortalReportingIngestUrl: "portalReportingIngestUrl"
4144
+ PortalReportingIngestUrl: "portalReportingIngestUrl",
4145
+ PortalReportingRunId: "portalReportingRunId"
4116
4146
  });
4117
4147
  return session;
4118
4148
  }
@@ -4122,10 +4152,21 @@ function attachPortalReportingSession(sinkSession, sessionInfo, licenseClient, l
4122
4152
  return;
4123
4153
  }
4124
4154
  const ingestUrl = licenseClient.portalReportingIngestUrl();
4155
+ const runId = buildPortalReportingRunId(sessionInfo.testInfo.sessionId);
4125
4156
  sinkSession.runToken = runToken;
4126
4157
  sinkSession.portalReportingIngestUrl = ingestUrl;
4158
+ sinkSession.portalReportingRunId = runId;
4127
4159
  sessionInfo.runToken = runToken;
4128
4160
  sessionInfo.portalReportingIngestUrl = ingestUrl;
4161
+ sessionInfo.portalReportingRunId = runId;
4162
+ }
4163
+ function buildPortalReportingRunId(sessionId) {
4164
+ const sessionPart = String(sessionId ?? "")
4165
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
4166
+ .replace(/^-+|-+$/g, "")
4167
+ .slice(0, 80);
4168
+ const uniquePart = generateRuntimeSessionId().slice(0, 16);
4169
+ return sessionPart ? `${sessionPart}-${uniquePart}` : uniquePart;
4129
4170
  }
4130
4171
  function attachScenarioInitContextAliases(context) {
4131
4172
  context.nodeInfo = attachNodeInfoAliases(context.nodeInfo);
package/dist/cjs/sinks.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.__loadstrikeTestExports = exports.OtelCollectorReportingSink = exports.SplunkReportingSink = exports.DatadogReportingSink = exports.TimescaleDbReportingSink = exports.GrafanaLokiReportingSink = exports.InfluxDbReportingSink = exports.PortalReportingSink = exports.CompositeReportingSink = exports.ConsoleReportingSink = exports.MemoryReportingSink = exports.OtelCollectorReportingSinkOptions = exports.SplunkReportingSinkOptions = exports.DatadogReportingSinkOptions = exports.TimescaleDbReportingSinkOptions = exports.GrafanaLokiReportingSinkOptions = exports.InfluxDbReportingSinkOptions = void 0;
4
+ exports.cloneReportingSinkForRun = cloneReportingSinkForRun;
4
5
  const runtime_js_1 = require("./runtime.js");
5
6
  const node_crypto_1 = require("node:crypto");
6
7
  const pg_1 = require("pg");
@@ -395,10 +396,14 @@ class PortalReportingSink {
395
396
  this.session = null;
396
397
  this.runToken = "";
397
398
  this.ingestUrl = "";
399
+ this.optionsInput = { ...options };
398
400
  const source = asRecord(options);
399
401
  this.timeoutMs = resolveTimeoutMs(optionNumber(source, "timeoutSeconds", "TimeoutSeconds"), optionNumber(source, "timeoutMs", "TimeoutMs"));
400
402
  this.fetchImpl = pickRecordValue(source, "fetchImpl", "FetchImpl") ?? fetch;
401
403
  }
404
+ cloneForRun() {
405
+ return new PortalReportingSink(this.optionsInput);
406
+ }
402
407
  init(context, _infraConfig) {
403
408
  this.baseContext = cloneBaseContext(context);
404
409
  }
@@ -411,7 +416,9 @@ class PortalReportingSink {
411
416
  if (!this.runToken || !this.ingestUrl) {
412
417
  throw new Error("PortalReportingSink requires a managed portal reporting session.");
413
418
  }
414
- this.session = sinkSessionMetadataFromContext(this.getBaseContext(), session);
419
+ this.session = sinkSessionMetadataFromContext(this.getBaseContext(), session, {
420
+ distinctRunIdFallback: true
421
+ });
415
422
  }
416
423
  Start(session) {
417
424
  this.start(session);
@@ -460,7 +467,7 @@ class PortalReportingSink {
460
467
  if (!events.length) {
461
468
  return;
462
469
  }
463
- await postWithTimeout(this.fetchImpl, this.ingestUrl, {
470
+ const responseBody = await postWithTimeout(this.fetchImpl, this.ingestUrl, {
464
471
  method: "POST",
465
472
  headers: { "Content-Type": "application/json" },
466
473
  body: JSON.stringify({
@@ -468,9 +475,16 @@ class PortalReportingSink {
468
475
  events: events.map((event, index) => portalEventPayload(event, index))
469
476
  })
470
477
  }, this.timeoutMs, "PortalReportingSink");
478
+ validatePortalIngestResponse(responseBody);
471
479
  }
472
480
  }
473
481
  exports.PortalReportingSink = PortalReportingSink;
482
+ function cloneReportingSinkForRun(sink) {
483
+ if (sink instanceof PortalReportingSink) {
484
+ return sink.cloneForRun();
485
+ }
486
+ return sink;
487
+ }
474
488
  class InfluxDbReportingSink {
475
489
  constructor(options = {}) {
476
490
  this.sinkName = "influxdb";
@@ -1619,6 +1633,7 @@ function createReportingEvent(session, occurredUtc, eventType, scenarioName, ste
1619
1633
  eventFields.completed_utc = occurredUtc.toISOString();
1620
1634
  }
1621
1635
  return {
1636
+ runId: session.runId,
1622
1637
  eventType,
1623
1638
  occurredUtc,
1624
1639
  sessionId: session.sessionId,
@@ -1636,7 +1651,7 @@ function createReportingEvent(session, occurredUtc, eventType, scenarioName, ste
1636
1651
  function portalEventPayload(event, index) {
1637
1652
  return {
1638
1653
  eventId: portalEventId(event, index),
1639
- runId: event.sessionId,
1654
+ runId: event.runId,
1640
1655
  eventType: event.eventType,
1641
1656
  occurredUtc: event.occurredUtc.toISOString(),
1642
1657
  sessionId: event.sessionId,
@@ -1654,6 +1669,7 @@ function portalEventPayload(event, index) {
1654
1669
  function portalEventId(event, index) {
1655
1670
  const material = JSON.stringify({
1656
1671
  sessionId: event.sessionId,
1672
+ runId: event.runId,
1657
1673
  eventType: event.eventType,
1658
1674
  occurredUtc: event.occurredUtc.toISOString(),
1659
1675
  scenarioName: event.scenarioName ?? "",
@@ -2206,13 +2222,20 @@ function toOtelAnyValue(value) {
2206
2222
  function toUnixSeconds(value) {
2207
2223
  return value.getTime() / 1000;
2208
2224
  }
2209
- function sinkSessionMetadataFromContext(context, session) {
2225
+ function sinkSessionMetadataFromContext(context, session, options = {}) {
2210
2226
  const nodeInfo = context.getNodeInfo?.() ?? context.nodeInfo;
2211
2227
  const testInfo = context.testInfo;
2212
2228
  const sessionStartedUtc = String(session?.startedUtc ?? session?.StartedUtc ?? "");
2213
2229
  const contextStartedUtc = String(testInfo.createdUtc ?? testInfo.CreatedUtc ?? "");
2230
+ const explicitRunId = String(session?.portalReportingRunId ?? session?.PortalReportingRunId ?? "").trim();
2231
+ const sessionId = String(testInfo.sessionId ?? "");
2232
+ const fallbackRunId = options.distinctRunIdFallback
2233
+ ? buildDistinctSinkRunId(sessionId)
2234
+ : sessionId;
2235
+ const runId = explicitRunId || fallbackRunId;
2214
2236
  return {
2215
- sessionId: String(testInfo.sessionId ?? ""),
2237
+ runId: runId || sessionId,
2238
+ sessionId,
2216
2239
  testSuite: String(testInfo.testSuite ?? ""),
2217
2240
  testName: String(testInfo.testName ?? ""),
2218
2241
  clusterId: String(testInfo.clusterId ?? ""),
@@ -2221,6 +2244,14 @@ function sinkSessionMetadataFromContext(context, session) {
2221
2244
  startedUtc: sessionStartedUtc || contextStartedUtc || new Date().toISOString()
2222
2245
  };
2223
2246
  }
2247
+ function buildDistinctSinkRunId(sessionId) {
2248
+ const sessionPart = String(sessionId ?? "")
2249
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
2250
+ .replace(/^-+|-+$/g, "")
2251
+ .slice(0, 80);
2252
+ const uniquePart = (0, node_crypto_1.randomBytes)(8).toString("hex");
2253
+ return sessionPart ? `${sessionPart}-${uniquePart}` : uniquePart;
2254
+ }
2224
2255
  function readInfluxOptionsFromConfig(infraConfig, configurationSectionPath) {
2225
2256
  const section = resolveConfigSection(infraConfig, configurationSectionPath);
2226
2257
  return {
@@ -2691,7 +2722,8 @@ function cloneSessionStartInfo(session) {
2691
2722
  scenarioNames: [...session.scenarioNames],
2692
2723
  scenarios: session.scenarios.map((value) => ({ ...value })),
2693
2724
  runToken: session.runToken,
2694
- portalReportingIngestUrl: session.portalReportingIngestUrl
2725
+ portalReportingIngestUrl: session.portalReportingIngestUrl,
2726
+ portalReportingRunId: session.portalReportingRunId
2695
2727
  };
2696
2728
  }
2697
2729
  function cloneNodeInfo(nodeInfo) {
@@ -2818,15 +2850,61 @@ async function postWithTimeout(fetchImpl, url, init, timeoutMs, sinkName) {
2818
2850
  const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 1));
2819
2851
  try {
2820
2852
  const response = await fetchImpl(url, { ...init, signal: controller.signal });
2853
+ const body = await readResponseBodyText(response);
2821
2854
  if (!response.ok) {
2822
- const body = await response.text();
2823
2855
  throw new Error(`${sinkName} write failed with status ${response.status}: ${body}`);
2824
2856
  }
2857
+ return body;
2825
2858
  }
2826
2859
  finally {
2827
2860
  clearTimeout(timer);
2828
2861
  }
2829
2862
  }
2863
+ async function readResponseBodyText(response) {
2864
+ const candidate = response;
2865
+ if (typeof candidate.text !== "function") {
2866
+ return "";
2867
+ }
2868
+ return String(await candidate.text());
2869
+ }
2870
+ function validatePortalIngestResponse(responseBody) {
2871
+ const text = String(responseBody ?? "").trim();
2872
+ if (!text) {
2873
+ return;
2874
+ }
2875
+ let parsed;
2876
+ try {
2877
+ parsed = JSON.parse(text);
2878
+ }
2879
+ catch {
2880
+ return;
2881
+ }
2882
+ if (!isRecord(parsed)) {
2883
+ return;
2884
+ }
2885
+ const rejected = readPortalIngestCount(parsed, "rejectedCount", "RejectedCount", "rejected");
2886
+ if (rejected <= 0) {
2887
+ return;
2888
+ }
2889
+ const accepted = readPortalIngestCount(parsed, "acceptedCount", "AcceptedCount", "accepted");
2890
+ const duplicate = readPortalIngestCount(parsed, "duplicateCount", "DuplicateCount", "duplicate");
2891
+ throw new Error(`PortalReportingSink ingest rejected ${rejected} reporting event(s). Accepted ${accepted}, duplicate ${duplicate}.`);
2892
+ }
2893
+ function readPortalIngestCount(source, ...keys) {
2894
+ for (const key of keys) {
2895
+ const value = source[key];
2896
+ if (typeof value === "number" && Number.isFinite(value)) {
2897
+ return Math.max(Math.trunc(value), 0);
2898
+ }
2899
+ if (typeof value === "string") {
2900
+ const parsed = Number.parseInt(value, 10);
2901
+ if (Number.isFinite(parsed)) {
2902
+ return Math.max(parsed, 0);
2903
+ }
2904
+ }
2905
+ }
2906
+ return 0;
2907
+ }
2830
2908
  exports.__loadstrikeTestExports = {
2831
2909
  GrafanaLokiReportingSink,
2832
2910
  InfluxDbReportingSink,
@@ -7,7 +7,7 @@ import { DistributedClusterAgent, DistributedClusterCoordinator } from "./cluste
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 { PortalReportingSink } from "./sinks.js";
10
+ import { PortalReportingSink, cloneReportingSinkForRun } from "./sinks.js";
11
11
  export const LoadStrikeNodeType = {
12
12
  SingleNode: "SingleNode",
13
13
  Coordinator: "Coordinator",
@@ -2468,7 +2468,7 @@ export class LoadStrikeRunner {
2468
2468
  const scenarioAccumulators = new Map();
2469
2469
  const scenarioDurationsMs = new Map();
2470
2470
  const stepStats = new Map();
2471
- const sinks = this.options.reportingSinks ?? [];
2471
+ const sinks = (this.options.reportingSinks ?? []).map((sink) => cloneReportingSinkForRun(sink));
2472
2472
  const sinkStates = sinks.map((sink, index) => ({
2473
2473
  sink,
2474
2474
  disabled: false,
@@ -2502,6 +2502,7 @@ export class LoadStrikeRunner {
2502
2502
  let sinksStopped = false;
2503
2503
  let realtimeTimer = null;
2504
2504
  let realtimeInFlight = false;
2505
+ let realtimeCurrent = null;
2505
2506
  let licenseClient = null;
2506
2507
  let licensePayload = null;
2507
2508
  let licenseSession = null;
@@ -2597,9 +2598,38 @@ export class LoadStrikeRunner {
2597
2598
  }
2598
2599
  };
2599
2600
  const reportingIntervalMs = Math.max(Math.trunc((this.options.reportingIntervalSeconds ?? 5) * 1000), 1);
2601
+ const triggerRealtimeSnapshot = () => {
2602
+ if (realtimeCurrent) {
2603
+ return;
2604
+ }
2605
+ const current = emitRealtimeSnapshot()
2606
+ .catch((error) => {
2607
+ sinkErrors.push({
2608
+ sinkName: "realtime",
2609
+ phase: "realtime",
2610
+ message: String(error ?? "realtime reporting failed"),
2611
+ attempts: 1
2612
+ });
2613
+ })
2614
+ .finally(() => {
2615
+ if (realtimeCurrent === current) {
2616
+ realtimeCurrent = null;
2617
+ }
2618
+ });
2619
+ realtimeCurrent = current;
2620
+ };
2621
+ const stopRealtimeReporting = async () => {
2622
+ if (realtimeTimer) {
2623
+ clearInterval(realtimeTimer);
2624
+ realtimeTimer = null;
2625
+ }
2626
+ if (realtimeCurrent) {
2627
+ await realtimeCurrent;
2628
+ }
2629
+ };
2600
2630
  if (sinkStates.length > 0 || toBoolean(this.options.displayConsoleMetrics, true)) {
2601
2631
  realtimeTimer = setInterval(() => {
2602
- void emitRealtimeSnapshot();
2632
+ triggerRealtimeSnapshot();
2603
2633
  }, reportingIntervalMs);
2604
2634
  if (typeof realtimeTimer.unref === "function") {
2605
2635
  realtimeTimer.unref();
@@ -2687,6 +2717,7 @@ export class LoadStrikeRunner {
2687
2717
  failedCorrelationRows: buildDetailedFailedCorrelationRows()
2688
2718
  };
2689
2719
  }
2720
+ await stopRealtimeReporting();
2690
2721
  result.pluginsData = mergePluginData(result.pluginsData, await this.collectPluginData(plugins, attachRunResultAliases(result), pluginLifecycleErrors));
2691
2722
  const finalizedResult = attachRunResultAliases(result);
2692
2723
  finalizedResult.logFiles = mergeStringArrays(finalizedResult.logFiles, loggerSetup.logFiles);
@@ -2701,9 +2732,7 @@ export class LoadStrikeRunner {
2701
2732
  return finalizedResult;
2702
2733
  }
2703
2734
  finally {
2704
- if (realtimeTimer) {
2705
- clearInterval(realtimeTimer);
2706
- }
2735
+ await stopRealtimeReporting();
2707
2736
  if (!sinksStopped) {
2708
2737
  await this.stopSinks(sinkStates, sinkRetryCount, sinkRetryBackoffMs, sinkErrors);
2709
2738
  }
@@ -3024,14 +3053,14 @@ export class LoadStrikeRunner {
3024
3053
  }
3025
3054
  catch (error) {
3026
3055
  if (attempts > retryCount) {
3056
+ sinkErrors.push({
3057
+ sinkName: state.name,
3058
+ phase,
3059
+ message: String(error ?? "sink action failed"),
3060
+ attempts
3061
+ });
3027
3062
  if (disableOnFailure) {
3028
3063
  state.disabled = true;
3029
- sinkErrors.push({
3030
- sinkName: state.name,
3031
- phase,
3032
- message: String(error ?? "sink action failed"),
3033
- attempts
3034
- });
3035
3064
  }
3036
3065
  return;
3037
3066
  }
@@ -4092,7 +4121,8 @@ function attachSessionStartInfoAliases(session) {
4092
4121
  ScenarioNames: "scenarioNames",
4093
4122
  Scenarios: "scenarios",
4094
4123
  RunToken: "runToken",
4095
- PortalReportingIngestUrl: "portalReportingIngestUrl"
4124
+ PortalReportingIngestUrl: "portalReportingIngestUrl",
4125
+ PortalReportingRunId: "portalReportingRunId"
4096
4126
  });
4097
4127
  return session;
4098
4128
  }
@@ -4102,10 +4132,21 @@ function attachPortalReportingSession(sinkSession, sessionInfo, licenseClient, l
4102
4132
  return;
4103
4133
  }
4104
4134
  const ingestUrl = licenseClient.portalReportingIngestUrl();
4135
+ const runId = buildPortalReportingRunId(sessionInfo.testInfo.sessionId);
4105
4136
  sinkSession.runToken = runToken;
4106
4137
  sinkSession.portalReportingIngestUrl = ingestUrl;
4138
+ sinkSession.portalReportingRunId = runId;
4107
4139
  sessionInfo.runToken = runToken;
4108
4140
  sessionInfo.portalReportingIngestUrl = ingestUrl;
4141
+ sessionInfo.portalReportingRunId = runId;
4142
+ }
4143
+ function buildPortalReportingRunId(sessionId) {
4144
+ const sessionPart = String(sessionId ?? "")
4145
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
4146
+ .replace(/^-+|-+$/g, "")
4147
+ .slice(0, 80);
4148
+ const uniquePart = generateRuntimeSessionId().slice(0, 16);
4149
+ return sessionPart ? `${sessionPart}-${uniquePart}` : uniquePart;
4109
4150
  }
4110
4151
  function attachScenarioInitContextAliases(context) {
4111
4152
  context.nodeInfo = attachNodeInfoAliases(context.nodeInfo);
package/dist/esm/sinks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { LoadStrikePluginData as LoadStrikePluginDataModel, LoadStrikePluginDataTable as LoadStrikePluginDataTableModel } from "./runtime.js";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
3
  import { Pool } from "pg";
4
4
  const DEFAULT_INFLUX_CONFIGURATION_SECTION_PATH = "LoadStrike:ReportingSinks:InfluxDb";
5
5
  const DEFAULT_GRAFANA_LOKI_CONFIGURATION_SECTION_PATH = "LoadStrike:ReportingSinks:GrafanaLoki";
@@ -383,10 +383,14 @@ export class PortalReportingSink {
383
383
  this.session = null;
384
384
  this.runToken = "";
385
385
  this.ingestUrl = "";
386
+ this.optionsInput = { ...options };
386
387
  const source = asRecord(options);
387
388
  this.timeoutMs = resolveTimeoutMs(optionNumber(source, "timeoutSeconds", "TimeoutSeconds"), optionNumber(source, "timeoutMs", "TimeoutMs"));
388
389
  this.fetchImpl = pickRecordValue(source, "fetchImpl", "FetchImpl") ?? fetch;
389
390
  }
391
+ cloneForRun() {
392
+ return new PortalReportingSink(this.optionsInput);
393
+ }
390
394
  init(context, _infraConfig) {
391
395
  this.baseContext = cloneBaseContext(context);
392
396
  }
@@ -399,7 +403,9 @@ export class PortalReportingSink {
399
403
  if (!this.runToken || !this.ingestUrl) {
400
404
  throw new Error("PortalReportingSink requires a managed portal reporting session.");
401
405
  }
402
- this.session = sinkSessionMetadataFromContext(this.getBaseContext(), session);
406
+ this.session = sinkSessionMetadataFromContext(this.getBaseContext(), session, {
407
+ distinctRunIdFallback: true
408
+ });
403
409
  }
404
410
  Start(session) {
405
411
  this.start(session);
@@ -448,7 +454,7 @@ export class PortalReportingSink {
448
454
  if (!events.length) {
449
455
  return;
450
456
  }
451
- await postWithTimeout(this.fetchImpl, this.ingestUrl, {
457
+ const responseBody = await postWithTimeout(this.fetchImpl, this.ingestUrl, {
452
458
  method: "POST",
453
459
  headers: { "Content-Type": "application/json" },
454
460
  body: JSON.stringify({
@@ -456,8 +462,15 @@ export class PortalReportingSink {
456
462
  events: events.map((event, index) => portalEventPayload(event, index))
457
463
  })
458
464
  }, this.timeoutMs, "PortalReportingSink");
465
+ validatePortalIngestResponse(responseBody);
459
466
  }
460
467
  }
468
+ export function cloneReportingSinkForRun(sink) {
469
+ if (sink instanceof PortalReportingSink) {
470
+ return sink.cloneForRun();
471
+ }
472
+ return sink;
473
+ }
461
474
  export class InfluxDbReportingSink {
462
475
  constructor(options = {}) {
463
476
  this.sinkName = "influxdb";
@@ -1600,6 +1613,7 @@ function createReportingEvent(session, occurredUtc, eventType, scenarioName, ste
1600
1613
  eventFields.completed_utc = occurredUtc.toISOString();
1601
1614
  }
1602
1615
  return {
1616
+ runId: session.runId,
1603
1617
  eventType,
1604
1618
  occurredUtc,
1605
1619
  sessionId: session.sessionId,
@@ -1617,7 +1631,7 @@ function createReportingEvent(session, occurredUtc, eventType, scenarioName, ste
1617
1631
  function portalEventPayload(event, index) {
1618
1632
  return {
1619
1633
  eventId: portalEventId(event, index),
1620
- runId: event.sessionId,
1634
+ runId: event.runId,
1621
1635
  eventType: event.eventType,
1622
1636
  occurredUtc: event.occurredUtc.toISOString(),
1623
1637
  sessionId: event.sessionId,
@@ -1635,6 +1649,7 @@ function portalEventPayload(event, index) {
1635
1649
  function portalEventId(event, index) {
1636
1650
  const material = JSON.stringify({
1637
1651
  sessionId: event.sessionId,
1652
+ runId: event.runId,
1638
1653
  eventType: event.eventType,
1639
1654
  occurredUtc: event.occurredUtc.toISOString(),
1640
1655
  scenarioName: event.scenarioName ?? "",
@@ -2187,13 +2202,20 @@ function toOtelAnyValue(value) {
2187
2202
  function toUnixSeconds(value) {
2188
2203
  return value.getTime() / 1000;
2189
2204
  }
2190
- function sinkSessionMetadataFromContext(context, session) {
2205
+ function sinkSessionMetadataFromContext(context, session, options = {}) {
2191
2206
  const nodeInfo = context.getNodeInfo?.() ?? context.nodeInfo;
2192
2207
  const testInfo = context.testInfo;
2193
2208
  const sessionStartedUtc = String(session?.startedUtc ?? session?.StartedUtc ?? "");
2194
2209
  const contextStartedUtc = String(testInfo.createdUtc ?? testInfo.CreatedUtc ?? "");
2210
+ const explicitRunId = String(session?.portalReportingRunId ?? session?.PortalReportingRunId ?? "").trim();
2211
+ const sessionId = String(testInfo.sessionId ?? "");
2212
+ const fallbackRunId = options.distinctRunIdFallback
2213
+ ? buildDistinctSinkRunId(sessionId)
2214
+ : sessionId;
2215
+ const runId = explicitRunId || fallbackRunId;
2195
2216
  return {
2196
- sessionId: String(testInfo.sessionId ?? ""),
2217
+ runId: runId || sessionId,
2218
+ sessionId,
2197
2219
  testSuite: String(testInfo.testSuite ?? ""),
2198
2220
  testName: String(testInfo.testName ?? ""),
2199
2221
  clusterId: String(testInfo.clusterId ?? ""),
@@ -2202,6 +2224,14 @@ function sinkSessionMetadataFromContext(context, session) {
2202
2224
  startedUtc: sessionStartedUtc || contextStartedUtc || new Date().toISOString()
2203
2225
  };
2204
2226
  }
2227
+ function buildDistinctSinkRunId(sessionId) {
2228
+ const sessionPart = String(sessionId ?? "")
2229
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
2230
+ .replace(/^-+|-+$/g, "")
2231
+ .slice(0, 80);
2232
+ const uniquePart = randomBytes(8).toString("hex");
2233
+ return sessionPart ? `${sessionPart}-${uniquePart}` : uniquePart;
2234
+ }
2205
2235
  function readInfluxOptionsFromConfig(infraConfig, configurationSectionPath) {
2206
2236
  const section = resolveConfigSection(infraConfig, configurationSectionPath);
2207
2237
  return {
@@ -2672,7 +2702,8 @@ function cloneSessionStartInfo(session) {
2672
2702
  scenarioNames: [...session.scenarioNames],
2673
2703
  scenarios: session.scenarios.map((value) => ({ ...value })),
2674
2704
  runToken: session.runToken,
2675
- portalReportingIngestUrl: session.portalReportingIngestUrl
2705
+ portalReportingIngestUrl: session.portalReportingIngestUrl,
2706
+ portalReportingRunId: session.portalReportingRunId
2676
2707
  };
2677
2708
  }
2678
2709
  function cloneNodeInfo(nodeInfo) {
@@ -2799,15 +2830,61 @@ async function postWithTimeout(fetchImpl, url, init, timeoutMs, sinkName) {
2799
2830
  const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 1));
2800
2831
  try {
2801
2832
  const response = await fetchImpl(url, { ...init, signal: controller.signal });
2833
+ const body = await readResponseBodyText(response);
2802
2834
  if (!response.ok) {
2803
- const body = await response.text();
2804
2835
  throw new Error(`${sinkName} write failed with status ${response.status}: ${body}`);
2805
2836
  }
2837
+ return body;
2806
2838
  }
2807
2839
  finally {
2808
2840
  clearTimeout(timer);
2809
2841
  }
2810
2842
  }
2843
+ async function readResponseBodyText(response) {
2844
+ const candidate = response;
2845
+ if (typeof candidate.text !== "function") {
2846
+ return "";
2847
+ }
2848
+ return String(await candidate.text());
2849
+ }
2850
+ function validatePortalIngestResponse(responseBody) {
2851
+ const text = String(responseBody ?? "").trim();
2852
+ if (!text) {
2853
+ return;
2854
+ }
2855
+ let parsed;
2856
+ try {
2857
+ parsed = JSON.parse(text);
2858
+ }
2859
+ catch {
2860
+ return;
2861
+ }
2862
+ if (!isRecord(parsed)) {
2863
+ return;
2864
+ }
2865
+ const rejected = readPortalIngestCount(parsed, "rejectedCount", "RejectedCount", "rejected");
2866
+ if (rejected <= 0) {
2867
+ return;
2868
+ }
2869
+ const accepted = readPortalIngestCount(parsed, "acceptedCount", "AcceptedCount", "accepted");
2870
+ const duplicate = readPortalIngestCount(parsed, "duplicateCount", "DuplicateCount", "duplicate");
2871
+ throw new Error(`PortalReportingSink ingest rejected ${rejected} reporting event(s). Accepted ${accepted}, duplicate ${duplicate}.`);
2872
+ }
2873
+ function readPortalIngestCount(source, ...keys) {
2874
+ for (const key of keys) {
2875
+ const value = source[key];
2876
+ if (typeof value === "number" && Number.isFinite(value)) {
2877
+ return Math.max(Math.trunc(value), 0);
2878
+ }
2879
+ if (typeof value === "string") {
2880
+ const parsed = Number.parseInt(value, 10);
2881
+ if (Number.isFinite(parsed)) {
2882
+ return Math.max(parsed, 0);
2883
+ }
2884
+ }
2885
+ }
2886
+ return 0;
2887
+ }
2811
2888
  export const __loadstrikeTestExports = {
2812
2889
  GrafanaLokiReportingSink,
2813
2890
  InfluxDbReportingSink,
@@ -560,6 +560,7 @@ export interface LoadStrikeSinkSession {
560
560
  infraConfig?: Record<string, unknown>;
561
561
  runToken?: string;
562
562
  portalReportingIngestUrl?: string;
563
+ portalReportingRunId?: string;
563
564
  }
564
565
  export interface LoadStrikeBaseContext {
565
566
  logger: LoadStrikeLogger;
@@ -577,11 +578,13 @@ export interface LoadStrikeSessionStartInfo extends LoadStrikeBaseContext {
577
578
  scenarios: LoadStrikeScenarioStartInfo[];
578
579
  runToken?: string;
579
580
  portalReportingIngestUrl?: string;
581
+ portalReportingRunId?: string;
580
582
  readonly StartedUtc?: string;
581
583
  readonly ScenarioNames?: string[];
582
584
  readonly Scenarios?: LoadStrikeScenarioStartInfo[];
583
585
  readonly RunToken?: string;
584
586
  readonly PortalReportingIngestUrl?: string;
587
+ readonly PortalReportingRunId?: string;
585
588
  }
586
589
  type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Omit<T, Keys> & {
587
590
  [Key in Keys]-?: Required<Pick<T, Key>> & Partial<Pick<T, Exclude<Keys, Key>>>;
@@ -24,6 +24,7 @@ interface LoadStrikeNodeStats {
24
24
  }
25
25
  type SinkFetch = (input: string, init?: RequestInit) => Promise<Response>;
26
26
  interface ReportingSinkEvent {
27
+ runId: string;
27
28
  eventType: string;
28
29
  occurredUtc: Date;
29
30
  sessionId: string;
@@ -38,6 +39,7 @@ interface ReportingSinkEvent {
38
39
  fields: Record<string, unknown>;
39
40
  }
40
41
  interface SinkSessionMetadata {
42
+ runId: string;
41
43
  sessionId: string;
42
44
  testSuite: string;
43
45
  testName: string;
@@ -448,6 +450,7 @@ export declare class PortalReportingSink implements LoadStrikeReportingSink {
448
450
  readonly SinkName = "portal";
449
451
  readonly licenseFeature = "extensions.reporting_sinks.portal";
450
452
  readonly LicenseFeature = "extensions.reporting_sinks.portal";
453
+ private readonly optionsInput;
451
454
  private readonly fetchImpl;
452
455
  private readonly timeoutMs;
453
456
  private baseContext;
@@ -455,6 +458,7 @@ export declare class PortalReportingSink implements LoadStrikeReportingSink {
455
458
  private runToken;
456
459
  private ingestUrl;
457
460
  constructor(options?: PortalReportingSinkOptionsInput);
461
+ cloneForRun(): PortalReportingSink;
458
462
  init(context: LoadStrikeBaseContext, _infraConfig: Record<string, unknown>): void;
459
463
  Init(context: LoadStrikeBaseContext, infraConfig: Record<string, unknown>): void;
460
464
  start(session: LoadStrikeSessionStartInfo): void;
@@ -472,6 +476,7 @@ export declare class PortalReportingSink implements LoadStrikeReportingSink {
472
476
  private getSession;
473
477
  private persistEvents;
474
478
  }
479
+ export declare function cloneReportingSinkForRun(sink: LoadStrikeReportingSink): LoadStrikeReportingSink;
475
480
  export declare class InfluxDbReportingSink implements LoadStrikeReportingSink {
476
481
  readonly sinkName = "influxdb";
477
482
  readonly SinkName = "influxdb";
@@ -642,7 +647,9 @@ declare function createFinalStatsEvents(session: SinkSessionMetadata, stats: Loa
642
647
  declare function createRunResultEvents(session: SinkSessionMetadata, result: LoadStrikeRunResult): ReportingSinkEvent[];
643
648
  declare function createReportingEvent(session: SinkSessionMetadata, occurredUtc: Date, eventType: string, scenarioName: string | null, stepName: string | null, tags: Record<string, string>, fields: Record<string, unknown>): ReportingSinkEvent;
644
649
  declare function toOtelAnyValue(value: unknown): Record<string, unknown>;
645
- declare function sinkSessionMetadataFromContext(context: LoadStrikeBaseContext, session?: LoadStrikeSessionStartInfo): SinkSessionMetadata;
650
+ declare function sinkSessionMetadataFromContext(context: LoadStrikeBaseContext, session?: LoadStrikeSessionStartInfo, options?: {
651
+ distinctRunIdFallback?: boolean;
652
+ }): SinkSessionMetadata;
646
653
  declare function mergeInfluxOptions(target: InfluxDbResolvedOptions, source: Partial<InfluxDbResolvedOptions>): void;
647
654
  declare function mergeGrafanaLokiOptions(target: GrafanaLokiResolvedOptions, source: Partial<GrafanaLokiResolvedOptions>): void;
648
655
  declare function mergeTimescaleDbOptions(target: TimescaleDbResolvedOptions, source: Partial<TimescaleDbResolvedOptions>): void;
@@ -673,7 +680,7 @@ declare function cloneSessionStartInfo(session: LoadStrikeSessionStartInfo): Loa
673
680
  declare function cloneMetricStats(metrics: LoadStrikeMetricStats): LoadStrikeMetricStats;
674
681
  declare function cloneScenarioStats(value: LoadStrikeScenarioStats): LoadStrikeScenarioStats;
675
682
  declare function cloneNodeStats(result: LoadStrikeNodeStats): LoadStrikeNodeStats;
676
- declare function postWithTimeout(fetchImpl: SinkFetch, url: string, init: RequestInit, timeoutMs: number, sinkName: string): Promise<void>;
683
+ declare function postWithTimeout(fetchImpl: SinkFetch, url: string, init: RequestInit, timeoutMs: number, sinkName: string): Promise<string>;
677
684
  export declare const __loadstrikeTestExports: {
678
685
  GrafanaLokiReportingSink: typeof GrafanaLokiReportingSink;
679
686
  InfluxDbReportingSink: typeof InfluxDbReportingSink;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadstrike/loadstrike-sdk",
3
- "version": "1.0.23201",
3
+ "version": "1.0.23601",
4
4
  "description": "TypeScript and JavaScript SDK for in-process load execution, traffic correlation, and reporting.",
5
5
  "keywords": [
6
6
  "load-testing",