@loadstrike/loadstrike-sdk 1.0.30401 → 1.0.31001

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/sinks.js CHANGED
@@ -10,7 +10,26 @@ const node_dgram_1 = require("node:dgram");
10
10
  const node_zlib_1 = require("node:zlib");
11
11
  const pg_1 = require("pg");
12
12
  const iteration_observations_js_1 = require("./iteration-observations.js");
13
+ const iteration_observation_diagnostics_js_1 = require("./iteration-observation-diagnostics.js");
13
14
  const PORTAL_RUN_TOKEN_PROVIDER = Symbol.for("loadstrike.internal.portal-run-token-provider");
15
+ const MAXIMUM_HTTP_RESPONSE_BODY_BYTES = 256 * 1024;
16
+ const MAXIMUM_HTTP_METADATA_CHARACTERS = 256;
17
+ const HTTP_METADATA_TRUNCATION_SUFFIX = " [truncated]";
18
+ class ReportingSinkHttpError extends Error {
19
+ constructor(sinkName, status, statusText, requestId) {
20
+ const normalizedStatus = Number.isFinite(status) ? Math.max(Math.trunc(status), 0) : 0;
21
+ const normalizedStatusText = normalizeHttpMetadata(statusText);
22
+ const normalizedRequestId = normalizeHttpMetadata(requestId);
23
+ super(`${sinkName} write failed with HTTP status ${normalizedStatus}`
24
+ + (normalizedStatusText ? ` ${normalizedStatusText}` : "")
25
+ + (normalizedRequestId ? ` (requestId=${normalizedRequestId})` : "")
26
+ + ".");
27
+ this.name = "ReportingSinkHttpError";
28
+ this.status = normalizedStatus;
29
+ this.statusText = normalizedStatusText;
30
+ this.requestId = normalizedRequestId;
31
+ }
32
+ }
14
33
  function gzipJsonAsync(value) {
15
34
  const json = Buffer.from(JSON.stringify(value), "utf8");
16
35
  return new Promise((resolve, reject) => {
@@ -2234,6 +2253,7 @@ function removeSdkPercentileFields(event) {
2234
2253
  }
2235
2254
  function createIterationObservationEvents(session, batch) {
2236
2255
  const events = [];
2256
+ const occurredUtc = dateFromUtcNanoseconds(batch.createdUtcNs);
2237
2257
  for (const observation of batch.observations) {
2238
2258
  const tags = {
2239
2259
  run_id: batch.runId,
@@ -2246,7 +2266,7 @@ function createIterationObservationEvents(session, batch) {
2246
2266
  events.push({
2247
2267
  runId: batch.runId,
2248
2268
  eventType: "iteration.observation",
2249
- occurredUtc: new Date(),
2269
+ occurredUtc,
2250
2270
  sessionId: batch.sessionId,
2251
2271
  testSuite: session.testSuite,
2252
2272
  testName: session.testName,
@@ -2281,7 +2301,7 @@ function createIterationObservationEvents(session, batch) {
2281
2301
  events.push({
2282
2302
  runId: batch.runId,
2283
2303
  eventType: "iteration.step",
2284
- occurredUtc: new Date(),
2304
+ occurredUtc,
2285
2305
  sessionId: batch.sessionId,
2286
2306
  testSuite: session.testSuite,
2287
2307
  testName: session.testName,
@@ -2312,10 +2332,11 @@ function createIterationObservationEvents(session, batch) {
2312
2332
  return events;
2313
2333
  }
2314
2334
  function createIterationCompletionEvents(session, completion) {
2335
+ const occurredUtc = dateFromUtcNanoseconds(completion.completedUtcNs);
2315
2336
  return [{
2316
2337
  runId: completion.runId,
2317
2338
  eventType: "observation.stream.completed",
2318
- occurredUtc: new Date(),
2339
+ occurredUtc,
2319
2340
  sessionId: completion.sessionId,
2320
2341
  testSuite: session.testSuite,
2321
2342
  testName: session.testName,
@@ -2374,6 +2395,22 @@ function portalEventId(event, index) {
2374
2395
  });
2375
2396
  return `lsr_${(0, node_crypto_1.createHash)("sha256").update(material).digest("hex").slice(0, 40)}`;
2376
2397
  }
2398
+ function dateFromUtcNanoseconds(value) {
2399
+ try {
2400
+ const nanoseconds = BigInt(value);
2401
+ if (nanoseconds < 0n) {
2402
+ return new Date(0);
2403
+ }
2404
+ const milliseconds = nanoseconds / 1000000n;
2405
+ const numeric = Number(milliseconds);
2406
+ return Number.isFinite(numeric) && numeric <= 8640000000000000
2407
+ ? new Date(numeric)
2408
+ : new Date(0);
2409
+ }
2410
+ catch {
2411
+ return new Date(0);
2412
+ }
2413
+ }
2377
2414
  function addMeasurementFields(fields, prefix, measurement) {
2378
2415
  const request = measurement?.request ?? { count: 0, percent: 0, rps: 0 };
2379
2416
  const latency = measurement?.latency ?? {
@@ -3565,22 +3602,85 @@ async function postWithTimeout(fetchImpl, url, init, timeoutMs, sinkName) {
3565
3602
  const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 1));
3566
3603
  try {
3567
3604
  const response = await fetchImpl(url, { ...init, signal: controller.signal });
3568
- const body = await readResponseBodyText(response);
3569
3605
  if (!response.ok) {
3570
- throw new Error(`${sinkName} write failed with status ${response.status}: ${body}`);
3606
+ throw new ReportingSinkHttpError(sinkName, response.status, readHttpResponseStatusText(response), readHttpResponseRequestId(response));
3571
3607
  }
3572
- return body;
3608
+ return await readResponseBodyText(response);
3573
3609
  }
3574
3610
  finally {
3575
3611
  clearTimeout(timer);
3576
3612
  }
3577
3613
  }
3578
3614
  async function readResponseBodyText(response) {
3579
- const candidate = response;
3580
- if (typeof candidate.text !== "function") {
3615
+ const body = response.body;
3616
+ if (body && typeof body.getReader === "function") {
3617
+ const reader = body.getReader();
3618
+ const chunks = [];
3619
+ let bytes = 0;
3620
+ try {
3621
+ while (true) {
3622
+ const next = await reader.read();
3623
+ if (next.done)
3624
+ break;
3625
+ if (!next.value)
3626
+ continue;
3627
+ if (bytes + next.value.byteLength > MAXIMUM_HTTP_RESPONSE_BODY_BYTES) {
3628
+ await reader.cancel();
3629
+ throw new Error("Reporting sink response exceeded the bounded response limit.");
3630
+ }
3631
+ chunks.push(next.value);
3632
+ bytes += next.value.byteLength;
3633
+ }
3634
+ }
3635
+ finally {
3636
+ reader.releaseLock();
3637
+ }
3638
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
3639
+ }
3640
+ return "";
3641
+ }
3642
+ function readHttpResponseStatusText(response) {
3643
+ try {
3644
+ return normalizeHttpMetadata(response.statusText);
3645
+ }
3646
+ catch {
3647
+ return "";
3648
+ }
3649
+ }
3650
+ function readHttpResponseRequestId(response) {
3651
+ for (const name of [
3652
+ "x-request-id",
3653
+ "x-correlation-id",
3654
+ "request-id",
3655
+ "traceparent"
3656
+ ]) {
3657
+ try {
3658
+ const value = response.headers?.get(name);
3659
+ if (value) {
3660
+ return normalizeHttpMetadata(value);
3661
+ }
3662
+ }
3663
+ catch {
3664
+ return "";
3665
+ }
3666
+ }
3667
+ return "";
3668
+ }
3669
+ function normalizeHttpMetadata(value) {
3670
+ let text;
3671
+ try {
3672
+ const source = String(value ?? "");
3673
+ text = source.length <= MAXIMUM_HTTP_METADATA_CHARACTERS
3674
+ ? source
3675
+ : source.slice(0, MAXIMUM_HTTP_METADATA_CHARACTERS - HTTP_METADATA_TRUNCATION_SUFFIX.length) + HTTP_METADATA_TRUNCATION_SUFFIX;
3676
+ }
3677
+ catch {
3581
3678
  return "";
3582
3679
  }
3583
- return String(await candidate.text());
3680
+ return (0, iteration_observation_diagnostics_js_1.redactIterationObservationSecrets)(Buffer.from(text, "utf8").toString("utf8")
3681
+ .replace(/[\u0000-\u001F\u007F-\u009F]/gu, " ")
3682
+ .replace(/\s{2,}/gu, " ")
3683
+ .trim());
3584
3684
  }
3585
3685
  function validatePortalIngestResponse(responseBody) {
3586
3686
  const text = String(responseBody ?? "").trim();
@@ -3633,6 +3733,8 @@ exports.__loadstrikeTestExports = {
3633
3733
  cloneScenarioStats,
3634
3734
  cloneSessionStartInfo,
3635
3735
  createFinalStatsEvents,
3736
+ createIterationCompletionEvents,
3737
+ createIterationObservationEvents,
3636
3738
  createRealtimeStatsEvents,
3637
3739
  createRunResultEvents,
3638
3740
  createReportingEvent,
@@ -3649,6 +3751,7 @@ exports.__loadstrikeTestExports = {
3649
3751
  normalizeStringMap,
3650
3752
  optionNumber,
3651
3753
  optionString,
3754
+ portalEventPayload,
3652
3755
  pickBooleanValue,
3653
3756
  pickRecordValue,
3654
3757
  postWithTimeout,
@@ -638,14 +638,10 @@ class WebSocketEndpointDefinitionModel extends TrafficEndpointDefinitionModel {
638
638
  parsed = new URL(url);
639
639
  }
640
640
  catch {
641
- // Diagnostic text only; no connection is opened here.
642
- // nosemgrep: vulnerability-tools.semgrep-rules.javascript.lang.security.detect-insecure-websocket
643
- throw new Error("Url must be an absolute ws:// or wss:// URI.");
641
+ throw new Error("Url must be an absolute WebSocket URI using the ws or wss scheme.");
644
642
  }
645
643
  if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
646
- // Diagnostic text only; no connection is opened here.
647
- // nosemgrep: vulnerability-tools.semgrep-rules.javascript.lang.security.detect-insecure-websocket
648
- throw new Error("Url must be an absolute ws:// or wss:// URI.");
644
+ throw new Error("Url must be an absolute WebSocket URI using the ws or wss scheme.");
649
645
  }
650
646
  if (this.ConnectTimeoutSeconds <= 0) {
651
647
  throw new RangeError("ConnectTimeout must be greater than zero.");
@@ -3175,14 +3171,10 @@ function validateWebSocketEndpoint(endpoint, mode) {
3175
3171
  parsed = new URL(url);
3176
3172
  }
3177
3173
  catch {
3178
- // Diagnostic text only; no connection is opened here.
3179
- // nosemgrep: vulnerability-tools.semgrep-rules.javascript.lang.security.detect-insecure-websocket
3180
- throw new Error("Url must be an absolute ws:// or wss:// URI.");
3174
+ throw new Error("Url must be an absolute WebSocket URI using the ws or wss scheme.");
3181
3175
  }
3182
3176
  if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
3183
- // Diagnostic text only; no connection is opened here.
3184
- // nosemgrep: vulnerability-tools.semgrep-rules.javascript.lang.security.detect-insecure-websocket
3185
- throw new Error("Url must be an absolute ws:// or wss:// URI.");
3177
+ throw new Error("Url must be an absolute WebSocket URI using the ws or wss scheme.");
3186
3178
  }
3187
3179
  const connectMs = optionNumber(options, "ConnectTimeoutMs", "connectTimeoutMs");
3188
3180
  const connectSeconds = optionNumber(options, "ConnectTimeoutSeconds", "connectTimeoutSeconds", "ConnectTimeout", "connectTimeout");
@@ -3612,7 +3604,13 @@ function extractJsonPath(value, arrayPath) {
3612
3604
  if (!normalized) {
3613
3605
  return value;
3614
3606
  }
3615
- const tokens = normalized.replace(/\[(\d+)\]/g, ".$1").split(".").filter((x) => x.length > 0);
3607
+ let tokens;
3608
+ try {
3609
+ tokens = safeJsonPathSegments(normalized.replace(/\[(\d+)\]/g, ".$1"));
3610
+ }
3611
+ catch {
3612
+ return null;
3613
+ }
3616
3614
  for (const token of tokens) {
3617
3615
  if (current == null) {
3618
3616
  return null;
@@ -3622,13 +3620,17 @@ function extractJsonPath(value, arrayPath) {
3622
3620
  if (!Number.isInteger(index) || index < 0 || index >= current.length) {
3623
3621
  return null;
3624
3622
  }
3625
- current = current[index];
3623
+ current = current.at(index);
3626
3624
  continue;
3627
3625
  }
3628
- if (!isRecord(current) || !(token in current)) {
3626
+ if (!isRecord(current)) {
3629
3627
  return null;
3630
3628
  }
3631
- current = current[token];
3629
+ const descriptor = Object.getOwnPropertyDescriptor(current, token);
3630
+ if (!descriptor || !("value" in descriptor)) {
3631
+ return null;
3632
+ }
3633
+ current = descriptor.value;
3632
3634
  }
3633
3635
  return current;
3634
3636
  }
@@ -3645,7 +3647,7 @@ function parseMaybeJson(value) {
3645
3647
  }
3646
3648
  }
3647
3649
  function headersToRecord(value) {
3648
- const headers = {};
3650
+ const headers = Object.create(null);
3649
3651
  value.forEach((headerValue, key) => {
3650
3652
  headers[key] = headerValue;
3651
3653
  });
@@ -3999,11 +4001,22 @@ function extractTrackingValue(payload, selector) {
3999
4001
  return null;
4000
4002
  }
4001
4003
  let current = body;
4002
- for (const segment of selector.slice("json:".length).trim().replace(/^\$\./, "").split(".").filter(Boolean)) {
4004
+ const path = selector.slice("json:".length).trim().replace(/^\$\./, "");
4005
+ let segments;
4006
+ try {
4007
+ segments = safeJsonPathSegments(path);
4008
+ }
4009
+ catch {
4010
+ return null;
4011
+ }
4012
+ for (const segment of segments) {
4003
4013
  if (!isRecord(current)) {
4004
4014
  return null;
4005
4015
  }
4006
- current = current[segment];
4016
+ if (!Object.prototype.hasOwnProperty.call(current, segment)) {
4017
+ return null;
4018
+ }
4019
+ current = readOwnJsonProperty(current, segment);
4007
4020
  }
4008
4021
  return current == null ? null : String(current);
4009
4022
  }
@@ -4034,23 +4047,61 @@ function injectTrackingValue(payload, selector, value) {
4034
4047
  }
4035
4048
  function setJsonBodyValue(body, path, value) {
4036
4049
  const target = parseBodyObject(body) ?? {};
4037
- const clone = cloneBody(target);
4038
- const segments = path.split(".").filter(Boolean);
4050
+ const clone = cloneJsonRecord(target);
4051
+ const segments = safeJsonPathSegments(path);
4039
4052
  if (!segments.length) {
4040
4053
  return clone;
4041
4054
  }
4042
4055
  let current = clone;
4043
4056
  for (let i = 0; i < segments.length - 1; i += 1) {
4044
4057
  const segment = segments[i];
4045
- const next = current[segment];
4058
+ let next = readOwnJsonProperty(current, segment);
4046
4059
  if (!isRecord(next)) {
4047
- current[segment] = {};
4060
+ next = {};
4061
+ defineJsonProperty(current, segment, next);
4048
4062
  }
4049
- current = current[segment];
4063
+ current = next;
4050
4064
  }
4051
- current[segments[segments.length - 1]] = value;
4065
+ defineJsonProperty(current, segments[segments.length - 1], value);
4052
4066
  return clone;
4053
4067
  }
4068
+ const FORBIDDEN_JSON_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
4069
+ function safeJsonPathSegments(path) {
4070
+ const segments = path.split(".").filter(Boolean);
4071
+ const forbidden = segments.find((segment) => FORBIDDEN_JSON_PATH_SEGMENTS.has(segment));
4072
+ if (forbidden) {
4073
+ throw new Error(`Tracking selector contains forbidden JSON path segment '${forbidden}'.`);
4074
+ }
4075
+ return segments;
4076
+ }
4077
+ function defineJsonProperty(target, key, value) {
4078
+ Object.defineProperty(target, key, {
4079
+ configurable: true,
4080
+ enumerable: true,
4081
+ value,
4082
+ writable: true
4083
+ });
4084
+ }
4085
+ function readOwnJsonProperty(target, key) {
4086
+ const descriptor = Object.getOwnPropertyDescriptor(target, key);
4087
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
4088
+ }
4089
+ function cloneJsonRecord(source) {
4090
+ const target = {};
4091
+ for (const [key, value] of Object.entries(source)) {
4092
+ defineJsonProperty(target, key, cloneJsonValue(value));
4093
+ }
4094
+ return target;
4095
+ }
4096
+ function cloneJsonValue(value) {
4097
+ if (Array.isArray(value)) {
4098
+ return value.map((entry) => cloneJsonValue(entry));
4099
+ }
4100
+ if (isRecord(value)) {
4101
+ return cloneJsonRecord(value);
4102
+ }
4103
+ return value;
4104
+ }
4054
4105
  function parseBodyObject(body) {
4055
4106
  if (body instanceof Uint8Array) {
4056
4107
  return parseBodyObject(new TextDecoder().decode(body));
@@ -4659,8 +4710,10 @@ exports.__loadstrikeTestExports = {
4659
4710
  createNatsHeaders,
4660
4711
  createRedisStreamPayload,
4661
4712
  deserializeBrokerPayloadBody,
4713
+ extractJsonPath,
4662
4714
  extractTrackingValue,
4663
4715
  fromKafkaHeaders,
4716
+ headersToRecord,
4664
4717
  headerValue,
4665
4718
  attachDotNetTrackingPayloadAliases,
4666
4719
  attachPayloadHelpers,