@loadstrike/loadstrike-sdk 1.0.23401 → 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
  }
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";
@@ -2208,15 +2222,20 @@ function toOtelAnyValue(value) {
2208
2222
  function toUnixSeconds(value) {
2209
2223
  return value.getTime() / 1000;
2210
2224
  }
2211
- function sinkSessionMetadataFromContext(context, session) {
2225
+ function sinkSessionMetadataFromContext(context, session, options = {}) {
2212
2226
  const nodeInfo = context.getNodeInfo?.() ?? context.nodeInfo;
2213
2227
  const testInfo = context.testInfo;
2214
2228
  const sessionStartedUtc = String(session?.startedUtc ?? session?.StartedUtc ?? "");
2215
2229
  const contextStartedUtc = String(testInfo.createdUtc ?? testInfo.CreatedUtc ?? "");
2216
- const runId = String(session?.portalReportingRunId ?? session?.PortalReportingRunId ?? testInfo.sessionId ?? "").trim();
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;
2217
2236
  return {
2218
- runId: runId || String(testInfo.sessionId ?? ""),
2219
- sessionId: String(testInfo.sessionId ?? ""),
2237
+ runId: runId || sessionId,
2238
+ sessionId,
2220
2239
  testSuite: String(testInfo.testSuite ?? ""),
2221
2240
  testName: String(testInfo.testName ?? ""),
2222
2241
  clusterId: String(testInfo.clusterId ?? ""),
@@ -2225,6 +2244,14 @@ function sinkSessionMetadataFromContext(context, session) {
2225
2244
  startedUtc: sessionStartedUtc || contextStartedUtc || new Date().toISOString()
2226
2245
  };
2227
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
+ }
2228
2255
  function readInfluxOptionsFromConfig(infraConfig, configurationSectionPath) {
2229
2256
  const section = resolveConfigSection(infraConfig, configurationSectionPath);
2230
2257
  return {
@@ -2823,15 +2850,61 @@ async function postWithTimeout(fetchImpl, url, init, timeoutMs, sinkName) {
2823
2850
  const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 1));
2824
2851
  try {
2825
2852
  const response = await fetchImpl(url, { ...init, signal: controller.signal });
2853
+ const body = await readResponseBodyText(response);
2826
2854
  if (!response.ok) {
2827
- const body = await response.text();
2828
2855
  throw new Error(`${sinkName} write failed with status ${response.status}: ${body}`);
2829
2856
  }
2857
+ return body;
2830
2858
  }
2831
2859
  finally {
2832
2860
  clearTimeout(timer);
2833
2861
  }
2834
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
+ }
2835
2908
  exports.__loadstrikeTestExports = {
2836
2909
  GrafanaLokiReportingSink,
2837
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
  }
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";
@@ -2189,15 +2202,20 @@ function toOtelAnyValue(value) {
2189
2202
  function toUnixSeconds(value) {
2190
2203
  return value.getTime() / 1000;
2191
2204
  }
2192
- function sinkSessionMetadataFromContext(context, session) {
2205
+ function sinkSessionMetadataFromContext(context, session, options = {}) {
2193
2206
  const nodeInfo = context.getNodeInfo?.() ?? context.nodeInfo;
2194
2207
  const testInfo = context.testInfo;
2195
2208
  const sessionStartedUtc = String(session?.startedUtc ?? session?.StartedUtc ?? "");
2196
2209
  const contextStartedUtc = String(testInfo.createdUtc ?? testInfo.CreatedUtc ?? "");
2197
- const runId = String(session?.portalReportingRunId ?? session?.PortalReportingRunId ?? testInfo.sessionId ?? "").trim();
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;
2198
2216
  return {
2199
- runId: runId || String(testInfo.sessionId ?? ""),
2200
- sessionId: String(testInfo.sessionId ?? ""),
2217
+ runId: runId || sessionId,
2218
+ sessionId,
2201
2219
  testSuite: String(testInfo.testSuite ?? ""),
2202
2220
  testName: String(testInfo.testName ?? ""),
2203
2221
  clusterId: String(testInfo.clusterId ?? ""),
@@ -2206,6 +2224,14 @@ function sinkSessionMetadataFromContext(context, session) {
2206
2224
  startedUtc: sessionStartedUtc || contextStartedUtc || new Date().toISOString()
2207
2225
  };
2208
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
+ }
2209
2235
  function readInfluxOptionsFromConfig(infraConfig, configurationSectionPath) {
2210
2236
  const section = resolveConfigSection(infraConfig, configurationSectionPath);
2211
2237
  return {
@@ -2804,15 +2830,61 @@ async function postWithTimeout(fetchImpl, url, init, timeoutMs, sinkName) {
2804
2830
  const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 1));
2805
2831
  try {
2806
2832
  const response = await fetchImpl(url, { ...init, signal: controller.signal });
2833
+ const body = await readResponseBodyText(response);
2807
2834
  if (!response.ok) {
2808
- const body = await response.text();
2809
2835
  throw new Error(`${sinkName} write failed with status ${response.status}: ${body}`);
2810
2836
  }
2837
+ return body;
2811
2838
  }
2812
2839
  finally {
2813
2840
  clearTimeout(timer);
2814
2841
  }
2815
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
+ }
2816
2888
  export const __loadstrikeTestExports = {
2817
2889
  GrafanaLokiReportingSink,
2818
2890
  InfluxDbReportingSink,
@@ -450,6 +450,7 @@ export declare class PortalReportingSink implements LoadStrikeReportingSink {
450
450
  readonly SinkName = "portal";
451
451
  readonly licenseFeature = "extensions.reporting_sinks.portal";
452
452
  readonly LicenseFeature = "extensions.reporting_sinks.portal";
453
+ private readonly optionsInput;
453
454
  private readonly fetchImpl;
454
455
  private readonly timeoutMs;
455
456
  private baseContext;
@@ -457,6 +458,7 @@ export declare class PortalReportingSink implements LoadStrikeReportingSink {
457
458
  private runToken;
458
459
  private ingestUrl;
459
460
  constructor(options?: PortalReportingSinkOptionsInput);
461
+ cloneForRun(): PortalReportingSink;
460
462
  init(context: LoadStrikeBaseContext, _infraConfig: Record<string, unknown>): void;
461
463
  Init(context: LoadStrikeBaseContext, infraConfig: Record<string, unknown>): void;
462
464
  start(session: LoadStrikeSessionStartInfo): void;
@@ -474,6 +476,7 @@ export declare class PortalReportingSink implements LoadStrikeReportingSink {
474
476
  private getSession;
475
477
  private persistEvents;
476
478
  }
479
+ export declare function cloneReportingSinkForRun(sink: LoadStrikeReportingSink): LoadStrikeReportingSink;
477
480
  export declare class InfluxDbReportingSink implements LoadStrikeReportingSink {
478
481
  readonly sinkName = "influxdb";
479
482
  readonly SinkName = "influxdb";
@@ -644,7 +647,9 @@ declare function createFinalStatsEvents(session: SinkSessionMetadata, stats: Loa
644
647
  declare function createRunResultEvents(session: SinkSessionMetadata, result: LoadStrikeRunResult): ReportingSinkEvent[];
645
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;
646
649
  declare function toOtelAnyValue(value: unknown): Record<string, unknown>;
647
- declare function sinkSessionMetadataFromContext(context: LoadStrikeBaseContext, session?: LoadStrikeSessionStartInfo): SinkSessionMetadata;
650
+ declare function sinkSessionMetadataFromContext(context: LoadStrikeBaseContext, session?: LoadStrikeSessionStartInfo, options?: {
651
+ distinctRunIdFallback?: boolean;
652
+ }): SinkSessionMetadata;
648
653
  declare function mergeInfluxOptions(target: InfluxDbResolvedOptions, source: Partial<InfluxDbResolvedOptions>): void;
649
654
  declare function mergeGrafanaLokiOptions(target: GrafanaLokiResolvedOptions, source: Partial<GrafanaLokiResolvedOptions>): void;
650
655
  declare function mergeTimescaleDbOptions(target: TimescaleDbResolvedOptions, source: Partial<TimescaleDbResolvedOptions>): void;
@@ -675,7 +680,7 @@ declare function cloneSessionStartInfo(session: LoadStrikeSessionStartInfo): Loa
675
680
  declare function cloneMetricStats(metrics: LoadStrikeMetricStats): LoadStrikeMetricStats;
676
681
  declare function cloneScenarioStats(value: LoadStrikeScenarioStats): LoadStrikeScenarioStats;
677
682
  declare function cloneNodeStats(result: LoadStrikeNodeStats): LoadStrikeNodeStats;
678
- 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>;
679
684
  export declare const __loadstrikeTestExports: {
680
685
  GrafanaLokiReportingSink: typeof GrafanaLokiReportingSink;
681
686
  InfluxDbReportingSink: typeof InfluxDbReportingSink;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadstrike/loadstrike-sdk",
3
- "version": "1.0.23401",
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",