@reproapp/node-sdk 0.0.51 → 0.0.52

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
@@ -304,8 +304,8 @@ await initRepro({
304
304
  privacy: {
305
305
  environment: process.env.REPRO_PRIVACY_ENV ?? "dev",
306
306
  apiBase: process.env.REPRO_PRIVACY_API_BASE ?? process.env.REPRO_API_BASE,
307
- fetchTimeoutMs: 2500,
308
- startupMaxWaitMs: 10000,
307
+ fetchTimeoutMs: 10000,
308
+ startupMaxWaitMs: 15000,
309
309
  startupRetryDelayMs: 500,
310
310
  },
311
311
  });
@@ -313,6 +313,21 @@ await initRepro({
313
313
 
314
314
  Use `privacy.captureRaw: true` only in controlled local development.
315
315
 
316
+ The default policy fetch timeout is 10 seconds, including reading the response
317
+ body. The startup retry budget defaults to 15 seconds. Large approved policies
318
+ are loaded in full; a timeout does not permit uploading unredacted values.
319
+
320
+ Trace value materialization uses a restartable background worker in both live and
321
+ staging modes. Worker messages are processed in bounded batches without dropping
322
+ events. When worker execution is unavailable, asynchronous capture traversal
323
+ yields to the event loop. Observation snapshots still have a synchronous cost,
324
+ so measure overhead against your own payloads and traffic before production use.
325
+
326
+ Run `npm run test:latency` with Docker running to test real MongoDB queries and
327
+ HTTP traffic with recording disabled, raw capture, approved policies, worker
328
+ fallback, and a worker-side policy failure. The test checks unrelated HTTP
329
+ responsiveness, captured values and mutations, privacy, and request manifests.
330
+
316
331
  ## Verify
317
332
 
318
333
  With the React SDK, record a browser session in `live` mode or start a manual QA
@@ -0,0 +1 @@
1
+ export declare function yieldCaptureWork(): Promise<void> | null;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.yieldCaptureWork = void 0;
4
+ // Promise-only recursion never yields to HTTP sockets. Share a small time slice
5
+ // across asynchronous capture walkers, including the worker-unavailable fallback.
6
+ let deadline = 0;
7
+ let pendingYield = null;
8
+ function yieldCaptureWork() {
9
+ if (pendingYield)
10
+ return pendingYield;
11
+ const now = performance.now();
12
+ if (now < deadline)
13
+ return null;
14
+ if (deadline === 0) {
15
+ deadline = now + 4;
16
+ return null;
17
+ }
18
+ pendingYield = new Promise(resolve => setImmediate(() => {
19
+ deadline = performance.now() + 4;
20
+ pendingYield = null;
21
+ resolve();
22
+ }));
23
+ return pendingYield;
24
+ }
25
+ exports.yieldCaptureWork = yieldCaptureWork;
package/dist/index.js CHANGED
@@ -7,9 +7,11 @@ const crypto_1 = require("crypto");
7
7
  const module_1 = require("module");
8
8
  const path_1 = require("path");
9
9
  const url_1 = require("url");
10
+ const node_v8_1 = require("node:v8");
10
11
  const client_1 = require("./ingest/client");
11
12
  const privacy_1 = require("./privacy");
12
13
  const semantic_serialization_1 = require("./semantic-serialization");
14
+ const capture_work_budget_1 = require("./capture-work-budget");
13
15
  const live_capture_limits_1 = require("./live-capture-limits");
14
16
  /**
15
17
  * ============================= IMPORTANT =============================
@@ -1170,6 +1172,7 @@ function scheduleTraceMaterializerIdleShutdown() {
1170
1172
  const worker = traceMaterializerWorker;
1171
1173
  traceMaterializerWorker = null;
1172
1174
  if (worker) {
1175
+ traceMaterializerExpectedTerminations.add(worker);
1173
1176
  try {
1174
1177
  void worker.terminate?.();
1175
1178
  }
@@ -1195,7 +1198,17 @@ function handleTraceMaterializerMessage(message) {
1195
1198
  traceMaterializerPending.delete(message.taskId);
1196
1199
  clearTraceMaterializerTaskTimeout(message.taskId);
1197
1200
  if (message.ok) {
1198
- pending.resolve(Array.isArray(message.events) ? message.events : []);
1201
+ const events = Array.isArray(message.events) ? message.events : [];
1202
+ for (const event of events) {
1203
+ // Symbols do not cross worker messages. Restore internal attachments
1204
+ // before transport, otherwise each occurrence embeds its full value.
1205
+ const entries = event.__reproTraceValueEntries;
1206
+ if (entries)
1207
+ for (const entry of entries)
1208
+ attachTraceValueEntry(event, entry);
1209
+ delete event.__reproTraceValueEntries;
1210
+ }
1211
+ pending.resolve(events);
1199
1212
  scheduleTraceMaterializerIdleShutdown();
1200
1213
  return;
1201
1214
  }
@@ -1256,9 +1269,15 @@ function getTraceMaterializerWorker() {
1256
1269
  catch { }
1257
1270
  worker.on('message', handleTraceMaterializerMessage);
1258
1271
  worker.on('error', (err) => {
1272
+ if (worker !== traceMaterializerWorker || traceMaterializerExpectedTerminations.has(worker))
1273
+ return;
1259
1274
  disableTraceMaterializerWorker(err instanceof Error ? err : new Error(String(err)));
1260
1275
  });
1261
1276
  worker.on('exit', (code) => {
1277
+ // Idle/timeout shutdown can finish after a replacement worker starts.
1278
+ // An old worker must never clear or disable its replacement.
1279
+ if (worker !== traceMaterializerWorker)
1280
+ return;
1262
1281
  const hadPending = traceMaterializerPending.size > 0;
1263
1282
  const expectedTermination = traceMaterializerExpectedTerminations.has(worker);
1264
1283
  clearTraceMaterializerIdleTimer();
@@ -1340,18 +1359,12 @@ function shouldOffloadPendingTraceEvents(events) {
1340
1359
  if (!pending)
1341
1360
  continue;
1342
1361
  if (pending.argsRaw !== undefined) {
1343
- if (traceInlineValueExceedsLimit(pending.argsRaw))
1344
- return false;
1345
1362
  pendingValueCount += 1;
1346
1363
  }
1347
1364
  if (pending.returnValueRaw !== undefined) {
1348
- if (traceInlineValueExceedsLimit(pending.returnValueRaw))
1349
- return false;
1350
1365
  pendingValueCount += 1;
1351
1366
  }
1352
1367
  if (pending.errorRaw !== undefined) {
1353
- if (traceInlineValueExceedsLimit(pending.errorRaw))
1354
- return false;
1355
1368
  pendingValueCount += 1;
1356
1369
  }
1357
1370
  if (pendingValueCount >= TRACE_MATERIALIZE_OFFTHREAD_MIN_VALUES) {
@@ -2820,9 +2833,16 @@ function internTraceObservationSnapshot(snapshot, captureScope) {
2820
2833
  if (!captureScope || snapshot === null || typeof snapshot !== 'object') {
2821
2834
  return snapshot;
2822
2835
  }
2823
- const serialized = stableSerializeTraceValue(snapshot);
2824
- if (serialized === null)
2836
+ // These are already detached data snapshots. Binary serialization avoids
2837
+ // sorting and cloning the whole graph again on the application's call stack,
2838
+ // while preserving undefined, NaN, and other non-JSON primitive distinctions.
2839
+ let serialized;
2840
+ try {
2841
+ serialized = (0, node_v8_1.serialize)(snapshot);
2842
+ }
2843
+ catch {
2825
2844
  return snapshot;
2845
+ } // Interning is optional; the snapshot is not.
2826
2846
  const digest = (0, crypto_1.createHash)('sha256')
2827
2847
  .update('repro-trace-observation-v1\0')
2828
2848
  .update(serialized)
@@ -3002,6 +3022,16 @@ async function materializePendingTraceEventsAsync(events, params) {
3002
3022
  if (!events.some((evt) => evt.__reproPending)) {
3003
3023
  return events;
3004
3024
  }
3025
+ // Bound work per worker message and timeout, not the captured data itself.
3026
+ // A long request must not become one enormous structured-clone operation.
3027
+ if (events.length > 64) {
3028
+ const materialized = [];
3029
+ for (let offset = 0; offset < events.length; offset += 64) {
3030
+ materialized.push(...await materializePendingTraceEventsAsync(events.slice(offset, offset + 64), params));
3031
+ await new Promise(resolve => setImmediate(resolve));
3032
+ }
3033
+ return materialized;
3034
+ }
3005
3035
  const privacy = params.resolvePrivacy();
3006
3036
  if (!shouldOffloadPendingTraceEvents(events)) {
3007
3037
  try {
@@ -3016,7 +3046,7 @@ async function materializePendingTraceEventsAsync(events, params) {
3016
3046
  }
3017
3047
  }
3018
3048
  try {
3019
- return await materializePendingTraceEventsOffThread(events, {
3049
+ const materialized = await materializePendingTraceEventsOffThread(events, {
3020
3050
  events,
3021
3051
  cfg: {
3022
3052
  tenantId: params.cfg.tenantId,
@@ -3032,6 +3062,14 @@ async function materializePendingTraceEventsAsync(events, params) {
3032
3062
  fullValueCaptureEnabled: __FULL_VALUE_CAPTURE_ENABLED,
3033
3063
  valueNamespace: params.valueNamespace,
3034
3064
  });
3065
+ for (const event of materialized) {
3066
+ for (const [field, captureField] of [['args', 'argsValueCapture'], ['returnValue', 'returnValueCapture'], ['error', 'errorValueCapture']]) {
3067
+ if (event[captureField]?.status === 'failed-policy') {
3068
+ recordRequestCaptureFailure(params.captureScope ?? getCtx().captureScope, 'privacy.full_value', `trace.${field}`, 'worker_value_materialization_failed');
3069
+ }
3070
+ }
3071
+ }
3072
+ return materialized;
3035
3073
  }
3036
3074
  catch (err) {
3037
3075
  try {
@@ -3059,10 +3097,15 @@ async function __materializePendingTraceEventsForWorker(payload) {
3059
3097
  resolvePrivacy: () => payload.privacy,
3060
3098
  valueNamespace: payload.valueNamespace,
3061
3099
  });
3100
+ const values = new Map();
3062
3101
  return materialized.map((event) => {
3063
3102
  const entries = getEventTraceValueEntries(event);
3064
3103
  if (entries?.length) {
3065
- event.__reproTraceValueEntries = entries.map((entry) => ({ ...entry }));
3104
+ event.__reproTraceValueEntries = entries.map((entry) => {
3105
+ if (!values.has(entry.id))
3106
+ values.set(entry.id, entry.value);
3107
+ return { ...entry, value: values.get(entry.id) };
3108
+ });
3066
3109
  }
3067
3110
  return event;
3068
3111
  });
@@ -3188,7 +3231,7 @@ function sanitizeHeaders(headers, rawCfg) {
3188
3231
  }
3189
3232
  const RUNTIME_PRIVACY_STATE_SYMBOL = Symbol('repro.runtimePrivacyState');
3190
3233
  const RUNTIME_PRIVACY_RETRY_COOLDOWN_MS = 5000;
3191
- const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS = 6000;
3234
+ const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS = 15000;
3192
3235
  const DEFAULT_RUNTIME_PRIVACY_STARTUP_RETRY_DELAY_MS = 150;
3193
3236
  function normalizeMaskTargets(target) {
3194
3237
  const out = Array.isArray(target) ? target : [target];
@@ -3334,7 +3377,7 @@ function handleRuntimePrivacyFetchResult(cfg, state, apiBase, result) {
3334
3377
  state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
3335
3378
  return null;
3336
3379
  }
3337
- reportRuntimePrivacyOutcome(cfg, state, `network:${result.error}:${apiBase ?? 'no-api-base'}`, `privacy policy fetch failed while contacting ${apiBase}`);
3380
+ reportRuntimePrivacyOutcome(cfg, state, `network:${result.error}:${apiBase ?? 'no-api-base'}`, `privacy policy fetch failed while contacting ${apiBase}: ${result.error}`);
3338
3381
  state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
3339
3382
  return null;
3340
3383
  }
@@ -4196,6 +4239,9 @@ function cloneFullValueCandidate(value, ctx, limits, depth = 0, seen = new WeakM
4196
4239
  return out;
4197
4240
  }
4198
4241
  async function cloneFullValueCandidateAsync(value, ctx, limits, depth = 0, seen = new WeakMap(), valuePath = '$') {
4242
+ const pause = (0, capture_work_budget_1.yieldCaptureWork)();
4243
+ if (pause)
4244
+ await pause;
4199
4245
  if (value === undefined)
4200
4246
  return undefined;
4201
4247
  if (value === null || typeof value === 'number' || typeof value === 'boolean')
package/dist/privacy.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.applyRuntimePrivacyPolicyAsync = exports.applyRuntimePrivacyPolicy = exports.normalizeRuntimePrivacyPolicy = exports.fetchApprovedRuntimePrivacyPolicy = exports.resolvePrivacyApiBase = exports.normalizePrivacyEnvironment = exports.clearRuntimePrivacyProvenance = void 0;
4
4
  const crypto_1 = require("crypto");
5
+ const capture_work_budget_1 = require("./capture-work-budget");
5
6
  const privacy_fallback_1 = require("./privacy-fallback");
6
7
  const privacy_redaction_1 = require("./privacy-redaction");
7
8
  const EMPTY_RUNTIME_PRIVACY_POLICY = {
@@ -89,7 +90,7 @@ function resolvePrivacyApiBase(cfg) {
89
90
  return String(value).replace(/\/+$/, '');
90
91
  }
91
92
  exports.resolvePrivacyApiBase = resolvePrivacyApiBase;
92
- const DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS = 2500;
93
+ const DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS = 10000;
93
94
  function resolveRuntimePrivacyFetchTimeoutMs(value) {
94
95
  if (!Number.isFinite(value)) {
95
96
  return DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS;
@@ -671,6 +672,9 @@ function transformValue(policy, surface, value, context, path, fallbackInput = n
671
672
  return transformChildrenExact(policy, surface, currentValue, context, path, fallback);
672
673
  }
673
674
  async function transformValueAsync(policy, surface, value, context, path, fallbackInput = null) {
675
+ const pause = (0, capture_work_budget_1.yieldCaptureWork)();
676
+ if (pause)
677
+ await pause;
674
678
  const fallback = resolveTransformFallback(value, fallbackInput);
675
679
  const objectId = coercePrivacyObjectId(value);
676
680
  if (objectId !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reproapp/node-sdk",
3
- "version": "0.0.51",
3
+ "version": "0.0.52",
4
4
  "description": "Repro Nest SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,6 +9,7 @@
9
9
  "node": ">=18"
10
10
  },
11
11
  "scripts": {
12
+ "test:latency": "npm run build && node test/privacy-fetch-timeout.test.js && node test/capture-latency.testcontainers.test.js",
12
13
  "test:live-capture": "npm run build && node test/live-capture-limits.test.js && node test/ingest-throttle-retention.test.js && node test/live-capture-limits.testcontainers.test.js",
13
14
  "test:live-brokers": "npm run build && node test/live-broker-limits.testcontainers.test.js",
14
15
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,16 @@
1
+ // Promise-only recursion never yields to HTTP sockets. Share a small time slice
2
+ // across asynchronous capture walkers, including the worker-unavailable fallback.
3
+ let deadline = 0;
4
+ let pendingYield: Promise<void> | null = null;
5
+ export function yieldCaptureWork(): Promise<void> | null {
6
+ if (pendingYield) return pendingYield;
7
+ const now = performance.now();
8
+ if (now < deadline) return null;
9
+ if (deadline === 0) { deadline = now + 4; return null; }
10
+ pendingYield = new Promise<void>(resolve => setImmediate(() => {
11
+ deadline = performance.now() + 4;
12
+ pendingYield = null;
13
+ resolve();
14
+ }));
15
+ return pendingYield;
16
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { createHash, createHmac, randomUUID } from 'crypto';
6
6
  import { createRequire } from 'module';
7
7
  import { join } from 'path';
8
8
  import { pathToFileURL } from 'url';
9
+ import { serialize as serializeSnapshot } from 'node:v8';
9
10
  import {
10
11
  configureIngestQueueDrain,
11
12
  enqueueIngestEntries,
@@ -29,6 +30,7 @@ import {
29
30
  type RuntimePrivacyTraceContext,
30
31
  } from './privacy';
31
32
  import { projectSemanticInfrastructureValue } from './semantic-serialization';
33
+ import { yieldCaptureWork } from './capture-work-budget';
32
34
  import { limitLiveCapture, limitLiveArguments, collectCaptureOmissions, type CaptureOmission } from './live-capture-limits';
33
35
 
34
36
  /**
@@ -1625,6 +1627,7 @@ function scheduleTraceMaterializerIdleShutdown(): void {
1625
1627
  const worker = traceMaterializerWorker;
1626
1628
  traceMaterializerWorker = null;
1627
1629
  if (worker) {
1630
+ traceMaterializerExpectedTerminations.add(worker);
1628
1631
  try { void worker.terminate?.(); } catch {}
1629
1632
  }
1630
1633
  }, TRACE_MATERIALIZE_WORKER_IDLE_SHUTDOWN_MS);
@@ -1649,7 +1652,15 @@ function handleTraceMaterializerMessage(message: TraceMaterializationWorkerRespo
1649
1652
  traceMaterializerPending.delete(message.taskId);
1650
1653
  clearTraceMaterializerTaskTimeout(message.taskId);
1651
1654
  if (message.ok) {
1652
- pending.resolve(Array.isArray(message.events) ? message.events : []);
1655
+ const events = Array.isArray(message.events) ? message.events : [];
1656
+ for (const event of events) {
1657
+ // Symbols do not cross worker messages. Restore internal attachments
1658
+ // before transport, otherwise each occurrence embeds its full value.
1659
+ const entries = event.__reproTraceValueEntries;
1660
+ if (entries) for (const entry of entries) attachTraceValueEntry(event, entry);
1661
+ delete event.__reproTraceValueEntries;
1662
+ }
1663
+ pending.resolve(events);
1653
1664
  scheduleTraceMaterializerIdleShutdown();
1654
1665
  return;
1655
1666
  }
@@ -1700,9 +1711,13 @@ function getTraceMaterializerWorker(): any | null {
1700
1711
  try { worker.unref?.(); } catch {}
1701
1712
  worker.on('message', handleTraceMaterializerMessage);
1702
1713
  worker.on('error', (err: Error) => {
1714
+ if (worker !== traceMaterializerWorker || traceMaterializerExpectedTerminations.has(worker)) return;
1703
1715
  disableTraceMaterializerWorker(err instanceof Error ? err : new Error(String(err)));
1704
1716
  });
1705
1717
  worker.on('exit', (code: number) => {
1718
+ // Idle/timeout shutdown can finish after a replacement worker starts.
1719
+ // An old worker must never clear or disable its replacement.
1720
+ if (worker !== traceMaterializerWorker) return;
1706
1721
  const hadPending = traceMaterializerPending.size > 0;
1707
1722
  const expectedTermination = traceMaterializerExpectedTerminations.has(worker);
1708
1723
  clearTraceMaterializerIdleTimer();
@@ -1779,15 +1794,12 @@ function shouldOffloadPendingTraceEvents(events: PendingTraceEventRecord[]): boo
1779
1794
  const pending = event.__reproPending;
1780
1795
  if (!pending) continue;
1781
1796
  if (pending.argsRaw !== undefined) {
1782
- if (traceInlineValueExceedsLimit(pending.argsRaw)) return false;
1783
1797
  pendingValueCount += 1;
1784
1798
  }
1785
1799
  if (pending.returnValueRaw !== undefined) {
1786
- if (traceInlineValueExceedsLimit(pending.returnValueRaw)) return false;
1787
1800
  pendingValueCount += 1;
1788
1801
  }
1789
1802
  if (pending.errorRaw !== undefined) {
1790
- if (traceInlineValueExceedsLimit(pending.errorRaw)) return false;
1791
1803
  pendingValueCount += 1;
1792
1804
  }
1793
1805
  if (pendingValueCount >= TRACE_MATERIALIZE_OFFTHREAD_MIN_VALUES) {
@@ -3430,8 +3442,12 @@ function internTraceObservationSnapshot(
3430
3442
  return snapshot;
3431
3443
  }
3432
3444
 
3433
- const serialized = stableSerializeTraceValue(snapshot);
3434
- if (serialized === null) return snapshot;
3445
+ // These are already detached data snapshots. Binary serialization avoids
3446
+ // sorting and cloning the whole graph again on the application's call stack,
3447
+ // while preserving undefined, NaN, and other non-JSON primitive distinctions.
3448
+ let serialized: Buffer;
3449
+ try { serialized = serializeSnapshot(snapshot); }
3450
+ catch { return snapshot; } // Interning is optional; the snapshot is not.
3435
3451
 
3436
3452
  const digest = createHash('sha256')
3437
3453
  .update('repro-trace-observation-v1\0')
@@ -3705,6 +3721,17 @@ async function materializePendingTraceEventsAsync(
3705
3721
  return events;
3706
3722
  }
3707
3723
 
3724
+ // Bound work per worker message and timeout, not the captured data itself.
3725
+ // A long request must not become one enormous structured-clone operation.
3726
+ if (events.length > 64) {
3727
+ const materialized: TraceEventRecord[] = [];
3728
+ for (let offset = 0; offset < events.length; offset += 64) {
3729
+ materialized.push(...await materializePendingTraceEventsAsync(events.slice(offset, offset + 64), params));
3730
+ await new Promise<void>(resolve => setImmediate(resolve));
3731
+ }
3732
+ return materialized;
3733
+ }
3734
+
3708
3735
  const privacy = params.resolvePrivacy();
3709
3736
  if (!shouldOffloadPendingTraceEvents(events)) {
3710
3737
  try {
@@ -3723,7 +3750,7 @@ async function materializePendingTraceEventsAsync(
3723
3750
  }
3724
3751
  }
3725
3752
  try {
3726
- return await materializePendingTraceEventsOffThread(events, {
3753
+ const materialized = await materializePendingTraceEventsOffThread(events, {
3727
3754
  events,
3728
3755
  cfg: {
3729
3756
  tenantId: params.cfg.tenantId,
@@ -3739,6 +3766,15 @@ async function materializePendingTraceEventsAsync(
3739
3766
  fullValueCaptureEnabled: __FULL_VALUE_CAPTURE_ENABLED,
3740
3767
  valueNamespace: params.valueNamespace,
3741
3768
  });
3769
+ for (const event of materialized) {
3770
+ for (const [field, captureField] of [['args', 'argsValueCapture'], ['returnValue', 'returnValueCapture'], ['error', 'errorValueCapture']] as const) {
3771
+ if (event[captureField]?.status === 'failed-policy') {
3772
+ recordRequestCaptureFailure(params.captureScope ?? getCtx().captureScope,
3773
+ 'privacy.full_value', `trace.${field}`, 'worker_value_materialization_failed');
3774
+ }
3775
+ }
3776
+ }
3777
+ return materialized;
3742
3778
  } catch (err) {
3743
3779
  try {
3744
3780
  return await materializePendingTraceEvents(events, {
@@ -3775,10 +3811,14 @@ export async function __materializePendingTraceEventsForWorker(
3775
3811
  resolvePrivacy: () => payload.privacy,
3776
3812
  valueNamespace: payload.valueNamespace,
3777
3813
  });
3814
+ const values = new Map<string, any>();
3778
3815
  return materialized.map((event) => {
3779
3816
  const entries = getEventTraceValueEntries(event);
3780
3817
  if (entries?.length) {
3781
- (event as PendingTraceEventRecord).__reproTraceValueEntries = entries.map((entry) => ({ ...entry }));
3818
+ (event as PendingTraceEventRecord).__reproTraceValueEntries = entries.map((entry) => {
3819
+ if (!values.has(entry.id)) values.set(entry.id, entry.value);
3820
+ return { ...entry, value: values.get(entry.id) };
3821
+ });
3782
3822
  }
3783
3823
  return event as PendingTraceEventRecord;
3784
3824
  });
@@ -3925,7 +3965,7 @@ type RuntimePrivacyState = {
3925
3965
 
3926
3966
  const RUNTIME_PRIVACY_STATE_SYMBOL = Symbol('repro.runtimePrivacyState');
3927
3967
  const RUNTIME_PRIVACY_RETRY_COOLDOWN_MS = 5000;
3928
- const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS = 6000;
3968
+ const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS = 15000;
3929
3969
  const DEFAULT_RUNTIME_PRIVACY_STARTUP_RETRY_DELAY_MS = 150;
3930
3970
 
3931
3971
  function normalizeMaskTargets(target: ReproMaskTarget | ReproMaskTarget[]): ReproMaskTarget[] {
@@ -4104,7 +4144,7 @@ function handleRuntimePrivacyFetchResult(
4104
4144
  cfg,
4105
4145
  state,
4106
4146
  `network:${result.error}:${apiBase ?? 'no-api-base'}`,
4107
- `privacy policy fetch failed while contacting ${apiBase}`,
4147
+ `privacy policy fetch failed while contacting ${apiBase}: ${result.error}`,
4108
4148
  );
4109
4149
  state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
4110
4150
  return null;
@@ -5142,6 +5182,8 @@ async function cloneFullValueCandidateAsync(
5142
5182
  seen: WeakMap<object, string> = new WeakMap(),
5143
5183
  valuePath = '$',
5144
5184
  ): Promise<any> {
5185
+ const pause = yieldCaptureWork();
5186
+ if (pause) await pause;
5145
5187
  if (value === undefined) return undefined;
5146
5188
  if (value === null || typeof value === 'number' || typeof value === 'boolean') return value;
5147
5189
  if (typeof value === 'string') {
package/src/privacy.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'crypto';
2
+ import { yieldCaptureWork } from './capture-work-budget';
2
3
  import { sanitizeUnknownBoundaryTextWithDetectors } from './privacy-fallback';
3
4
  import { redactEmailDeterministic, redactEmailsInText } from './privacy-redaction';
4
5
 
@@ -236,7 +237,7 @@ export function resolvePrivacyApiBase(cfg?: RuntimePrivacyConfig | null): string
236
237
  return String(value).replace(/\/+$/, '');
237
238
  }
238
239
 
239
- const DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS = 2500;
240
+ const DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS = 10000;
240
241
 
241
242
  function resolveRuntimePrivacyFetchTimeoutMs(value?: number | null): number {
242
243
  if (!Number.isFinite(value)) {
@@ -971,6 +972,8 @@ async function transformValueAsync(
971
972
  path: string[],
972
973
  fallbackInput: RuntimePrivacyAction | TransformFallback = null,
973
974
  ): Promise<any> {
975
+ const pause = yieldCaptureWork();
976
+ if (pause) await pause;
974
977
  const fallback = resolveTransformFallback(value, fallbackInput);
975
978
  const objectId = coercePrivacyObjectId(value);
976
979
  if (objectId !== null) {
@@ -0,0 +1,123 @@
1
+ const assert = require('node:assert/strict');
2
+ const http = require('node:http');
3
+ const { fork } = require('node:child_process');
4
+ const { once } = require('node:events');
5
+ const { performance } = require('node:perf_hooks');
6
+ const { GenericContainer, Wait } = require('testcontainers');
7
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
8
+ const policy = { environment: 'dev', statements: [
9
+ ...Array.from({ length: 5000 }, (_, i) => ({ sid: `f${i}`, when: { scope: 'FUNCTION', selector: `Service${i}.method`, target: 'trace.args|trace.returnValue' }, apply: { action: 'KEEP_EXACT' } })),
10
+ { sid: 'secret', when: { scope: 'FIELD', selector: 'field:internalSecret' }, apply: { action: 'DROP' } },
11
+ ] };
12
+ async function main() {
13
+ const events = [];
14
+ const sink = http.createServer((req, res) => {
15
+ const chunks = [];
16
+ req.on('data', chunk => chunks.push(chunk));
17
+ req.on('end', () => {
18
+ const body = JSON.parse(Buffer.concat(chunks));
19
+ events.push(...(body.events || []));
20
+ res.writeHead(202); res.end('{}');
21
+ });
22
+ });
23
+ let mongo, child;
24
+ await new Promise(resolve => sink.listen(0, '127.0.0.1', resolve));
25
+ const results = [];
26
+ try {
27
+ mongo = await new GenericContainer('mongo:7').withExposedPorts(27017).withWaitStrategy(Wait.forListeningPorts()).start();
28
+ for (const variant of ['off', 'live-raw', 'staging-raw', 'live-policy', 'staging-policy', 'live-policy-fallback', 'staging-policy-fallback', 'staging-policy-fault']) {
29
+ const mode = variant.split('-')[0];
30
+ child = fork(require.resolve('./fixtures/capture-latency-server'), [], {
31
+ env: { ...process.env, CAPTURE_TEST_MODE: mode,
32
+ CAPTURE_TEST_MONGO: `mongodb://${mongo.getHost()}:${mongo.getMappedPort(27017)}/latency_${variant}`,
33
+ CAPTURE_TEST_SINK: `http://127.0.0.1:${sink.address().port}`,
34
+ CAPTURE_TEST_POLICY: variant.includes('-policy') ? JSON.stringify(policy) : '',
35
+ REPRO_TRACE_MATERIALIZE_OFFTHREAD: variant.endsWith('fallback') ? 'false' : 'true',
36
+ CAPTURE_TEST_WORKER_FAULT: variant.endsWith('fault') ? 'true' : '',
37
+ REPRO_SDK_BACKGROUND_IDLE_DELAY_MS: '0', REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS: '0',
38
+ REPRO_SDK_BACKGROUND_MAX_DEFER_MS: '0', TRACE_IDLE_FLUSH_MS: '10', TRACE_LINGER_AFTER_FINISH_MS: '0',
39
+ }, stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
40
+ });
41
+ let stderr = '';
42
+ child.stderr.on('data', chunk => { stderr += chunk; });
43
+ const ready = await Promise.race([once(child, 'message').then(([value]) => value), once(child, 'exit').then(() => { throw new Error(stderr); })]);
44
+ const base = `http://127.0.0.1:${ready.port}`;
45
+ const healthTimes = [], requestTimes = [];
46
+ let stop = false;
47
+ const polling = (async () => {
48
+ while (!stop) {
49
+ const start = performance.now();
50
+ await (await fetch(`${base}/health`)).json();
51
+ healthTimes.push(performance.now() - start);
52
+ await sleep(10);
53
+ }
54
+ })();
55
+ try {
56
+ // Both startup-idle and between-wave idle must permit a fresh worker.
57
+ for (let wave = 0; wave < 2; wave++) {
58
+ await sleep(800);
59
+ const sid = `S_latency_${variant}_${wave}`;
60
+ const start = performance.now();
61
+ const body = await (await fetch(`${base}/names${mode === 'off' ? '' : `?__repro_sid=${sid}&__repro_aid=A_${wave}&__repro_mode=${mode}`}`)).json();
62
+ requestTimes.push(performance.now() - start);
63
+ assert.equal(body.rows.length, 120);
64
+ assert.equal(body.rows[119].internalSecret, 'secret-119', 'business responses must not be redacted or limited');
65
+ assert.equal(body.phase, 11);
66
+ if (mode !== 'off') {
67
+ const deadline = Date.now() + 60000;
68
+ const scoped = () => events.filter(e => e.session_id === sid);
69
+ const complete = () => {
70
+ const capture = scoped();
71
+ const manifest = capture.find(e => e.event_type === 'request_capture_manifest')?.payload.requestCaptureManifest;
72
+ return manifest && ['backend_request', 'db_change'].every(t => capture.some(e => e.event_type === t))
73
+ && capture.filter(e => e.event_type === 'trace_batch').length >= manifest.expected.traceBatch;
74
+ };
75
+ while (!complete() && Date.now() < deadline) await sleep(25);
76
+ assert(complete(), `incomplete ${variant}: ${stderr.slice(-2000)}`);
77
+ const captured = scoped();
78
+ assert(!JSON.stringify(captured).includes('__reproTraceValueEntries'), 'worker-only attachments must never be serialized into each occurrence');
79
+ const values = new Map(captured.flatMap(e => [...(e.payload.traceValues || []), ...(e.payload.requestValues || []), ...(e.payload.dbValues || [])]).map(v => [v.id, v.value]));
80
+ const trace = captured.flatMap(e => e.payload.trace || []);
81
+ // The tracer also emits the function-body entry; verify the twelve callsites.
82
+ const observations = [...new Map(trace.filter(e => e.fn === 'observeRows' && e.line === 6 && e.type === 'enter' && (e.args || e.argsValueCapture))
83
+ .map(e => [e.spanId, e])).values()];
84
+ const resolve = (e, field) => values.get(e[`${field}ValueCapture`]?.valueId) ?? e[field];
85
+ assert.equal(observations.length, 12, JSON.stringify(observations.map(e => ({spanId:e.spanId, file:e.file, line:e.line, phase:resolve(e,'args')?.[0]?.phase}))));
86
+ if (variant.endsWith('fault')) {
87
+ assert(observations.some(e => e.argsValueCapture?.status === 'failed-policy'));
88
+ const manifest = captured.find(e => e.event_type === 'request_capture_manifest').payload.requestCaptureManifest;
89
+ assert.equal(manifest.privacy.status, 'failed', 'worker errors must not falsely attest applied privacy');
90
+ assert(manifest.captureFailures.some(f => f.reason === 'worker_value_materialization_failed'));
91
+ assert(!JSON.stringify(captured).includes('secret-0'), 'failed policy capture must never fall back to raw');
92
+ continue;
93
+ }
94
+ assert.deepEqual(observations.map(e => resolve(e, 'args')[0].phase), Array.from({ length: 12 }, (_, i) => i));
95
+ const document = resolve(observations[0], 'args')[0];
96
+ assert.equal(document.rows.length, mode === 'live' ? 101 : 120);
97
+ if (mode === 'live') assert.equal(document.rows[100].reason, 'max-entries');
98
+ assert.equal(document.rows[0].internalSecret, variant.includes('-policy') ? '[dropped]' : 'secret-0');
99
+ const manifest = captured.find(e => e.event_type === 'request_capture_manifest').payload.requestCaptureManifest;
100
+ assert(!manifest.captureFailures?.length, JSON.stringify(manifest.captureFailures));
101
+ assert.equal(manifest.privacy.status, variant.includes('-policy') ? 'applied' : 'bypassed');
102
+ const stats = await (await fetch(`${base}/health`)).json();
103
+ if (variant.endsWith('fallback')) assert.equal(stats.tasks, 0);
104
+ else if (!process.env.REPRO_BENCHMARK_ONLY) assert(stats.tasks >= wave + 1, `${variant}: trace worker stopped accepting large captures after idle: ${JSON.stringify(stats)}`);
105
+ }
106
+ }
107
+ const stats = await (await fetch(`${base}/health`)).json();
108
+ results.push({ variant, requestMs: requestTimes.map(Math.round), maxHealthMs: Math.round(Math.max(...healthTimes)), workers: stats.workers, tasks: stats.tasks });
109
+ console.log(JSON.stringify(results.at(-1)));
110
+ } finally { stop = true; await polling; }
111
+ const exited = once(child, 'exit'); child.send('stop'); await exited; child = null;
112
+ }
113
+ if (!process.env.REPRO_BENCHMARK_ONLY) {
114
+ for (const result of results.slice(1)) assert(result.maxHealthMs < 250, `capture stalled unrelated HTTP: ${JSON.stringify(result)}`);
115
+ }
116
+ console.log('live/staging capture latency and exact evidence testcontainers OK');
117
+ } finally {
118
+ if (child) { const exited = once(child, 'exit'); child.kill(); await exited; }
119
+ await new Promise(resolve => sink.close(resolve));
120
+ if (mongo) await mongo.stop();
121
+ }
122
+ }
123
+ main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -118,15 +118,19 @@ async function runRequest(config, sessionId, repeatedPayload, changedPayload = m
118
118
  });
119
119
  });
120
120
 
121
- const deadline = Date.now() + 5000;
121
+ const deadline = Date.now() + 30000;
122
+ let completed = false;
122
123
  while (Date.now() < deadline) {
123
124
  const events = posts.flatMap((post) => post.events || []);
124
- if (events.some((event) => event.event_type === 'backend_request') &&
125
- events.some((event) => event.event_type === 'trace_batch')) {
125
+ const manifest = events.find(event => event.event_type === 'request_capture_manifest')?.payload.requestCaptureManifest;
126
+ if (manifest && events.some(event => event.event_type === 'backend_request') &&
127
+ events.filter(event => event.event_type === 'trace_batch').length === manifest.expected.traceBatch) {
128
+ completed = true;
126
129
  break;
127
130
  }
128
131
  await sleep(20);
129
132
  }
133
+ assert(completed, `capture did not finish for ${sessionId}`);
130
134
  await __reproTestHooks.drainSdkBackgroundQueueForTest();
131
135
  await flushIngestQueue();
132
136
  return posts;
@@ -228,7 +232,11 @@ async function main() {
228
232
  makePayload(false, 'policy-secret-alpha'),
229
233
  makePayload(false, 'policy-secret-beta'),
230
234
  ));
231
- assert.equal(policyCapture.refs.length, 122);
235
+ assert.equal(policyCapture.refs.length, 122, JSON.stringify({
236
+ eventTypes: policyCapture.ingestEvents.map(event => event.event_type),
237
+ observations: policyCapture.observedEvents.slice(0, 2),
238
+ manifests: policyCapture.ingestEvents.filter(event => event.event_type === 'request_capture_manifest'),
239
+ }));
232
240
  assert.equal(new Set(policyCapture.refs).size, 1);
233
241
  assert.equal(policyCapture.definitions.length, 1);
234
242
  const policySerialized = JSON.stringify(policyCapture.definitions);
@@ -0,0 +1,10 @@
1
+ function observeRows(document) { return document; }
2
+ async function loadRows(Model) {
3
+ const document = await Model.findOne({ reference: 'LATENCY' }).lean().exec();
4
+ for (let i = 0; i < 12; i++) {
5
+ document.phase = i;
6
+ if (observeRows(document) !== document) throw new Error('return identity changed');
7
+ }
8
+ return document;
9
+ }
10
+ module.exports = { loadRows };
@@ -0,0 +1,54 @@
1
+ const workerThreads = require('node:worker_threads');
2
+ const metrics = { workers: 0, tasks: 0 };
3
+ const RealWorker = workerThreads.Worker;
4
+ workerThreads.Worker = class extends RealWorker {
5
+ constructor(...args) {
6
+ if (process.env.CAPTURE_TEST_WORKER_FAULT && String(args[0]).includes('trace-materializer-worker')) {
7
+ args[1] = { ...args[1], execArgv: ['--require', require.resolve('./worker-value-failure')] };
8
+ }
9
+ super(...args);
10
+ if (String(args[0]).includes('trace-materializer-worker')) {
11
+ metrics.workers++;
12
+ const post = this.postMessage.bind(this);
13
+ this.postMessage = (...messages) => { metrics.tasks++; return post(...messages); };
14
+ }
15
+ }
16
+ };
17
+ const http = require('node:http');
18
+ const mode = process.env.CAPTURE_TEST_MODE;
19
+ const { initRepro, reproMongoosePlugin } = require(process.env.CAPTURE_TEST_SDK || '../../dist');
20
+ async function main() {
21
+ const config = {
22
+ tenantId: 'TENANT_latency', appId: 'APP_latency', appSecret: 'test-only',
23
+ appName: 'latency-test', serviceName: 'Latency API', liveMode: false,
24
+ tracing: { include: [/capture-latency-business\.js$/], disableFunctionTypes: ['constructor'], logFunctionCalls: false },
25
+ privacy: { environment: 'dev', captureRaw: !process.env.CAPTURE_TEST_POLICY,
26
+ ...(process.env.CAPTURE_TEST_POLICY ? { policy: JSON.parse(process.env.CAPTURE_TEST_POLICY) } : {}) },
27
+ ingestBase: process.env.CAPTURE_TEST_SINK,
28
+ };
29
+ if (mode !== 'off') await initRepro(config);
30
+ const mongoose = require('mongoose');
31
+ const express = require('express');
32
+ const connection = await mongoose.createConnection(process.env.CAPTURE_TEST_MONGO).asPromise();
33
+ const schema = new mongoose.Schema({ reference: String, phase: Number, rows: [Object] });
34
+ if (mode !== 'off') schema.plugin(reproMongoosePlugin, config);
35
+ const Model = connection.model('Latency', schema);
36
+ await Model.create({ reference: 'LATENCY', phase: -1, rows: Array.from({ length: 120 }, (_, id) => ({
37
+ id, status: 'available', internalSecret: `secret-${id}`, description: 'Operational depot description '.repeat(32),
38
+ })) });
39
+ const { loadRows } = require('./capture-latency-business');
40
+ const app = express();
41
+ app.get('/health', (_req, res) => res.json({ ok: true, ...metrics }));
42
+ app.get('/names', async (_req, res, next) => {
43
+ try { res.json(await loadRows(Model)); } catch (e) { next(e); }
44
+ });
45
+ const server = http.createServer(app);
46
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
47
+ process.send({ port: server.address().port });
48
+ process.once('message', async () => {
49
+ await new Promise(resolve => server.close(resolve));
50
+ await connection.close();
51
+ process.exit(0);
52
+ });
53
+ }
54
+ main().catch(error => { console.error(error); process.exit(1); });
@@ -0,0 +1,11 @@
1
+ // Fault injection inside a real worker: preview succeeds, full-value policy
2
+ // transformation fails. The parent must not publish a successful attestation.
3
+ const privacy = require('../../dist/privacy');
4
+ const apply = privacy.applyRuntimePrivacyPolicyAsync;
5
+ let calls = 0;
6
+ privacy.applyRuntimePrivacyPolicyAsync = async (...args) => {
7
+ if (args[1] === 'trace.args' && args[2]?.[0]?.rows?.length === 120 && ++calls % 2 === 0) {
8
+ throw new Error('injected full-value policy failure');
9
+ }
10
+ return apply(...args);
11
+ };
@@ -0,0 +1,25 @@
1
+ const assert = require('node:assert/strict');
2
+ const http = require('node:http');
3
+ const { fetchApprovedRuntimePrivacyPolicy } = require('../dist/privacy');
4
+ const policy = { environment: 'dev', statements: [{ sid: 'secret', when: { scope: 'FIELD', selector: 'field:internalSecret' }, apply: { action: 'DROP' } }], notes: 'policy-analysis '.repeat(140000) };
5
+ async function main() {
6
+ const server = http.createServer((req, res) => {
7
+ const service = new URL(req.url, 'http://local').searchParams.get('serviceName');
8
+ if (service === 'forbidden') { res.writeHead(403); res.end('{}'); return; }
9
+ res.writeHead(200, { 'content-type': 'application/json' });
10
+ res.flushHeaders();
11
+ const timer = setTimeout(() => res.end(JSON.stringify({ enabled: true, policy })), 2800);
12
+ res.once('close', () => clearTimeout(timer));
13
+ });
14
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
15
+ const params = { appId: 'APP_test', appSecret: 'test-only', environment: 'dev', apiBase: `http://127.0.0.1:${server.address().port}` };
16
+ try {
17
+ const result = await fetchApprovedRuntimePrivacyPolicy(params);
18
+ assert.equal(result.status, 'ok', 'a valid multi-megabyte policy must survive the observed production response latency');
19
+ assert.equal(result.policy.statements.length, 1);
20
+ assert.deepEqual(await fetchApprovedRuntimePrivacyPolicy({ ...params, fetchTimeoutMs: 250 }), { status: 'network_error', error: 'timeout' });
21
+ assert.deepEqual(await fetchApprovedRuntimePrivacyPolicy({ ...params, serviceName: 'forbidden' }), { status: 'http_error', httpStatus: 403 });
22
+ console.log('policy body timeout and explicit timeout/auth failures OK');
23
+ } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
24
+ }
25
+ main().catch(error => { console.error(error); process.exitCode = 1; });