@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/README.md CHANGED
@@ -107,6 +107,7 @@ await initRepro({
107
107
  appName: "Acme Web",
108
108
  serviceName: "api",
109
109
  apiBase: process.env.REPRO_API_BASE,
110
+ ingestBase: process.env.REPRO_INGEST_BASE!,
110
111
  });
111
112
  ```
112
113
 
@@ -123,14 +124,56 @@ await initRepro({
123
124
  liveMode: {
124
125
  enabled: true,
125
126
  backendOnly: true,
126
- defineActorId: ({ req, ip }) => req.headers["x-user-id"] ?? (ip ? `ip:${ip}` : null),
127
+ // Trust only a header that your authenticated gateway overwrites.
128
+ defineActorId: ({ req }) => req.headers["x-user-id"] ?? null,
127
129
  shouldCapture: ({ path }) => path.startsWith("/api/") && !path.includes("/health"),
128
130
  actorType: "user",
129
131
  actorLabels: ({ path }) => ({ routeGroup: path.split("/").filter(Boolean)[0] ?? "api" }),
132
+ liveCapture: { sampleRate: 0.1, targets: [] },
130
133
  },
131
134
  });
132
135
  ```
133
136
 
137
+ ### Sampling, limits and runtime targets
138
+
139
+ Backend-owned live capture accepts `liveMode.liveCapture.sampleRate` from 0 to 1
140
+ (default 1 for compatibility). A chain keeps its sample decision until five minutes
141
+ of inactivity. Actor identity separates chains; three-minute child segments admit
142
+ new work. Admitted requests and their queue/DB/trace tails stay in the original
143
+ segment even when delivery finishes later. Browser-correlated captures use the
144
+ browser's admission decision, rather than sampling a second time.
145
+
146
+ Keep the `liveCapture` object in your runtime configuration and replace its `targets`
147
+ array when a support case starts or ends. Example target entries:
148
+
149
+ ```ts
150
+ const liveCapture = {
151
+ sampleRate: 0.1,
152
+ targets: [
153
+ { actorIds: ['user-123'], expiresAt: Date.now() + 3600000 },
154
+ { labels: { routeGroup: 'reports' }, expiresAt: Date.now() + 3600000 },
155
+ ],
156
+ };
157
+ // Pass this object as liveMode.liveCapture; update liveCapture.targets at runtime.
158
+ ```
159
+
160
+ Targets override sampling, including rate 0, but never quota admission. All labels
161
+ in one target must match; actor IDs and label selectors are alternatives. Use a
162
+ trusted identity source, not arbitrary client-controlled headers.
163
+
164
+ Live business values have depth 10, 100 keys per object, and 100 entries per array,
165
+ Map, Set or native collection. Partial captures carry explicit omitted path/reason/
166
+ count metadata. `captureRaw: true` bypasses privacy processing, not live limits.
167
+ Staging business values are unchanged. Both modes project recognized runtime
168
+ infrastructure (connections, pools, framework/configuration machinery) into compact
169
+ descriptions; business documents, queries, results and application payloads remain
170
+ evidence. Unknown objects are treated as business data.
171
+
172
+ HTTP 429 honors `Retry-After` without dropping critical uploads. Quota refusal (402)
173
+ and deleted/failed capture refusal (410) stop retrying that refused batch; business
174
+ requests continue. Quota-enabled installations must use the configured collector,
175
+ not the legacy direct API evidence endpoints.
176
+
134
177
  ## Manual Wiring
135
178
 
136
179
  Use the lower-level exports when auto-attach is not appropriate or when you need
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ import type { Schema } from 'mongoose';
28
28
  import { type IngestClientConfig } from './ingest/client';
29
29
  import { type NormalizedRuntimePrivacyPolicy, type RuntimePrivacyConfig, type RuntimePrivacyDbContext, type RuntimePrivacySurface } from './privacy';
30
30
  import { projectSemanticInfrastructureValue } from './semantic-serialization';
31
+ import { type CaptureOmission } from './live-capture-limits';
31
32
  type SpanContext = {
32
33
  traceId: string | null;
33
34
  spanId: string | number | null;
@@ -88,6 +89,8 @@ type PendingTraceEventRecord = TraceEventRecord & {
88
89
  __reproTraceValueEntries?: TraceValueBatchEntry[];
89
90
  };
90
91
  type TraceValueCaptureRef = {
92
+ complete?: boolean;
93
+ omissions?: CaptureOmission[];
91
94
  status: 'stored' | 'truncated-for-storage' | 'skipped-non-meaningful' | 'failed-policy';
92
95
  valueId?: string;
93
96
  projectedKind?: string;
@@ -107,6 +110,7 @@ type TraceValueBatchEntry = {
107
110
  eventIndex?: number;
108
111
  };
109
112
  type TraceMaterializationConfig = {
113
+ captureMode?: 'live' | 'staging';
110
114
  tenantId: string;
111
115
  appId: string;
112
116
  appSecret?: string;
@@ -284,6 +288,7 @@ type NativeMongoSuppression = {
284
288
  ownerStore?: Ctx;
285
289
  };
286
290
  type RequestCaptureScopeState = {
291
+ liveCapture?: boolean;
287
292
  requestRid: string;
288
293
  transport: 'http' | 'kafka' | 'amqp' | 'background';
289
294
  backendRequestCount: number;
@@ -342,6 +347,8 @@ declare function getEventTraceValueEntries(event: TraceEventRecord | PendingTrac
342
347
  declare function estimateTraceBatchSerializedBytes(batch: TraceEventRecord[], requestRid: string, actionId: string | null | undefined, batchIndex: number, previouslyEmittedValueIds?: ReadonlySet<string>): number;
343
348
  declare function chunkTraceEventsForTransport(events: TraceEventRecord[], requestRid: string, actionId: string | null | undefined): TraceEventRecord[][];
344
349
  export type ReproMiddlewareConfig = IngestClientConfig & {
350
+ /** Propagated recording mode. A tagged staging request never inherits live limits. */
351
+ captureMode?: 'live' | 'staging';
345
352
  /** API base used for backend-owned live session documents. Defaults to REPRO_API_BASE_URL/REPRO_API_BASE/privacy.apiBase. */
346
353
  apiBase?: string;
347
354
  /** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
@@ -370,6 +377,15 @@ export type ReproLiveActorDecision = ReproLiveActorArgs & {
370
377
  actorLabels?: Record<string, string>;
371
378
  };
372
379
  export type ReproLiveModeConfig = {
380
+ /** Stable chain sampling. Runtime updates apply to new chains; targets are evaluated on each claim. */
381
+ liveCapture?: {
382
+ sampleRate?: number;
383
+ targets?: {
384
+ actorIds?: string[];
385
+ labels?: Record<string, string>;
386
+ expiresAt: number;
387
+ }[];
388
+ };
373
389
  enabled?: boolean;
374
390
  backendOnly?: boolean;
375
391
  /**
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ const url_1 = require("url");
10
10
  const client_1 = require("./ingest/client");
11
11
  const privacy_1 = require("./privacy");
12
12
  const semantic_serialization_1 = require("./semantic-serialization");
13
+ const live_capture_limits_1 = require("./live-capture-limits");
13
14
  /**
14
15
  * ============================= IMPORTANT =============================
15
16
  * We snapshot Mongoose core methods BEFORE tracer init, then re-apply them
@@ -828,6 +829,7 @@ function createRequestCaptureScope(requestRid, transport, cfg) {
828
829
  return {
829
830
  requestRid,
830
831
  transport,
832
+ liveCapture: cfg?.captureMode === 'live' || (!!cfg && cfg.captureMode !== 'staging' && !!normalizeLiveModeConfig(cfg)),
831
833
  // Every scope represents one captured transport request. This is an
832
834
  // observed expectation, not a count of successful enqueue operations.
833
835
  backendRequestCount: 1,
@@ -1119,22 +1121,20 @@ function hasActiveClientRequests() {
1119
1121
  function noteClientActivity(now = Date.now()) {
1120
1122
  lastClientActivityAt = now;
1121
1123
  }
1122
- function getSdkBackgroundDelayMs(now = Date.now()) {
1124
+ function getSdkBackgroundDelayMs(now = Date.now(), ingestEnqueuedAt) {
1125
+ // Bound deferral by work age, not just by a continuously active request.
1126
+ // Short non-overlapping requests reset oldestActiveClientRequestAt forever.
1127
+ const oldestWorkAt = Math.min(sdkBackgroundHighQueue[0]?.enqueuedAt ?? Infinity, sdkBackgroundQueue[0]?.enqueuedAt ?? Infinity, ingestEnqueuedAt ?? Infinity, oldestActiveClientRequestAt ?? Infinity);
1128
+ const remainingDeferMs = Number.isFinite(oldestWorkAt) && Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS)
1129
+ ? Math.max(0, oldestWorkAt + SDK_BACKGROUND_MAX_DEFER_MS - now)
1130
+ : Infinity;
1123
1131
  if (hasActiveClientRequests()) {
1124
- if (oldestActiveClientRequestAt !== null &&
1125
- Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS) &&
1126
- SDK_BACKGROUND_MAX_DEFER_MS >= 0) {
1127
- const activeForMs = now - oldestActiveClientRequestAt;
1128
- if (activeForMs >= SDK_BACKGROUND_MAX_DEFER_MS)
1129
- return 0;
1130
- return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, Math.max(0, SDK_BACKGROUND_MAX_DEFER_MS - activeForMs));
1131
- }
1132
- return SDK_BACKGROUND_IDLE_DELAY_MS;
1132
+ return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, remainingDeferMs);
1133
1133
  }
1134
1134
  const sinceLastActivity = now - lastClientActivityAt;
1135
1135
  const idleDelayRemaining = Math.max(0, SDK_BACKGROUND_IDLE_DELAY_MS - sinceLastActivity);
1136
1136
  const quietWindowRemaining = Math.max(0, sdkBackgroundQuietUntil - now);
1137
- return Math.max(idleDelayRemaining, quietWindowRemaining);
1137
+ return Math.min(Math.max(idleDelayRemaining, quietWindowRemaining), remainingDeferMs);
1138
1138
  }
1139
1139
  function shouldDeferSdkBackgroundWork(now = Date.now()) {
1140
1140
  return getSdkBackgroundDelayMs(now) > 0;
@@ -1370,13 +1370,8 @@ function beginClientRequest() {
1370
1370
  oldestActiveClientRequestAt = now;
1371
1371
  }
1372
1372
  activeClientRequestCount += 1;
1373
- if (sdkBackgroundTimer) {
1374
- try {
1375
- clearTimeout(sdkBackgroundTimer);
1376
- }
1377
- catch { }
1378
- sdkBackgroundTimer = null;
1379
- }
1373
+ // The scheduled drain rechecks activity; cancelling it here loses the
1374
+ // queued work's deadline under sustained traffic.
1380
1375
  }
1381
1376
  function endClientRequest() {
1382
1377
  activeClientRequestCount = Math.max(0, activeClientRequestCount - 1);
@@ -1395,15 +1390,18 @@ function endClientRequest() {
1395
1390
  }
1396
1391
  }
1397
1392
  function scheduleSdkBackgroundWork(work, options = {}) {
1393
+ const item = { work, enqueuedAt: Date.now() };
1398
1394
  if (options.priority === 'high') {
1399
- sdkBackgroundHighQueue.push(work);
1395
+ sdkBackgroundHighQueue.push(item);
1400
1396
  }
1401
1397
  else {
1402
- sdkBackgroundQueue.push(work);
1398
+ sdkBackgroundQueue.push(item);
1403
1399
  }
1404
1400
  scheduleSdkBackgroundDrain();
1405
1401
  }
1406
1402
  function scheduleSdkBackgroundDrain(delayMs = 0) {
1403
+ if (!sdkBackgroundHighQueue.length && !sdkBackgroundQueue.length)
1404
+ return;
1407
1405
  if (sdkBackgroundTimer)
1408
1406
  return;
1409
1407
  const nextDelay = Math.max(delayMs, getSdkBackgroundDelayMs());
@@ -1439,12 +1437,12 @@ async function drainSdkBackgroundQueue() {
1439
1437
  scheduleSdkBackgroundDrain();
1440
1438
  return;
1441
1439
  }
1442
- const work = sdkBackgroundHighQueue.shift() ?? sdkBackgroundQueue.shift();
1443
- if (!work)
1440
+ const item = sdkBackgroundHighQueue.shift() ?? sdkBackgroundQueue.shift();
1441
+ if (!item)
1444
1442
  continue;
1445
1443
  try {
1446
1444
  await drainQueuedIngestBeforeBackgroundWork();
1447
- await runSdkBackgroundWork(work);
1445
+ await runSdkBackgroundWork(item.work);
1448
1446
  }
1449
1447
  catch {
1450
1448
  // never break user code
@@ -1462,8 +1460,8 @@ async function drainSdkBackgroundQueue() {
1462
1460
  }
1463
1461
  }
1464
1462
  (0, client_1.configureIngestQueueDrain)({
1465
- shouldDefer: shouldDeferSdkBackgroundWork,
1466
- deferMs: SDK_BACKGROUND_IDLE_DELAY_MS,
1463
+ shouldDefer: (enqueuedAt) => getSdkBackgroundDelayMs(Date.now(), enqueuedAt) > 0,
1464
+ deferMs: Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, SDK_BACKGROUND_MAX_DEFER_MS),
1467
1465
  });
1468
1466
  // Track in-flight requests per session so we can delay flushing until a session is drained.
1469
1467
  const sessionInFlight = new Map();
@@ -2054,6 +2052,7 @@ async function resolveBackendLiveContext(cfg, req, requestStart) {
2054
2052
  }
2055
2053
  const { actorId, actorType, actorLabels } = identity;
2056
2054
  const claim = await postLiveApiJson(cfg, liveMode, '/v1/live-sessions/claim', {
2055
+ liveCapture: liveMode.liveCapture,
2057
2056
  clientTime: requestStart,
2058
2057
  captureMode: 'backend-only',
2059
2058
  actorId,
@@ -2156,6 +2155,7 @@ function stripReproQueryFromUrl(rawUrl) {
2156
2155
  parsed.searchParams.delete(REPRO_QUERY_SESSION_PARAM);
2157
2156
  parsed.searchParams.delete(REPRO_QUERY_ACTION_PARAM);
2158
2157
  parsed.searchParams.delete(REPRO_QUERY_START_PARAM);
2158
+ parsed.searchParams.delete('__repro_mode');
2159
2159
  return `${parsed.pathname}${parsed.search}${parsed.hash}`;
2160
2160
  }
2161
2161
  catch {
@@ -2169,6 +2169,7 @@ function stripReproQueryParams(req) {
2169
2169
  delete query[REPRO_QUERY_SESSION_PARAM];
2170
2170
  delete query[REPRO_QUERY_ACTION_PARAM];
2171
2171
  delete query[REPRO_QUERY_START_PARAM];
2172
+ delete query.__repro_mode;
2172
2173
  }
2173
2174
  }
2174
2175
  catch { }
@@ -2834,6 +2835,11 @@ function internTraceObservationSnapshot(snapshot, captureScope) {
2834
2835
  }
2835
2836
  function snapshotTraceValueAtObservation(value, captureScope, valuePath = '$', target = 'trace.value') {
2836
2837
  try {
2838
+ if (captureScope?.liveCapture) {
2839
+ value = target === 'trace.args' && Array.isArray(value)
2840
+ ? (0, live_capture_limits_1.limitLiveArguments)(value)
2841
+ : (0, live_capture_limits_1.limitLiveCapture)(value, valuePath).value;
2842
+ }
2837
2843
  const snapshot = sanitizeTraceValue(value, 0, new WeakMap(), { preserveLongStrings: true, disableTruncation: true }, valuePath);
2838
2844
  return internTraceObservationSnapshot(snapshot, captureScope);
2839
2845
  }
@@ -3018,6 +3024,7 @@ async function materializePendingTraceEventsAsync(events, params) {
3018
3024
  appSecret: params.cfg.appSecret,
3019
3025
  appName: params.cfg.appName,
3020
3026
  privacy: params.cfg.privacy,
3027
+ captureMode: params.captureScope?.liveCapture ? 'live' : params.cfg.captureMode,
3021
3028
  },
3022
3029
  maskReq: params.maskReq,
3023
3030
  masking: params.masking,
@@ -3439,7 +3446,20 @@ async function initializeRuntimePrivacyPolicyAtStartup(cfg) {
3439
3446
  }
3440
3447
  throw new Error(`Runtime privacy policy failed to load during startup from ${apiBase} for env ${state.environment} (${lastFailure}).`);
3441
3448
  }
3449
+ function isLiveCapture(cfg, scope) {
3450
+ if (scope)
3451
+ return scope.liveCapture === true;
3452
+ return cfg.captureMode === 'live' || (cfg.captureMode !== 'staging' && getCtx().captureScope?.liveCapture === true);
3453
+ }
3454
+ function constrainLiveCapture(value, cfg, target, scope) {
3455
+ if (!isLiveCapture(cfg, scope))
3456
+ return value;
3457
+ return target === 'trace.args' && Array.isArray(value)
3458
+ ? (0, live_capture_limits_1.limitLiveArguments)(value)
3459
+ : (0, live_capture_limits_1.limitLiveCapture)(value).value;
3460
+ }
3442
3461
  function applyPrivacyThenMask(target, value, cfg, req, trace, masking, privacy, db) {
3462
+ value = constrainLiveCapture(value, cfg, target);
3443
3463
  if (shouldCapturePrivacyRaw(cfg)) {
3444
3464
  return value;
3445
3465
  }
@@ -3460,6 +3480,7 @@ function applyPrivacyThenMask(target, value, cfg, req, trace, masking, privacy,
3460
3480
  return applyMasking(target, transformed, req, trace, masking);
3461
3481
  }
3462
3482
  async function applyPrivacyThenMaskAsync(target, value, cfg, req, trace, masking, privacy, db) {
3483
+ value = constrainLiveCapture(value, cfg, target);
3463
3484
  if (shouldCapturePrivacyRaw(cfg)) {
3464
3485
  return value;
3465
3486
  }
@@ -3506,6 +3527,7 @@ function limitRawInlinePrivacyValue(target, value) {
3506
3527
  return limited.skipped ? limited : null;
3507
3528
  }
3508
3529
  function materializeInlinePrivacyValue(target, value, cfg, req, trace, masking, privacy, db, captureScope) {
3530
+ value = constrainLiveCapture(value, cfg, target, captureScope);
3509
3531
  if (value === undefined) {
3510
3532
  return { value };
3511
3533
  }
@@ -3529,6 +3551,7 @@ function materializeInlinePrivacyValue(target, value, cfg, req, trace, masking,
3529
3551
  }
3530
3552
  }
3531
3553
  async function materializeInlinePrivacyValueAsync(target, value, cfg, req, trace, masking, privacy, db, captureScope) {
3554
+ value = constrainLiveCapture(value, cfg, target, captureScope);
3532
3555
  if (value === undefined) {
3533
3556
  return { value };
3534
3557
  }
@@ -4505,6 +4528,8 @@ function boundTraceEventsForTransport(events) {
4505
4528
  function createCapturedValueEntry(params) {
4506
4529
  if (!__FULL_VALUE_CAPTURE_ENABLED)
4507
4530
  return undefined;
4531
+ const limitedRaw = constrainLiveCapture(params.rawValue, params.capture.runtimeConfig, params.target);
4532
+ const omissions = isLiveCapture(params.capture.runtimeConfig) ? (0, live_capture_limits_1.collectCaptureOmissions)(limitedRaw) : [];
4508
4533
  const preferredLimits = {
4509
4534
  maxDepth: TRACE_FULL_VALUE_MAX_DEPTH,
4510
4535
  maxKeys: TRACE_FULL_VALUE_MAX_KEYS,
@@ -4514,7 +4539,7 @@ function createCapturedValueEntry(params) {
4514
4539
  const buildMaskedCandidate = (limits) => {
4515
4540
  const projected = params.target === 'request.headers'
4516
4541
  ? sanitizeHeaders(params.rawValue, params.capture.captureHeaders)
4517
- : cloneFullValueCandidate(params.rawValue, params.capture, limits);
4542
+ : cloneFullValueCandidate(limitedRaw, params.capture, limits);
4518
4543
  if (projected === undefined)
4519
4544
  return undefined;
4520
4545
  return applyPrivacyThenMask(params.target, projected, params.capture.runtimeConfig, params.capture.maskReq, params.capture.trace, params.capture.masking, params.capture.privacy, params.capture.db);
@@ -4525,7 +4550,7 @@ function createCapturedValueEntry(params) {
4525
4550
  return { captureRef: { status: 'skipped-non-meaningful' } };
4526
4551
  }
4527
4552
  const candidateSerialized = stableSerializeTraceValue(candidate);
4528
- if (!candidateSerialized || candidateSerialized === previewSerialized) {
4553
+ if (!candidateSerialized || (candidateSerialized === previewSerialized && !omissions.length)) {
4529
4554
  return undefined;
4530
4555
  }
4531
4556
  const valueId = contentAddressedValueId(params.target, candidateSerialized, params.capture);
@@ -4545,6 +4570,7 @@ function createCapturedValueEntry(params) {
4545
4570
  status: 'stored',
4546
4571
  valueId,
4547
4572
  projectedKind: entry.projectedKind,
4573
+ ...(omissions.length ? { complete: false, omissions } : {}),
4548
4574
  },
4549
4575
  entry,
4550
4576
  };
@@ -4552,6 +4578,8 @@ function createCapturedValueEntry(params) {
4552
4578
  async function createCapturedValueEntryAsync(params) {
4553
4579
  if (!__FULL_VALUE_CAPTURE_ENABLED)
4554
4580
  return undefined;
4581
+ const limitedRaw = constrainLiveCapture(params.rawValue, params.capture.runtimeConfig, params.target);
4582
+ const omissions = isLiveCapture(params.capture.runtimeConfig) ? (0, live_capture_limits_1.collectCaptureOmissions)(limitedRaw) : [];
4555
4583
  const preferredLimits = {
4556
4584
  maxDepth: TRACE_FULL_VALUE_MAX_DEPTH,
4557
4585
  maxKeys: TRACE_FULL_VALUE_MAX_KEYS,
@@ -4561,7 +4589,7 @@ async function createCapturedValueEntryAsync(params) {
4561
4589
  const buildMaskedCandidate = async (limits) => {
4562
4590
  const projected = params.target === 'request.headers'
4563
4591
  ? sanitizeHeaders(params.rawValue, params.capture.captureHeaders)
4564
- : await cloneFullValueCandidateAsync(params.rawValue, params.capture, limits);
4592
+ : await cloneFullValueCandidateAsync(limitedRaw, params.capture, limits);
4565
4593
  if (projected === undefined)
4566
4594
  return undefined;
4567
4595
  return applyPrivacyThenMaskAsync(params.target, projected, params.capture.runtimeConfig, params.capture.maskReq, params.capture.trace, params.capture.masking, params.capture.privacy, params.capture.db);
@@ -4572,7 +4600,7 @@ async function createCapturedValueEntryAsync(params) {
4572
4600
  return { captureRef: { status: 'skipped-non-meaningful' } };
4573
4601
  }
4574
4602
  const candidateSerialized = stableSerializeTraceValue(candidate);
4575
- if (!candidateSerialized || candidateSerialized === previewSerialized) {
4603
+ if (!candidateSerialized || (candidateSerialized === previewSerialized && !omissions.length)) {
4576
4604
  return undefined;
4577
4605
  }
4578
4606
  const valueId = contentAddressedValueId(params.target, candidateSerialized, params.capture);
@@ -4592,6 +4620,7 @@ async function createCapturedValueEntryAsync(params) {
4592
4620
  status: 'stored',
4593
4621
  valueId,
4594
4622
  projectedKind: entry.projectedKind,
4623
+ ...(omissions.length ? { complete: false, omissions } : {}),
4595
4624
  },
4596
4625
  entry,
4597
4626
  };
@@ -4794,6 +4823,7 @@ function reproMiddleware(cfg) {
4794
4823
  const querySid = readRequestQueryParam(req, REPRO_QUERY_SESSION_PARAM);
4795
4824
  const queryAid = readRequestQueryParam(req, REPRO_QUERY_ACTION_PARAM);
4796
4825
  const queryTs = readRequestQueryNumber(req, REPRO_QUERY_START_PARAM);
4826
+ const queryCaptureMode = readRequestQueryParam(req, '__repro_mode');
4797
4827
  if (querySid || queryAid || queryTs !== null) {
4798
4828
  stripReproQueryParams(req);
4799
4829
  }
@@ -4863,6 +4893,11 @@ function reproMiddleware(cfg) {
4863
4893
  }],
4864
4894
  }, { priority: 'high' });
4865
4895
  }
4896
+ const captureMode = backendLiveContext || req.headers['x-repro-capture-mode'] === 'live' || queryCaptureMode === 'live'
4897
+ ? 'live' : 'staging';
4898
+ const modeConfig = { ...captureCfg, captureMode };
4899
+ shareRuntimePrivacyState(captureCfg, modeConfig);
4900
+ captureCfg = modeConfig;
4866
4901
  const resolvePrivacy = () => getRuntimePrivacyState(captureCfg).policy ?? null;
4867
4902
  const clockSkewMs = headerTs !== null ? headerTs - requestStartRaw : 0;
4868
4903
  const rid = nextRequestId(requestEpochMs);
@@ -6287,6 +6322,8 @@ function patchNodeFetchForPropagation() {
6287
6322
  if (runtime.aid && !hasHttpHeader(headers, KAFKA_HEADER_ACTION_ID)) {
6288
6323
  setHttpHeader(headers, KAFKA_HEADER_ACTION_ID, runtime.aid);
6289
6324
  }
6325
+ if (runtime.sid)
6326
+ setHttpHeader(headers, 'x-repro-capture-mode', getCtx().captureScope?.liveCapture ? 'live' : 'staging');
6290
6327
  if (runtime.traceId && !hasHttpHeader(headers, KAFKA_HEADER_TRACE_ID)) {
6291
6328
  setHttpHeader(headers, KAFKA_HEADER_TRACE_ID, runtime.traceId);
6292
6329
  }
@@ -6337,6 +6374,8 @@ function patchNodeHttpModuleRequest(mod) {
6337
6374
  maybeSet(REQUEST_START_HEADER, String(alignedNow()));
6338
6375
  maybeSet(KAFKA_HEADER_SESSION_ID, runtime.sid);
6339
6376
  maybeSet(KAFKA_HEADER_ACTION_ID, runtime.aid);
6377
+ if (runtime.sid)
6378
+ maybeSet('x-repro-capture-mode', getCtx().captureScope?.liveCapture ? 'live' : 'staging');
6340
6379
  maybeSet(KAFKA_HEADER_TRACE_ID, runtime.traceId);
6341
6380
  maybeSet(KAFKA_HEADER_PARENT_SPAN_ID, runtime.parentSpanId);
6342
6381
  maybeSet(KAFKA_HEADER_REQUEST_ID, runtime.requestRid);
@@ -6513,6 +6552,11 @@ function cloneKafkaTelemetryValue(value) {
6513
6552
  }
6514
6553
  }
6515
6554
  async function materializeKafkaRequestBody(cfg, maskReq, body, captureScope) {
6555
+ // Broker bodies have no separate full-value reference. Returning the trace
6556
+ // inline preview here would permanently replace message arrays with 20 items.
6557
+ if (shouldCapturePrivacyRaw(cfg)) {
6558
+ return { value: constrainLiveCapture(sanitizeInlinePrivacyValue(body), cfg, 'request.body', captureScope) };
6559
+ }
6516
6560
  return materializeInlinePrivacyValueAsync('request.body', sanitizeInlinePrivacyValue(body), cfg, maskReq, null, normalizeMaskingConfig(cfg.masking), getRuntimePrivacyState(cfg).policy ?? null, undefined, captureScope);
6517
6561
  }
6518
6562
  function summarizeKafkaError(error) {
@@ -6557,7 +6601,8 @@ function resolveKafkaContext(cfg, headers) {
6557
6601
  readKafkaHeader(headers, KAFKA_HEADER_PARENT_SPAN_ID) ??
6558
6602
  null;
6559
6603
  const requestRid = readKafkaHeader(headers, KAFKA_HEADER_REQUEST_ID);
6560
- return { sid, aid, traceId, parentSpanId, requestRid };
6604
+ return { sid, aid, traceId, parentSpanId, requestRid,
6605
+ captureMode: readKafkaHeader(headers, 'x-repro-capture-mode') === 'live' || ctx.captureScope?.liveCapture ? 'live' : 'staging' };
6561
6606
  }
6562
6607
  function withOptionalTraceContext(traceId, fn) {
6563
6608
  const tracerWithTrace = __TRACER__?.withTrace;
@@ -6857,6 +6902,7 @@ function patchKafkaProducerInstance(producer, cfg) {
6857
6902
  [KAFKA_HEADER_REQUEST_ID]: publishRid,
6858
6903
  [KAFKA_HEADER_SESSION_ID]: sid,
6859
6904
  [KAFKA_HEADER_ACTION_ID]: aid,
6905
+ 'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
6860
6906
  }),
6861
6907
  };
6862
6908
  });
@@ -6979,6 +7025,7 @@ function patchKafkaProducerInstance(producer, cfg) {
6979
7025
  [KAFKA_HEADER_REQUEST_ID]: publishRid,
6980
7026
  [KAFKA_HEADER_SESSION_ID]: sid,
6981
7027
  [KAFKA_HEADER_ACTION_ID]: aid,
7028
+ 'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
6982
7029
  }),
6983
7030
  };
6984
7031
  }),
@@ -7091,6 +7138,7 @@ function wrapKafkaEachMessageHandler(eachMessage, cfg, consumer) {
7091
7138
  const consumeRid = nextRequestId(`${topic}-${message?.offset ?? alignedNow()}`);
7092
7139
  const traceId = consumeRid;
7093
7140
  const captureScope = createRequestCaptureScope(consumeRid, 'kafka', cfg);
7141
+ captureScope.liveCapture = runtime.captureMode === 'live';
7094
7142
  emitRequestCaptureStart(cfg, sid, aid, captureScope);
7095
7143
  const rawTraceEvents = [];
7096
7144
  const maskReq = {
@@ -7205,6 +7253,7 @@ function wrapKafkaEachBatchHandler(eachBatch, cfg, consumer) {
7205
7253
  const consumeRid = nextRequestId(`${topic}-batch-${alignedNow()}`);
7206
7254
  const traceId = consumeRid;
7207
7255
  const captureScope = createRequestCaptureScope(consumeRid, 'kafka', cfg);
7256
+ captureScope.liveCapture = runtime.captureMode === 'live';
7208
7257
  emitRequestCaptureStart(cfg, sid, aid, captureScope);
7209
7258
  const rawTraceEvents = [];
7210
7259
  const maskReq = {
@@ -7642,7 +7691,8 @@ function resolveAmqpContext(cfg, headers) {
7642
7691
  readAmqpHeader(headers, KAFKA_HEADER_PARENT_SPAN_ID) ??
7643
7692
  null;
7644
7693
  const requestRid = readAmqpHeader(headers, KAFKA_HEADER_REQUEST_ID);
7645
- return { sid, aid, traceId, parentSpanId, requestRid };
7694
+ return { sid, aid, traceId, parentSpanId, requestRid,
7695
+ captureMode: readAmqpHeader(headers, 'x-repro-capture-mode') === 'live' || ctx.captureScope?.liveCapture ? 'live' : 'staging' };
7646
7696
  }
7647
7697
  function withOptionalTraceContextSync(traceId, fn) {
7648
7698
  const tracerWithTrace = __TRACER__?.withTrace;
@@ -7817,6 +7867,7 @@ function patchAmqplibPublishMethod(channel, cfg, methodName) {
7817
7867
  [KAFKA_HEADER_REQUEST_ID]: publishRid,
7818
7868
  [KAFKA_HEADER_SESSION_ID]: sid,
7819
7869
  [KAFKA_HEADER_ACTION_ID]: aid,
7870
+ 'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
7820
7871
  };
7821
7872
  const shouldPropagate = hasAmqpPropagationValues(propagationValues);
7822
7873
  const callbackIndex = args.length > 0 && typeof args[args.length - 1] === 'function'
@@ -7922,6 +7973,7 @@ function wrapAmqplibConsumeHandler(onMessage, cfg, queue) {
7922
7973
  const consumeRid = nextRequestId(`${queueName}-${deliveryTag}`);
7923
7974
  const traceId = consumeRid;
7924
7975
  const captureScope = createRequestCaptureScope(consumeRid, 'amqp', cfg);
7976
+ captureScope.liveCapture = runtime.captureMode === 'live';
7925
7977
  emitRequestCaptureStart(cfg, sid, aid, captureScope);
7926
7978
  const rawTraceEvents = [];
7927
7979
  const maskReq = {
@@ -3,8 +3,8 @@ export type EnqueueIngestOptions = {
3
3
  priority?: 'normal' | 'high' | 'critical';
4
4
  };
5
5
  export declare const configureIngestQueueDrain: (options: {
6
- shouldDefer?: (() => boolean) | null;
7
- deferMs?: number;
6
+ shouldDefer?: ((enqueuedAt?: number) => boolean) | null | undefined;
7
+ deferMs?: number | undefined;
8
8
  }) => void;
9
9
  export declare const kickIngestQueueDrain: () => void;
10
10
  export declare const postIngestEntries: (config: IngestClientConfig, sessionId: string, entries: LegacyEntry[]) => Promise<void>;
@@ -47,6 +47,8 @@ let backoffMs = 0;
47
47
  let criticalBackoffMs = 0;
48
48
  let shouldDeferDrain = null;
49
49
  let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
50
+ const throttledUntil = new Map();
51
+ const throttleDelay = (config) => Math.max(0, (throttledUntil.get(resolveIngestUrl(config)) ?? 0) - Date.now());
50
52
  const configureIngestQueueDrain = (options) => {
51
53
  shouldDeferDrain = options.shouldDefer ?? null;
52
54
  if (Number.isFinite(options.deferMs) && Number(options.deferMs) >= 0) {
@@ -59,7 +61,7 @@ const kickIngestQueueDrain = () => {
59
61
  scheduleCriticalDrain();
60
62
  }
61
63
  if (ingestQueue.length > 0 && !drainTimer) {
62
- scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
64
+ scheduleDrain(shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt) ? deferDrainMs : 0);
63
65
  }
64
66
  };
65
67
  exports.kickIngestQueueDrain = kickIngestQueueDrain;
@@ -166,6 +168,9 @@ const postIngestEntries = async (config, sessionId, entries) => {
166
168
  };
167
169
  exports.postIngestEntries = postIngestEntries;
168
170
  const sendIngestEntries = async (config, sessionId, entries) => {
171
+ if (throttleDelay(config) > 0)
172
+ return 'throttled';
173
+ throttledUntil.delete(resolveIngestUrl(config));
169
174
  if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
170
175
  return true;
171
176
  }
@@ -197,6 +202,18 @@ const sendIngestEntries = async (config, sessionId, entries) => {
197
202
  body: JSON.stringify({ events }),
198
203
  ...(controller ? { signal: controller.signal } : {}),
199
204
  });
205
+ if (response.status === 429) {
206
+ const retryAfter = response.headers?.get?.('retry-after');
207
+ const seconds = retryAfter ? Number(retryAfter) : NaN;
208
+ const date = retryAfter ? Date.parse(retryAfter) : NaN;
209
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Number.isFinite(date) ? date - Date.now() : 1000;
210
+ throttledUntil.set(resolveIngestUrl(config), Date.now() + Math.max(1000, Math.min(86400000, delay)));
211
+ return 'throttled';
212
+ }
213
+ if (response.status === 402 || response.status === 410) {
214
+ console.warn(`[repro] Upload refused for ${sessionId}: ${response.status === 402 ? 'capture quota exhausted' : 'capture deleted, expired or failed'}. This batch will not be retried.`);
215
+ return 'denied';
216
+ }
200
217
  return response.ok;
201
218
  }
202
219
  catch {
@@ -216,12 +233,12 @@ const scheduleDrain = (delayMs = 0) => {
216
233
  if (drainTimer) {
217
234
  return;
218
235
  }
219
- const nextDelay = shouldDeferDrain?.()
236
+ const nextDelay = shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)
220
237
  ? Math.max(delayMs, deferDrainMs)
221
238
  : delayMs;
222
239
  drainTimer = setTimeout(() => {
223
240
  drainTimer = null;
224
- if (shouldDeferDrain?.()) {
241
+ if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
225
242
  scheduleDrain(deferDrainMs);
226
243
  return;
227
244
  }
@@ -257,7 +274,11 @@ const drainCriticalIngestQueue = async () => {
257
274
  return;
258
275
  }
259
276
  const ok = await sendIngestEntries(selected.batch.config, selected.batch.sessionId, selected.batch.entries);
260
- if (ok) {
277
+ if (ok === 'throttled') {
278
+ scheduleCriticalDrain(throttleDelay(selected.batch.config));
279
+ return;
280
+ }
281
+ if (ok === true || ok === 'denied') {
261
282
  criticalIngestQueue.splice(0, selected.queueItems);
262
283
  criticalBackoffMs = 0;
263
284
  continue;
@@ -281,14 +302,14 @@ const drainIngestQueue = async () => {
281
302
  if (draining) {
282
303
  return;
283
304
  }
284
- if (shouldDeferDrain?.()) {
305
+ if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
285
306
  scheduleDrain(deferDrainMs);
286
307
  return;
287
308
  }
288
309
  draining = true;
289
310
  try {
290
311
  while (ingestQueue.length > 0) {
291
- if (shouldDeferDrain?.()) {
312
+ if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
292
313
  scheduleDrain(deferDrainMs);
293
314
  return;
294
315
  }
@@ -298,7 +319,11 @@ const drainIngestQueue = async () => {
298
319
  }
299
320
  const batch = selected.batch;
300
321
  const ok = await sendIngestEntries(batch.config, batch.sessionId, batch.entries);
301
- if (ok) {
322
+ if (ok === 'throttled') {
323
+ scheduleDrain(throttleDelay(batch.config));
324
+ return;
325
+ }
326
+ if (ok === true || ok === 'denied') {
302
327
  ingestQueue.splice(0, selected.queueItems);
303
328
  backoffMs = 0;
304
329
  continue;
@@ -342,6 +367,7 @@ const enqueueIngestEntries = (config, sessionId, entries, options = {}) => {
342
367
  sessionId,
343
368
  entries: entries.slice(),
344
369
  attempts: 0,
370
+ enqueuedAt: Date.now(),
345
371
  priority,
346
372
  };
347
373
  if (priority === 'critical') {
@@ -0,0 +1,19 @@
1
+ export type CaptureOmission = {
2
+ reason: 'max-depth' | 'max-keys' | 'max-entries' | 'accessor' | 'uninspectable';
3
+ path: string;
4
+ limit?: number;
5
+ originalCount?: number;
6
+ capturedCount: number;
7
+ };
8
+ export declare const LIVE_CAPTURE_LIMITS: Readonly<{
9
+ maxDepth: 10;
10
+ maxKeys: 100;
11
+ maxEntries: 100;
12
+ }>;
13
+ /** Snapshot only data descriptors: capture must never execute application getters. */
14
+ export declare function limitLiveCapture(value: unknown, rootPath?: string): {
15
+ value: any;
16
+ omissions: CaptureOmission[];
17
+ };
18
+ export declare function collectCaptureOmissions(value: any): CaptureOmission[];
19
+ export declare function limitLiveArguments(args: any[]): any[];