@reproapp/node-sdk 0.0.49 → 0.0.51

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/src/index.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  type RuntimePrivacyTraceContext,
30
30
  } from './privacy';
31
31
  import { projectSemanticInfrastructureValue } from './semantic-serialization';
32
+ import { limitLiveCapture, limitLiveArguments, collectCaptureOmissions, type CaptureOmission } from './live-capture-limits';
32
33
 
33
34
  /**
34
35
  * ============================= IMPORTANT =============================
@@ -380,6 +381,8 @@ type PendingTraceEventRecord = TraceEventRecord & {
380
381
  };
381
382
 
382
383
  type TraceValueCaptureRef = {
384
+ complete?: boolean;
385
+ omissions?: CaptureOmission[];
383
386
  status:
384
387
  | 'stored'
385
388
  | 'truncated-for-storage'
@@ -419,6 +422,7 @@ type TraceValueBatchEntry = {
419
422
  };
420
423
 
421
424
  type TraceMaterializationConfig = {
425
+ captureMode?: 'live' | 'staging';
422
426
  tenantId: string;
423
427
  appId: string;
424
428
  appSecret?: string;
@@ -1213,6 +1217,7 @@ type NativeMongoSuppression = {
1213
1217
  };
1214
1218
 
1215
1219
  type RequestCaptureScopeState = {
1220
+ liveCapture?: boolean;
1216
1221
  requestRid: string;
1217
1222
  transport: 'http' | 'kafka' | 'amqp' | 'background';
1218
1223
  backendRequestCount: number;
@@ -1246,6 +1251,7 @@ function createRequestCaptureScope(
1246
1251
  return {
1247
1252
  requestRid,
1248
1253
  transport,
1254
+ liveCapture: cfg?.captureMode === 'live' || (!!cfg && cfg.captureMode !== 'staging' && !!normalizeLiveModeConfig(cfg)),
1249
1255
  // Every scope represents one captured transport request. This is an
1250
1256
  // observed expectation, not a count of successful enqueue operations.
1251
1257
  backendRequestCount: 1,
@@ -1546,8 +1552,9 @@ let activeClientRequestCount = 0;
1546
1552
  let oldestActiveClientRequestAt: number | null = null;
1547
1553
  let lastClientActivityAt = 0;
1548
1554
  let sdkBackgroundQuietUntil = 0;
1549
- const sdkBackgroundHighQueue: Array<() => Promise<void> | void> = [];
1550
- const sdkBackgroundQueue: Array<() => Promise<void> | void> = [];
1555
+ type SdkBackgroundWork = { work: () => Promise<void> | void; enqueuedAt: number };
1556
+ const sdkBackgroundHighQueue: SdkBackgroundWork[] = [];
1557
+ const sdkBackgroundQueue: SdkBackgroundWork[] = [];
1551
1558
  let sdkBackgroundTimer: NodeJS.Timeout | null = null;
1552
1559
  let sdkBackgroundDraining = false;
1553
1560
  let traceMaterializerWorker: any = null;
@@ -1569,23 +1576,25 @@ function noteClientActivity(now = Date.now()): void {
1569
1576
  lastClientActivityAt = now;
1570
1577
  }
1571
1578
 
1572
- function getSdkBackgroundDelayMs(now = Date.now()): number {
1579
+ function getSdkBackgroundDelayMs(now = Date.now(), ingestEnqueuedAt?: number): number {
1580
+ // Bound deferral by work age, not just by a continuously active request.
1581
+ // Short non-overlapping requests reset oldestActiveClientRequestAt forever.
1582
+ const oldestWorkAt = Math.min(
1583
+ sdkBackgroundHighQueue[0]?.enqueuedAt ?? Infinity,
1584
+ sdkBackgroundQueue[0]?.enqueuedAt ?? Infinity,
1585
+ ingestEnqueuedAt ?? Infinity,
1586
+ oldestActiveClientRequestAt ?? Infinity,
1587
+ );
1588
+ const remainingDeferMs = Number.isFinite(oldestWorkAt) && Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS)
1589
+ ? Math.max(0, oldestWorkAt + SDK_BACKGROUND_MAX_DEFER_MS - now)
1590
+ : Infinity;
1573
1591
  if (hasActiveClientRequests()) {
1574
- if (
1575
- oldestActiveClientRequestAt !== null &&
1576
- Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS) &&
1577
- SDK_BACKGROUND_MAX_DEFER_MS >= 0
1578
- ) {
1579
- const activeForMs = now - oldestActiveClientRequestAt;
1580
- if (activeForMs >= SDK_BACKGROUND_MAX_DEFER_MS) return 0;
1581
- return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, Math.max(0, SDK_BACKGROUND_MAX_DEFER_MS - activeForMs));
1582
- }
1583
- return SDK_BACKGROUND_IDLE_DELAY_MS;
1592
+ return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, remainingDeferMs);
1584
1593
  }
1585
1594
  const sinceLastActivity = now - lastClientActivityAt;
1586
1595
  const idleDelayRemaining = Math.max(0, SDK_BACKGROUND_IDLE_DELAY_MS - sinceLastActivity);
1587
1596
  const quietWindowRemaining = Math.max(0, sdkBackgroundQuietUntil - now);
1588
- return Math.max(idleDelayRemaining, quietWindowRemaining);
1597
+ return Math.min(Math.max(idleDelayRemaining, quietWindowRemaining), remainingDeferMs);
1589
1598
  }
1590
1599
 
1591
1600
  function shouldDeferSdkBackgroundWork(now = Date.now()): boolean {
@@ -1798,10 +1807,8 @@ function beginClientRequest(): void {
1798
1807
  oldestActiveClientRequestAt = now;
1799
1808
  }
1800
1809
  activeClientRequestCount += 1;
1801
- if (sdkBackgroundTimer) {
1802
- try { clearTimeout(sdkBackgroundTimer); } catch {}
1803
- sdkBackgroundTimer = null;
1804
- }
1810
+ // The scheduled drain rechecks activity; cancelling it here loses the
1811
+ // queued work's deadline under sustained traffic.
1805
1812
  }
1806
1813
 
1807
1814
  function endClientRequest(): void {
@@ -1822,15 +1829,17 @@ function scheduleSdkBackgroundWork(
1822
1829
  work: () => Promise<void> | void,
1823
1830
  options: { priority?: 'high' | 'normal' } = {},
1824
1831
  ): void {
1832
+ const item = { work, enqueuedAt: Date.now() };
1825
1833
  if (options.priority === 'high') {
1826
- sdkBackgroundHighQueue.push(work);
1834
+ sdkBackgroundHighQueue.push(item);
1827
1835
  } else {
1828
- sdkBackgroundQueue.push(work);
1836
+ sdkBackgroundQueue.push(item);
1829
1837
  }
1830
1838
  scheduleSdkBackgroundDrain();
1831
1839
  }
1832
1840
 
1833
1841
  function scheduleSdkBackgroundDrain(delayMs = 0): void {
1842
+ if (!sdkBackgroundHighQueue.length && !sdkBackgroundQueue.length) return;
1834
1843
  if (sdkBackgroundTimer) return;
1835
1844
  const nextDelay = Math.max(delayMs, getSdkBackgroundDelayMs());
1836
1845
  sdkBackgroundTimer = setTimeout(() => {
@@ -1865,11 +1874,11 @@ async function drainSdkBackgroundQueue(): Promise<void> {
1865
1874
  scheduleSdkBackgroundDrain();
1866
1875
  return;
1867
1876
  }
1868
- const work = sdkBackgroundHighQueue.shift() ?? sdkBackgroundQueue.shift();
1869
- if (!work) continue;
1877
+ const item = sdkBackgroundHighQueue.shift() ?? sdkBackgroundQueue.shift();
1878
+ if (!item) continue;
1870
1879
  try {
1871
1880
  await drainQueuedIngestBeforeBackgroundWork();
1872
- await runSdkBackgroundWork(work);
1881
+ await runSdkBackgroundWork(item.work);
1873
1882
  } catch {
1874
1883
  // never break user code
1875
1884
  }
@@ -1885,8 +1894,8 @@ async function drainSdkBackgroundQueue(): Promise<void> {
1885
1894
  }
1886
1895
 
1887
1896
  configureIngestQueueDrain({
1888
- shouldDefer: shouldDeferSdkBackgroundWork,
1889
- deferMs: SDK_BACKGROUND_IDLE_DELAY_MS,
1897
+ shouldDefer: (enqueuedAt) => getSdkBackgroundDelayMs(Date.now(), enqueuedAt) > 0,
1898
+ deferMs: Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, SDK_BACKGROUND_MAX_DEFER_MS),
1890
1899
  });
1891
1900
 
1892
1901
  // Track in-flight requests per session so we can delay flushing until a session is drained.
@@ -2599,6 +2608,7 @@ async function resolveBackendLiveContext(
2599
2608
  liveMode,
2600
2609
  '/v1/live-sessions/claim',
2601
2610
  {
2611
+ liveCapture: liveMode.liveCapture,
2602
2612
  clientTime: requestStart,
2603
2613
  captureMode: 'backend-only',
2604
2614
  actorId,
@@ -2712,6 +2722,7 @@ function stripReproQueryFromUrl(rawUrl: string): string {
2712
2722
  parsed.searchParams.delete(REPRO_QUERY_SESSION_PARAM);
2713
2723
  parsed.searchParams.delete(REPRO_QUERY_ACTION_PARAM);
2714
2724
  parsed.searchParams.delete(REPRO_QUERY_START_PARAM);
2725
+ parsed.searchParams.delete('__repro_mode');
2715
2726
  return `${parsed.pathname}${parsed.search}${parsed.hash}`;
2716
2727
  } catch {
2717
2728
  return rawUrl;
@@ -2725,6 +2736,7 @@ function stripReproQueryParams(req: Request): void {
2725
2736
  delete query[REPRO_QUERY_SESSION_PARAM];
2726
2737
  delete query[REPRO_QUERY_ACTION_PARAM];
2727
2738
  delete query[REPRO_QUERY_START_PARAM];
2739
+ delete query.__repro_mode;
2728
2740
  }
2729
2741
  } catch {}
2730
2742
  try {
@@ -3439,6 +3451,11 @@ function snapshotTraceValueAtObservation(
3439
3451
  target = 'trace.value',
3440
3452
  ): any {
3441
3453
  try {
3454
+ if (captureScope?.liveCapture) {
3455
+ value = target === 'trace.args' && Array.isArray(value)
3456
+ ? limitLiveArguments(value)
3457
+ : limitLiveCapture(value, valuePath).value;
3458
+ }
3442
3459
  const snapshot = sanitizeTraceValue(
3443
3460
  value,
3444
3461
  0,
@@ -3714,6 +3731,7 @@ async function materializePendingTraceEventsAsync(
3714
3731
  appSecret: params.cfg.appSecret,
3715
3732
  appName: params.cfg.appName,
3716
3733
  privacy: params.cfg.privacy,
3734
+ captureMode: params.captureScope?.liveCapture ? 'live' : params.cfg.captureMode,
3717
3735
  },
3718
3736
  maskReq: params.maskReq,
3719
3737
  masking: params.masking,
@@ -4223,6 +4241,18 @@ async function initializeRuntimePrivacyPolicyAtStartup(cfg: ReproMiddlewareConfi
4223
4241
  );
4224
4242
  }
4225
4243
 
4244
+ function isLiveCapture(cfg: ReproMiddlewareConfig, scope?: RequestCaptureScopeState | null): boolean {
4245
+ if (scope) return scope.liveCapture === true;
4246
+ return cfg.captureMode === 'live' || (cfg.captureMode !== 'staging' && getCtx().captureScope?.liveCapture === true);
4247
+ }
4248
+
4249
+ function constrainLiveCapture(value: any, cfg: ReproMiddlewareConfig, target: string, scope?: RequestCaptureScopeState | null): any {
4250
+ if (!isLiveCapture(cfg, scope)) return value;
4251
+ return target === 'trace.args' && Array.isArray(value)
4252
+ ? limitLiveArguments(value)
4253
+ : limitLiveCapture(value).value;
4254
+ }
4255
+
4226
4256
  function applyPrivacyThenMask(
4227
4257
  target: RuntimePrivacySurface,
4228
4258
  value: any,
@@ -4233,6 +4263,7 @@ function applyPrivacyThenMask(
4233
4263
  privacy: NormalizedRuntimePrivacyPolicy | null,
4234
4264
  db?: RuntimePrivacyDbContext | null,
4235
4265
  ) {
4266
+ value = constrainLiveCapture(value, cfg, target);
4236
4267
  if (shouldCapturePrivacyRaw(cfg)) {
4237
4268
  return value;
4238
4269
  }
@@ -4263,6 +4294,7 @@ async function applyPrivacyThenMaskAsync(
4263
4294
  privacy: NormalizedRuntimePrivacyPolicy | null,
4264
4295
  db?: RuntimePrivacyDbContext | null,
4265
4296
  ) {
4297
+ value = constrainLiveCapture(value, cfg, target);
4266
4298
  if (shouldCapturePrivacyRaw(cfg)) {
4267
4299
  return value;
4268
4300
  }
@@ -4342,6 +4374,7 @@ function materializeInlinePrivacyValue(
4342
4374
  db?: RuntimePrivacyDbContext | null,
4343
4375
  captureScope?: RequestCaptureScopeState | null,
4344
4376
  ): InlinePrivacyMaterializationResult {
4377
+ value = constrainLiveCapture(value, cfg, target, captureScope);
4345
4378
  if (value === undefined) {
4346
4379
  return { value };
4347
4380
  }
@@ -4381,6 +4414,7 @@ async function materializeInlinePrivacyValueAsync(
4381
4414
  db?: RuntimePrivacyDbContext | null,
4382
4415
  captureScope?: RequestCaptureScopeState | null,
4383
4416
  ): Promise<InlinePrivacyMaterializationResult> {
4417
+ value = constrainLiveCapture(value, cfg, target, captureScope);
4384
4418
  if (value === undefined) {
4385
4419
  return { value };
4386
4420
  }
@@ -5549,6 +5583,9 @@ function createCapturedValueEntry(
5549
5583
  ): { captureRef: TraceValueCaptureRef; entry?: TraceValueBatchEntry } | undefined {
5550
5584
  if (!__FULL_VALUE_CAPTURE_ENABLED) return undefined;
5551
5585
 
5586
+ const limitedRaw = constrainLiveCapture(params.rawValue, params.capture.runtimeConfig, params.target);
5587
+ const omissions = isLiveCapture(params.capture.runtimeConfig) ? collectCaptureOmissions(limitedRaw) : [];
5588
+
5552
5589
  const preferredLimits: FullValueCloneLimits = {
5553
5590
  maxDepth: TRACE_FULL_VALUE_MAX_DEPTH,
5554
5591
  maxKeys: TRACE_FULL_VALUE_MAX_KEYS,
@@ -5558,7 +5595,7 @@ function createCapturedValueEntry(
5558
5595
  const buildMaskedCandidate = (limits: FullValueCloneLimits) => {
5559
5596
  const projected = params.target === 'request.headers'
5560
5597
  ? sanitizeHeaders(params.rawValue, params.capture.captureHeaders)
5561
- : cloneFullValueCandidate(params.rawValue, params.capture, limits);
5598
+ : cloneFullValueCandidate(limitedRaw, params.capture, limits);
5562
5599
  if (projected === undefined) return undefined;
5563
5600
  return applyPrivacyThenMask(
5564
5601
  params.target,
@@ -5579,7 +5616,7 @@ function createCapturedValueEntry(
5579
5616
  }
5580
5617
 
5581
5618
  const candidateSerialized = stableSerializeTraceValue(candidate);
5582
- if (!candidateSerialized || candidateSerialized === previewSerialized) {
5619
+ if (!candidateSerialized || (candidateSerialized === previewSerialized && !omissions.length)) {
5583
5620
  return undefined;
5584
5621
  }
5585
5622
 
@@ -5605,6 +5642,7 @@ function createCapturedValueEntry(
5605
5642
  status: 'stored',
5606
5643
  valueId,
5607
5644
  projectedKind: entry.projectedKind,
5645
+ ...(omissions.length ? { complete: false, omissions } : {}),
5608
5646
  },
5609
5647
  entry,
5610
5648
  };
@@ -5635,6 +5673,9 @@ async function createCapturedValueEntryAsync(
5635
5673
  ): Promise<{ captureRef: TraceValueCaptureRef; entry?: TraceValueBatchEntry } | undefined> {
5636
5674
  if (!__FULL_VALUE_CAPTURE_ENABLED) return undefined;
5637
5675
 
5676
+ const limitedRaw = constrainLiveCapture(params.rawValue, params.capture.runtimeConfig, params.target);
5677
+ const omissions = isLiveCapture(params.capture.runtimeConfig) ? collectCaptureOmissions(limitedRaw) : [];
5678
+
5638
5679
  const preferredLimits: FullValueCloneLimits = {
5639
5680
  maxDepth: TRACE_FULL_VALUE_MAX_DEPTH,
5640
5681
  maxKeys: TRACE_FULL_VALUE_MAX_KEYS,
@@ -5644,7 +5685,7 @@ async function createCapturedValueEntryAsync(
5644
5685
  const buildMaskedCandidate = async (limits: FullValueCloneLimits) => {
5645
5686
  const projected = params.target === 'request.headers'
5646
5687
  ? sanitizeHeaders(params.rawValue, params.capture.captureHeaders)
5647
- : await cloneFullValueCandidateAsync(params.rawValue, params.capture, limits);
5688
+ : await cloneFullValueCandidateAsync(limitedRaw, params.capture, limits);
5648
5689
  if (projected === undefined) return undefined;
5649
5690
  return applyPrivacyThenMaskAsync(
5650
5691
  params.target,
@@ -5665,7 +5706,7 @@ async function createCapturedValueEntryAsync(
5665
5706
  }
5666
5707
 
5667
5708
  const candidateSerialized = stableSerializeTraceValue(candidate);
5668
- if (!candidateSerialized || candidateSerialized === previewSerialized) {
5709
+ if (!candidateSerialized || (candidateSerialized === previewSerialized && !omissions.length)) {
5669
5710
  return undefined;
5670
5711
  }
5671
5712
 
@@ -5691,6 +5732,7 @@ async function createCapturedValueEntryAsync(
5691
5732
  status: 'stored',
5692
5733
  valueId,
5693
5734
  projectedKind: entry.projectedKind,
5735
+ ...(omissions.length ? { complete: false, omissions } : {}),
5694
5736
  },
5695
5737
  entry,
5696
5738
  };
@@ -5892,6 +5934,8 @@ async function maybeCaptureDbValueFromCandidatesAsync(
5892
5934
  // reproMiddleware — unchanged behavior + passive per-request trace
5893
5935
  // ===================================================================
5894
5936
  export type ReproMiddlewareConfig = IngestClientConfig & {
5937
+ /** Propagated recording mode. A tagged staging request never inherits live limits. */
5938
+ captureMode?: 'live' | 'staging';
5895
5939
  /** API base used for backend-owned live session documents. Defaults to REPRO_API_BASE_URL/REPRO_API_BASE/privacy.apiBase. */
5896
5940
  apiBase?: string;
5897
5941
  /** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
@@ -5923,6 +5967,11 @@ export type ReproLiveActorDecision = ReproLiveActorArgs & {
5923
5967
  };
5924
5968
 
5925
5969
  export type ReproLiveModeConfig = {
5970
+ /** Stable chain sampling. Runtime updates apply to new chains; targets are evaluated on each claim. */
5971
+ liveCapture?: {
5972
+ sampleRate?: number;
5973
+ targets?: { actorIds?: string[]; labels?: Record<string, string>; expiresAt: number }[];
5974
+ };
5926
5975
  enabled?: boolean;
5927
5976
  backendOnly?: boolean;
5928
5977
  /**
@@ -6112,6 +6161,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
6112
6161
  const querySid = readRequestQueryParam(req, REPRO_QUERY_SESSION_PARAM);
6113
6162
  const queryAid = readRequestQueryParam(req, REPRO_QUERY_ACTION_PARAM);
6114
6163
  const queryTs = readRequestQueryNumber(req, REPRO_QUERY_START_PARAM);
6164
+ const queryCaptureMode = readRequestQueryParam(req, '__repro_mode');
6115
6165
  if (querySid || queryAid || queryTs !== null) {
6116
6166
  stripReproQueryParams(req);
6117
6167
  }
@@ -6181,6 +6231,11 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
6181
6231
  }],
6182
6232
  }, { priority: 'high' });
6183
6233
  }
6234
+ const captureMode = backendLiveContext || req.headers['x-repro-capture-mode'] === 'live' || queryCaptureMode === 'live'
6235
+ ? 'live' : 'staging';
6236
+ const modeConfig = { ...captureCfg, captureMode } as ReproMiddlewareConfig;
6237
+ shareRuntimePrivacyState(captureCfg, modeConfig);
6238
+ captureCfg = modeConfig;
6184
6239
  const resolvePrivacy = (): NormalizedRuntimePrivacyPolicy | null =>
6185
6240
  getRuntimePrivacyState(captureCfg).policy ?? null;
6186
6241
 
@@ -7745,6 +7800,7 @@ function patchNodeFetchForPropagation() {
7745
7800
  if (runtime.aid && !hasHttpHeader(headers, KAFKA_HEADER_ACTION_ID)) {
7746
7801
  setHttpHeader(headers, KAFKA_HEADER_ACTION_ID, runtime.aid);
7747
7802
  }
7803
+ if (runtime.sid) setHttpHeader(headers, 'x-repro-capture-mode', getCtx().captureScope?.liveCapture ? 'live' : 'staging');
7748
7804
  if (runtime.traceId && !hasHttpHeader(headers, KAFKA_HEADER_TRACE_ID)) {
7749
7805
  setHttpHeader(headers, KAFKA_HEADER_TRACE_ID, runtime.traceId);
7750
7806
  }
@@ -7799,6 +7855,7 @@ function patchNodeHttpModuleRequest(mod: any) {
7799
7855
  maybeSet(REQUEST_START_HEADER, String(alignedNow()));
7800
7856
  maybeSet(KAFKA_HEADER_SESSION_ID, runtime.sid);
7801
7857
  maybeSet(KAFKA_HEADER_ACTION_ID, runtime.aid);
7858
+ if (runtime.sid) maybeSet('x-repro-capture-mode', getCtx().captureScope?.liveCapture ? 'live' : 'staging');
7802
7859
  maybeSet(KAFKA_HEADER_TRACE_ID, runtime.traceId);
7803
7860
  maybeSet(KAFKA_HEADER_PARENT_SPAN_ID, runtime.parentSpanId);
7804
7861
  maybeSet(KAFKA_HEADER_REQUEST_ID, runtime.requestRid);
@@ -7826,6 +7883,7 @@ type KafkaHeaderValue = string | Buffer | null | undefined;
7826
7883
  type KafkaHeaderMap = Record<string, KafkaHeaderValue>;
7827
7884
  type KafkaContextResolver = () => { sid?: string; aid?: string } | undefined;
7828
7885
  type KafkaPropagationContext = {
7886
+ captureMode?: 'live' | 'staging';
7829
7887
  sid: string | null;
7830
7888
  aid: string | null;
7831
7889
  traceId: string | null;
@@ -7990,6 +8048,11 @@ async function materializeKafkaRequestBody(
7990
8048
  body: Record<string, any>,
7991
8049
  captureScope?: RequestCaptureScopeState | null,
7992
8050
  ): Promise<InlinePrivacyMaterializationResult> {
8051
+ // Broker bodies have no separate full-value reference. Returning the trace
8052
+ // inline preview here would permanently replace message arrays with 20 items.
8053
+ if (shouldCapturePrivacyRaw(cfg)) {
8054
+ return { value: constrainLiveCapture(sanitizeInlinePrivacyValue(body), cfg, 'request.body', captureScope) };
8055
+ }
7993
8056
  return materializeInlinePrivacyValueAsync(
7994
8057
  'request.body',
7995
8058
  sanitizeInlinePrivacyValue(body),
@@ -8053,7 +8116,8 @@ function resolveKafkaContext(cfg: KafkaJsPatchConfig, headers: any): KafkaPropag
8053
8116
  null;
8054
8117
  const requestRid = readKafkaHeader(headers, KAFKA_HEADER_REQUEST_ID);
8055
8118
 
8056
- return { sid, aid, traceId, parentSpanId, requestRid };
8119
+ return { sid, aid, traceId, parentSpanId, requestRid,
8120
+ captureMode: readKafkaHeader(headers, 'x-repro-capture-mode') === 'live' || ctx.captureScope?.liveCapture ? 'live' : 'staging' };
8057
8121
  }
8058
8122
 
8059
8123
  function withOptionalTraceContext<T>(traceId: string | null, fn: () => Promise<T>): Promise<T> {
@@ -8420,6 +8484,7 @@ function patchKafkaProducerInstance(producer: any, cfg: KafkaJsPatchConfig) {
8420
8484
  [KAFKA_HEADER_REQUEST_ID]: publishRid,
8421
8485
  [KAFKA_HEADER_SESSION_ID]: sid,
8422
8486
  [KAFKA_HEADER_ACTION_ID]: aid,
8487
+ 'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
8423
8488
  }),
8424
8489
  };
8425
8490
  });
@@ -8552,6 +8617,7 @@ function patchKafkaProducerInstance(producer: any, cfg: KafkaJsPatchConfig) {
8552
8617
  [KAFKA_HEADER_REQUEST_ID]: publishRid,
8553
8618
  [KAFKA_HEADER_SESSION_ID]: sid,
8554
8619
  [KAFKA_HEADER_ACTION_ID]: aid,
8620
+ 'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
8555
8621
  }),
8556
8622
  };
8557
8623
  }),
@@ -8668,6 +8734,7 @@ function wrapKafkaEachMessageHandler(eachMessage: any, cfg: KafkaJsPatchConfig,
8668
8734
  const consumeRid = nextRequestId(`${topic}-${message?.offset ?? alignedNow()}`);
8669
8735
  const traceId = consumeRid;
8670
8736
  const captureScope = createRequestCaptureScope(consumeRid, 'kafka', cfg);
8737
+ captureScope.liveCapture = runtime.captureMode === 'live';
8671
8738
  emitRequestCaptureStart(cfg, sid, aid, captureScope);
8672
8739
  const rawTraceEvents: any[] = [];
8673
8740
  const maskReq: MaskRequestContext = {
@@ -8778,6 +8845,7 @@ function wrapKafkaEachBatchHandler(eachBatch: any, cfg: KafkaJsPatchConfig, cons
8778
8845
  const consumeRid = nextRequestId(`${topic}-batch-${alignedNow()}`);
8779
8846
  const traceId = consumeRid;
8780
8847
  const captureScope = createRequestCaptureScope(consumeRid, 'kafka', cfg);
8848
+ captureScope.liveCapture = runtime.captureMode === 'live';
8781
8849
  emitRequestCaptureStart(cfg, sid, aid, captureScope);
8782
8850
  const rawTraceEvents: any[] = [];
8783
8851
  const maskReq: MaskRequestContext = {
@@ -8972,6 +9040,7 @@ export function patchKafkaJs(cfg: KafkaJsPatchConfig) {
8972
9040
  type AmqpHeaderMap = Record<string, any>;
8973
9041
  type AmqpContextResolver = () => { sid?: string; aid?: string } | undefined;
8974
9042
  type AmqpPropagationContext = {
9043
+ captureMode?: 'live' | 'staging';
8975
9044
  sid: string | null;
8976
9045
  aid: string | null;
8977
9046
  traceId: string | null;
@@ -9240,7 +9309,8 @@ function resolveAmqpContext(cfg: AmqplibPatchConfig, headers: any): AmqpPropagat
9240
9309
  null;
9241
9310
  const requestRid = readAmqpHeader(headers, KAFKA_HEADER_REQUEST_ID);
9242
9311
 
9243
- return { sid, aid, traceId, parentSpanId, requestRid };
9312
+ return { sid, aid, traceId, parentSpanId, requestRid,
9313
+ captureMode: readAmqpHeader(headers, 'x-repro-capture-mode') === 'live' || ctx.captureScope?.liveCapture ? 'live' : 'staging' };
9244
9314
  }
9245
9315
 
9246
9316
  function withOptionalTraceContextSync<T>(traceId: string | null, fn: () => T): T {
@@ -9478,6 +9548,7 @@ function patchAmqplibPublishMethod(
9478
9548
  [KAFKA_HEADER_REQUEST_ID]: publishRid,
9479
9549
  [KAFKA_HEADER_SESSION_ID]: sid,
9480
9550
  [KAFKA_HEADER_ACTION_ID]: aid,
9551
+ 'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
9481
9552
  };
9482
9553
  const shouldPropagate = hasAmqpPropagationValues(propagationValues);
9483
9554
  const callbackIndex = args.length > 0 && typeof args[args.length - 1] === 'function'
@@ -9586,6 +9657,7 @@ function wrapAmqplibConsumeHandler(onMessage: any, cfg: AmqplibPatchConfig, queu
9586
9657
  const consumeRid = nextRequestId(`${queueName}-${deliveryTag}`);
9587
9658
  const traceId = consumeRid;
9588
9659
  const captureScope = createRequestCaptureScope(consumeRid, 'amqp', cfg);
9660
+ captureScope.liveCapture = runtime.captureMode === 'live';
9589
9661
  emitRequestCaptureStart(cfg, sid, aid, captureScope);
9590
9662
  const rawTraceEvents: any[] = [];
9591
9663
  const maskReq: MaskRequestContext = {
@@ -44,6 +44,7 @@ type QueuedIngestBatch = {
44
44
  sessionId: string;
45
45
  entries: LegacyEntry[];
46
46
  attempts: number;
47
+ enqueuedAt: number;
47
48
  priority: 'normal' | 'high' | 'critical';
48
49
  };
49
50
 
@@ -59,11 +60,13 @@ let draining = false;
59
60
  let criticalDraining = false;
60
61
  let backoffMs = 0;
61
62
  let criticalBackoffMs = 0;
62
- let shouldDeferDrain: (() => boolean) | null = null;
63
+ let shouldDeferDrain: ((enqueuedAt?: number) => boolean) | null = null;
63
64
  let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
65
+ const throttledUntil = new Map<string, number>();
66
+ const throttleDelay = (config: IngestClientConfig) => Math.max(0, (throttledUntil.get(resolveIngestUrl(config)) ?? 0) - Date.now());
64
67
 
65
68
  export const configureIngestQueueDrain = (options: {
66
- shouldDefer?: (() => boolean) | null;
69
+ shouldDefer?: ((enqueuedAt?: number) => boolean) | null;
67
70
  deferMs?: number;
68
71
  }): void => {
69
72
  shouldDeferDrain = options.shouldDefer ?? null;
@@ -77,7 +80,7 @@ export const kickIngestQueueDrain = (): void => {
77
80
  scheduleCriticalDrain();
78
81
  }
79
82
  if (ingestQueue.length > 0 && !drainTimer) {
80
- scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
83
+ scheduleDrain(shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt) ? deferDrainMs : 0);
81
84
  }
82
85
  };
83
86
 
@@ -220,7 +223,9 @@ const sendIngestEntries = async (
220
223
  config: IngestClientConfig,
221
224
  sessionId: string,
222
225
  entries: LegacyEntry[],
223
- ): Promise<boolean> => {
226
+ ): Promise<boolean | 'throttled' | 'denied'> => {
227
+ if (throttleDelay(config) > 0) return 'throttled';
228
+ throttledUntil.delete(resolveIngestUrl(config));
224
229
  if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
225
230
  return true;
226
231
  }
@@ -255,6 +260,18 @@ const sendIngestEntries = async (
255
260
  body: JSON.stringify({ events }),
256
261
  ...(controller ? { signal: controller.signal } : {}),
257
262
  });
263
+ if (response.status === 429) {
264
+ const retryAfter = response.headers?.get?.('retry-after');
265
+ const seconds = retryAfter ? Number(retryAfter) : NaN;
266
+ const date = retryAfter ? Date.parse(retryAfter) : NaN;
267
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Number.isFinite(date) ? date - Date.now() : 1000;
268
+ throttledUntil.set(resolveIngestUrl(config), Date.now() + Math.max(1000, Math.min(86400_000, delay)));
269
+ return 'throttled';
270
+ }
271
+ if (response.status === 402 || response.status === 410) {
272
+ console.warn(`[repro] Upload refused for ${sessionId}: ${response.status === 402 ? 'capture quota exhausted' : 'capture deleted, expired or failed'}. This batch will not be retried.`);
273
+ return 'denied';
274
+ }
258
275
  return response.ok;
259
276
  } catch {
260
277
  // swallow in SDK
@@ -272,12 +289,12 @@ const scheduleDrain = (delayMs = 0): void => {
272
289
  if (drainTimer) {
273
290
  return;
274
291
  }
275
- const nextDelay = shouldDeferDrain?.()
292
+ const nextDelay = shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)
276
293
  ? Math.max(delayMs, deferDrainMs)
277
294
  : delayMs;
278
295
  drainTimer = setTimeout(() => {
279
296
  drainTimer = null;
280
- if (shouldDeferDrain?.()) {
297
+ if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
281
298
  scheduleDrain(deferDrainMs);
282
299
  return;
283
300
  }
@@ -317,7 +334,11 @@ const drainCriticalIngestQueue = async (): Promise<void> => {
317
334
  selected.batch.sessionId,
318
335
  selected.batch.entries,
319
336
  );
320
- if (ok) {
337
+ if (ok === 'throttled') {
338
+ scheduleCriticalDrain(throttleDelay(selected.batch.config));
339
+ return;
340
+ }
341
+ if (ok === true || ok === 'denied') {
321
342
  criticalIngestQueue.splice(0, selected.queueItems);
322
343
  criticalBackoffMs = 0;
323
344
  continue;
@@ -345,14 +366,14 @@ const drainIngestQueue = async (): Promise<void> => {
345
366
  if (draining) {
346
367
  return;
347
368
  }
348
- if (shouldDeferDrain?.()) {
369
+ if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
349
370
  scheduleDrain(deferDrainMs);
350
371
  return;
351
372
  }
352
373
  draining = true;
353
374
  try {
354
375
  while (ingestQueue.length > 0) {
355
- if (shouldDeferDrain?.()) {
376
+ if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
356
377
  scheduleDrain(deferDrainMs);
357
378
  return;
358
379
  }
@@ -362,7 +383,11 @@ const drainIngestQueue = async (): Promise<void> => {
362
383
  }
363
384
  const batch = selected.batch;
364
385
  const ok = await sendIngestEntries(batch.config, batch.sessionId, batch.entries);
365
- if (ok) {
386
+ if (ok === 'throttled') {
387
+ scheduleDrain(throttleDelay(batch.config));
388
+ return;
389
+ }
390
+ if (ok === true || ok === 'denied') {
366
391
  ingestQueue.splice(0, selected.queueItems);
367
392
  backoffMs = 0;
368
393
  continue;
@@ -420,6 +445,7 @@ export const enqueueIngestEntries = (
420
445
  sessionId,
421
446
  entries: entries.slice(),
422
447
  attempts: 0,
448
+ enqueuedAt: Date.now(),
423
449
  priority,
424
450
  };
425
451
  if (priority === 'critical') {