@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.
@@ -1,5 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { performance } from "node:perf_hooks";
2
3
  import { gzipSync } from "node:zlib";
4
+ import { logIterationObservationFailure, logIterationObservationRecovery } from "./iteration-observation-diagnostics.js";
5
+ import { cooperativeSinkRetryYield, normalizeSinkRetryBackoffMs, normalizeSinkRetryCount, sinkRetryDelayMs, waitForSinkRetryDelay } from "./sink-retry-policy.js";
3
6
  export const ITERATION_OBSERVATION_SCHEMA_VERSION = "loadstrike.iteration-observation/1";
4
7
  export const ITERATION_OBSERVATION_BATCH_SCHEMA_VERSION = "loadstrike.iteration-batch/1";
5
8
  export const ITERATION_OBSERVATION_STREAM_COMPLETION_SCHEMA_VERSION = "loadstrike.iteration-stream-completion/1";
@@ -141,10 +144,15 @@ export class ProcessIterationObservationBuffer {
141
144
  }
142
145
  const processIterationObservationBuffer = new ProcessIterationObservationBuffer();
143
146
  class SinkDispatcher {
144
- constructor(target, depth, parallelism, onDropped) {
147
+ constructor(target, depth, parallelism, retryCount, retryBackoffMs, logger, runId, resultOwnerId, onDropped) {
145
148
  this.target = target;
146
149
  this.depth = depth;
147
150
  this.parallelism = parallelism;
151
+ this.retryCount = retryCount;
152
+ this.retryBackoffMs = retryBackoffMs;
153
+ this.logger = logger;
154
+ this.runId = runId;
155
+ this.resultOwnerId = resultOwnerId;
148
156
  this.onDropped = onDropped;
149
157
  this.queue = [];
150
158
  this.active = new Set();
@@ -152,6 +160,8 @@ class SinkDispatcher {
152
160
  this.timedOut = false;
153
161
  this.delivered = 0n;
154
162
  this.dropped = 0n;
163
+ this.drainDeadlineMs = null;
164
+ this.pendingRetryDelays = new Set();
155
165
  }
156
166
  get deliveredCount() {
157
167
  return this.delivered;
@@ -160,7 +170,7 @@ class SinkDispatcher {
160
170
  return this.dropped;
161
171
  }
162
172
  get canPublishCompletion() {
163
- return this.queue.length === 0 && this.active.size === 0;
173
+ return !this.timedOut && this.queue.length === 0 && this.active.size === 0;
164
174
  }
165
175
  enqueue(batch) {
166
176
  if (this.timedOut || this.queue.length >= this.depth) {
@@ -188,7 +198,6 @@ class SinkDispatcher {
188
198
  });
189
199
  const timeout = new Promise((resolve) => {
190
200
  timer = setTimeout(() => resolve(false), Math.max(timeoutMs, 1));
191
- timer.unref?.();
192
201
  });
193
202
  const completed = await Promise.race([idle, timeout]);
194
203
  if (timer) {
@@ -201,16 +210,30 @@ class SinkDispatcher {
201
210
  }
202
211
  return completed;
203
212
  }
213
+ beginDrain(deadlineMs) {
214
+ this.drainDeadlineMs = deadlineMs;
215
+ const delaysThatCannotFit = Array.from(this.pendingRetryDelays)
216
+ .filter((pending) => pending.dueAtMs >= deadlineMs);
217
+ if (delaysThatCannotFit.length > 0) {
218
+ for (const pending of delaysThatCannotFit) {
219
+ pending.cancel();
220
+ }
221
+ this.timeoutOutstanding();
222
+ }
223
+ }
204
224
  pump() {
205
225
  while (!this.timedOut && this.active.size < this.parallelism && this.queue.length > 0) {
206
226
  const item = this.queue.shift();
207
227
  this.active.add(item);
208
228
  Promise.resolve()
209
- .then(() => this.target.saveIterationBatch(item.batch))
210
- .then(() => {
211
- if (!this.timedOut) {
229
+ .then(() => this.deliver(item))
230
+ .then((outcome) => {
231
+ if (outcome === "success" && !this.timedOut) {
212
232
  this.delivered += item.count;
213
233
  }
234
+ else if (outcome === "exhausted" && !this.timedOut) {
235
+ this.drop("observation_sink_delivery_failed", item.batch.observations);
236
+ }
214
237
  })
215
238
  .catch(() => {
216
239
  if (!this.timedOut) {
@@ -225,10 +248,95 @@ class SinkDispatcher {
225
248
  }
226
249
  this.notifyIdle();
227
250
  }
251
+ async deliver(item) {
252
+ const maximumAttempts = this.retryCount + 1;
253
+ for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
254
+ if (this.timedOut || this.deadlineExpired()) {
255
+ return "deadline";
256
+ }
257
+ try {
258
+ await this.target.saveIterationBatch(item.batch);
259
+ if (attempt > 1) {
260
+ logIterationObservationRecovery(this.logger, {
261
+ ...this.diagnosticContext(item, attempt, maximumAttempts),
262
+ nextDelayMs: 0
263
+ });
264
+ }
265
+ return "success";
266
+ }
267
+ catch (error) {
268
+ const exhausted = attempt >= maximumAttempts;
269
+ const nextDelayMs = exhausted
270
+ ? 0
271
+ : sinkRetryDelayMs(this.retryBackoffMs, attempt);
272
+ logIterationObservationFailure(this.logger, exhausted ? "error" : "warn", {
273
+ ...this.diagnosticContext(item, attempt, maximumAttempts),
274
+ nextDelayMs
275
+ }, error);
276
+ if (exhausted) {
277
+ return "exhausted";
278
+ }
279
+ if (!await this.waitForRetry(attempt)) {
280
+ this.timeoutOutstanding();
281
+ return "deadline";
282
+ }
283
+ }
284
+ }
285
+ return "exhausted";
286
+ }
287
+ diagnosticContext(item, attempt, maximumAttempts) {
288
+ return {
289
+ sinkName: this.target.name,
290
+ operation: "observation-batch",
291
+ runId: this.runId,
292
+ resultOwnerId: this.resultOwnerId,
293
+ batchId: item.batch.batchId,
294
+ batchSequence64: item.batch.batchSequence64,
295
+ observationCount: Number(item.count),
296
+ attempt,
297
+ maximumAttempts
298
+ };
299
+ }
300
+ async waitForRetry(retryNumber) {
301
+ const delayMs = sinkRetryDelayMs(this.retryBackoffMs, retryNumber);
302
+ const deadline = this.drainDeadlineMs;
303
+ if (deadline !== null && !delayCanFit(delayMs, deadline)) {
304
+ return false;
305
+ }
306
+ if (delayMs === 0) {
307
+ await cooperativeSinkRetryYield();
308
+ return !this.timedOut && !this.deadlineExpired();
309
+ }
310
+ return await new Promise((resolve) => {
311
+ let settled = false;
312
+ let pending;
313
+ const finish = (completed) => {
314
+ if (settled)
315
+ return;
316
+ settled = true;
317
+ clearTimeout(timer);
318
+ this.pendingRetryDelays.delete(pending);
319
+ resolve(completed && !this.timedOut && !this.deadlineExpired());
320
+ };
321
+ const timer = setTimeout(() => finish(true), delayMs);
322
+ const cancel = () => finish(false);
323
+ pending = {
324
+ dueAtMs: performance.now() + delayMs,
325
+ cancel
326
+ };
327
+ this.pendingRetryDelays.add(pending);
328
+ });
329
+ }
330
+ deadlineExpired() {
331
+ return this.drainDeadlineMs !== null && performance.now() >= this.drainDeadlineMs;
332
+ }
228
333
  timeoutOutstanding() {
229
334
  if (this.timedOut || this.isIdle())
230
335
  return;
231
336
  this.timedOut = true;
337
+ for (const pending of Array.from(this.pendingRetryDelays)) {
338
+ pending.cancel();
339
+ }
232
340
  const queued = [...this.queue];
233
341
  const active = [...this.active];
234
342
  this.queue.length = 0;
@@ -300,7 +408,7 @@ export class IterationObservationReporter {
300
408
  this.recordWarning("sink_iteration_batches_unsupported", sink.name, "", -1, 1n, "The reporting sink does not support raw iteration batches.");
301
409
  return false;
302
410
  })
303
- .map((target) => new SinkDispatcher(target, this.settings.sinkQueueDepth, this.settings.sinkParallelism, (code, sinkName, observations) => this.recordSinkDrop(code, sinkName, observations)));
411
+ .map((target) => new SinkDispatcher(target, this.settings.sinkQueueDepth, this.settings.sinkParallelism, normalizeSinkRetryCount(options.sinkRetryCount), normalizeSinkRetryBackoffMs(options.sinkRetryBackoffMs), options.logger, options.runId, options.resultOwnerId, (code, sinkName, observations) => this.recordSinkDrop(code, sinkName, observations)));
304
412
  if (this.dispatchers.length > 0) {
305
413
  this.buffer.register(this.streamKey, this.settings.maxBufferBytes);
306
414
  }
@@ -356,11 +464,14 @@ export class IterationObservationReporter {
356
464
  this.sealed = true;
357
465
  this.clock.clearInterval(this.timer);
358
466
  this.flushNow();
359
- const started = Date.now();
467
+ const started = performance.now();
360
468
  const deadline = started + this.settings.drainTimeoutMs;
469
+ for (const dispatcher of this.dispatchers) {
470
+ dispatcher.beginDrain(deadline);
471
+ }
361
472
  const drainResults = new Array(this.dispatchers.length).fill(false);
362
473
  const completionResults = await Promise.all(this.dispatchers.map(async (dispatcher, index) => {
363
- const drained = await dispatcher.waitForIdle(Math.max(deadline - Date.now(), 1));
474
+ const drained = await dispatcher.waitForIdle(Math.max(deadline - performance.now(), 1));
364
475
  drainResults[index] = drained;
365
476
  if (!dispatcher.canPublishCompletion) {
366
477
  return false;
@@ -375,19 +486,7 @@ export class IterationObservationReporter {
375
486
  && dispatcher.droppedCount === 0n
376
487
  && dispatcher.deliveredCount === this.captured;
377
488
  const completion = this.createCompletion(dispatcher, sinkReportingComplete);
378
- let timer;
379
- const outcome = await Promise.race([
380
- Promise.resolve()
381
- .then(() => complete(completion))
382
- .then(() => "success", () => "failed"),
383
- new Promise((resolve) => {
384
- timer = setTimeout(() => resolve("timeout"), Math.max(deadline - Date.now(), 1));
385
- timer.unref?.();
386
- })
387
- ]);
388
- if (timer) {
389
- clearTimeout(timer);
390
- }
489
+ const outcome = await this.completeWithRetries(complete, completion, deadline, dispatcher.target.name);
391
490
  if (outcome === "success") {
392
491
  return true;
393
492
  }
@@ -423,6 +522,76 @@ export class IterationObservationReporter {
423
522
  };
424
523
  return { ...this.finalResult };
425
524
  }
525
+ async completeWithRetries(complete, completion, deadline, sinkName) {
526
+ const retryCount = normalizeSinkRetryCount(this.options.sinkRetryCount);
527
+ const backoffMs = normalizeSinkRetryBackoffMs(this.options.sinkRetryBackoffMs);
528
+ const maximumAttempts = retryCount + 1;
529
+ for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
530
+ const remainingMs = deadline - performance.now();
531
+ if (remainingMs <= 0) {
532
+ return "timeout";
533
+ }
534
+ let timer;
535
+ const outcome = await Promise.race([
536
+ Promise.resolve()
537
+ .then(() => complete(completion))
538
+ .then(() => ({ kind: "success" }), (error) => ({ kind: "failed", error })),
539
+ new Promise((resolve) => {
540
+ timer = setTimeout(() => resolve({ kind: "timeout" }), Math.max(remainingMs, 1));
541
+ })
542
+ ]);
543
+ if (timer) {
544
+ clearTimeout(timer);
545
+ }
546
+ if (outcome.kind === "success") {
547
+ if (attempt > 1) {
548
+ logIterationObservationRecovery(this.options.logger, {
549
+ sinkName,
550
+ operation: "observation-stream-completion",
551
+ runId: this.options.runId,
552
+ resultOwnerId: this.options.resultOwnerId,
553
+ observationCount: 0,
554
+ attempt,
555
+ maximumAttempts,
556
+ nextDelayMs: 0
557
+ });
558
+ }
559
+ return "success";
560
+ }
561
+ if (outcome.kind === "timeout") {
562
+ return "timeout";
563
+ }
564
+ const exhausted = attempt >= maximumAttempts;
565
+ const nextDelayMs = exhausted ? 0 : sinkRetryDelayMs(backoffMs, attempt);
566
+ logIterationObservationFailure(this.options.logger, exhausted ? "error" : "warn", {
567
+ sinkName,
568
+ operation: "observation-stream-completion",
569
+ runId: this.options.runId,
570
+ resultOwnerId: this.options.resultOwnerId,
571
+ observationCount: 0,
572
+ attempt,
573
+ maximumAttempts,
574
+ nextDelayMs
575
+ }, outcome.error);
576
+ if (exhausted) {
577
+ return "failed";
578
+ }
579
+ const delayMs = nextDelayMs;
580
+ if (!delayCanFit(delayMs, deadline)) {
581
+ return "timeout";
582
+ }
583
+ if (delayMs === 0) {
584
+ await cooperativeSinkRetryYield();
585
+ }
586
+ else {
587
+ await waitForSinkRetryDelay(delayMs);
588
+ }
589
+ if (performance.now() >= deadline) {
590
+ return "timeout";
591
+ }
592
+ }
593
+ return "failed";
594
+ }
426
595
  buildWarnings() {
427
596
  return Array.from(this.warnings.values())
428
597
  .sort((left, right) => left.code.localeCompare(right.code)
@@ -685,6 +854,10 @@ function truncateUtf8(value, maxBytes) {
685
854
  }
686
855
  return output;
687
856
  }
857
+ function delayCanFit(delayMs, deadlineMs) {
858
+ const remainingMs = deadlineMs - performance.now();
859
+ return remainingMs > 0 && delayMs < remainingMs;
860
+ }
688
861
  function deepFreezeObservation(observation) {
689
862
  for (const step of observation.steps) {
690
863
  Object.freeze(step);
package/dist/esm/local.js CHANGED
@@ -1378,9 +1378,20 @@ function readTrackingId(payload, selector) {
1378
1378
  return null;
1379
1379
  }
1380
1380
  let current = body;
1381
- for (const segment of path.split(".").filter(Boolean)) {
1381
+ let segments;
1382
+ try {
1383
+ segments = safeJsonPathSegments(path);
1384
+ }
1385
+ catch {
1386
+ return null;
1387
+ }
1388
+ for (const segment of segments) {
1382
1389
  if (current && typeof current === "object" && !Array.isArray(current)) {
1383
- current = current[segment];
1390
+ const record = current;
1391
+ if (!Object.prototype.hasOwnProperty.call(record, segment)) {
1392
+ return null;
1393
+ }
1394
+ current = record[segment];
1384
1395
  }
1385
1396
  else {
1386
1397
  return null;
@@ -1413,23 +1424,57 @@ function readOptionalTrackingSelectorValue(value) {
1413
1424
  return undefined;
1414
1425
  }
1415
1426
  function setJsonPathValue(body, path, value) {
1416
- const target = body && typeof body === "object" && !Array.isArray(body)
1417
- ? { ...body }
1418
- : {};
1419
- const segments = path.split(".").filter(Boolean);
1427
+ const target = cloneJsonRecord(body);
1428
+ const segments = safeJsonPathSegments(path);
1420
1429
  if (!segments.length) {
1421
1430
  return target;
1422
1431
  }
1423
1432
  let current = target;
1424
1433
  for (let i = 0; i < segments.length - 1; i += 1) {
1425
1434
  const segment = segments[i];
1426
- const next = current[segment];
1435
+ const next = readOwnJsonProperty(current, segment);
1436
+ let child;
1427
1437
  if (!next || typeof next !== "object" || Array.isArray(next)) {
1428
- current[segment] = {};
1438
+ child = {};
1429
1439
  }
1430
- current = current[segment];
1440
+ else {
1441
+ child = cloneJsonRecord(next);
1442
+ }
1443
+ defineJsonProperty(current, segment, child);
1444
+ current = child;
1445
+ }
1446
+ defineJsonProperty(current, segments[segments.length - 1], value);
1447
+ return target;
1448
+ }
1449
+ const FORBIDDEN_JSON_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
1450
+ function safeJsonPathSegments(path) {
1451
+ const segments = path.split(".").filter(Boolean);
1452
+ const forbidden = segments.find((segment) => FORBIDDEN_JSON_PATH_SEGMENTS.has(segment));
1453
+ if (forbidden) {
1454
+ throw new Error(`Tracking selector contains forbidden JSON path segment '${forbidden}'.`);
1455
+ }
1456
+ return segments;
1457
+ }
1458
+ function defineJsonProperty(target, key, value) {
1459
+ Object.defineProperty(target, key, {
1460
+ configurable: true,
1461
+ enumerable: true,
1462
+ value,
1463
+ writable: true
1464
+ });
1465
+ }
1466
+ function readOwnJsonProperty(target, key) {
1467
+ const descriptor = Object.getOwnPropertyDescriptor(target, key);
1468
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
1469
+ }
1470
+ function cloneJsonRecord(value) {
1471
+ const target = {};
1472
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1473
+ return target;
1474
+ }
1475
+ for (const [key, entry] of Object.entries(value)) {
1476
+ defineJsonProperty(target, key, entry);
1431
1477
  }
1432
- current[segments[segments.length - 1]] = value;
1433
1478
  return target;
1434
1479
  }
1435
1480
  function mapCorrelationStore(tracking, runNamespace) {
@@ -38,8 +38,12 @@ function reportValue(source, ...keys) {
38
38
  }
39
39
  const record = source;
40
40
  for (const key of keys) {
41
- if (record[key] !== undefined && record[key] !== null) {
42
- return record[key];
41
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
42
+ if (descriptor
43
+ && "value" in descriptor
44
+ && descriptor.value !== undefined
45
+ && descriptor.value !== null) {
46
+ return descriptor.value;
43
47
  }
44
48
  }
45
49
  return undefined;
@@ -322,7 +326,22 @@ function formatDotnetDateTime(value) {
322
326
  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${fraction}Z`;
323
327
  }
324
328
  function escapeJsonForHtmlScript(value) {
325
- return value.split("</").join("<\\/").split("<!--").join("<\\!--");
329
+ return value.replace(/[<>&\u2028\u2029]/g, (character) => {
330
+ switch (character) {
331
+ case "<":
332
+ return "\\u003c";
333
+ case ">":
334
+ return "\\u003e";
335
+ case "&":
336
+ return "\\u0026";
337
+ case "\u2028":
338
+ return "\\u2028";
339
+ case "\u2029":
340
+ return "\\u2029";
341
+ default:
342
+ return character;
343
+ }
344
+ });
326
345
  }
327
346
  function buildReportLogoDataUri(resourceName) {
328
347
  const cached = REPORT_LOGO_CACHE.get(resourceName);
@@ -1272,7 +1291,7 @@ function buildDotnetHtmlTabs(nodeStats) {
1272
1291
  body = buildDotnetGroupedCorrelationSummaryHtml(bodyRows, `grouped-correlation-${tabs.length}`);
1273
1292
  }
1274
1293
  else if (lowerPlugin.includes("correlation") && lowerTable.includes("ungrouped correlation rows")) {
1275
- title = "Ungrouped Corelation Summary";
1294
+ title = "Ungrouped Correlation Summary";
1276
1295
  body = buildDotnetUngroupedCorrelationSummaryHtml(bodyRows, `ungrouped-correlation-${tabs.length}`);
1277
1296
  }
1278
1297
  tabs.push([`plugin-${tabs.length}`, title, `${hints}${body}`]);
@@ -1388,9 +1407,9 @@ export function buildDotnetMarkdownReport(nodeStats) {
1388
1407
  */
1389
1408
  export function buildDotnetHtmlReport(nodeStats) {
1390
1409
  const tabs = buildDotnetHtmlTabs(nodeStats);
1391
- const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${tabId}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
1392
- const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${tabId}" class="tab">${html}</section>${REPORT_EOL}`).join("");
1393
- const chartDataJson = JSON.stringify(buildDotnetChartData(nodeStats));
1410
+ const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${escapeHtml(tabId)}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
1411
+ const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${escapeHtml(tabId)}" class="tab">${html}</section>${REPORT_EOL}`).join("");
1412
+ const chartDataJson = escapeJsonForHtmlScript(JSON.stringify(buildDotnetChartData(nodeStats)));
1394
1413
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1395
1414
  const template = `<!doctype html>
1396
1415
  <html lang="en">