@reproapp/node-sdk 0.0.48 → 0.0.50

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.
@@ -0,0 +1,78 @@
1
+ process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
2
+ process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
3
+ process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '30';
4
+ process.env.TRACE_IDLE_FLUSH_MS = '10';
5
+ process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
6
+ const assert = require('node:assert/strict');
7
+ const http = require('node:http');
8
+ const { GenericContainer, Wait } = require('testcontainers');
9
+ const { initRepro } = require('../dist');
10
+ const { flushIngestQueue } = require('../dist/ingest/client');
11
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
12
+
13
+ async function main() {
14
+ const posts = [];
15
+ const sink = http.createServer((req, res) => {
16
+ const chunks = [];
17
+ req.on('data', chunk => chunks.push(chunk));
18
+ req.on('end', () => { posts.push(JSON.parse(Buffer.concat(chunks))); res.writeHead(202); res.end('{}'); });
19
+ });
20
+ await new Promise(resolve => sink.listen(0, '127.0.0.1', resolve));
21
+ let mongo, app;
22
+ try {
23
+ mongo = await new GenericContainer('mongo:7').withExposedPorts(27017).withWaitStrategy(Wait.forListeningPorts()).start();
24
+ const config = { tenantId: 'TENANT_limits', appId: 'APP_limits', appSecret: 'test',
25
+ privacy: { environment: 'dev', captureRaw: true }, ingestBase: `http://127.0.0.1:${sink.address().port}`,
26
+ tracing: { fullValueCapture: true, disableFunctionTypes: ['constructor'], logFunctionCalls: false } };
27
+ await initRepro(config);
28
+ app = await require('./fixtures/live-capture-limits-server').start(config, `mongodb://${mongo.getHost()}:${mongo.getMappedPort(27017)}/limits`);
29
+ for (const mode of ['live', 'staging']) {
30
+ const response = await fetch(`http://127.0.0.1:${app.server.address().port}/shipment?__repro_sid=S_${mode}&__repro_aid=A_${mode}&__repro_mode=${mode}`);
31
+ assert.equal(response.status, 200);
32
+ const actual = await response.json();
33
+ assert.equal(actual.rows.length, 157, 'recording must not alter the application response');
34
+ assert.equal(actual.rows[156].id, 156);
35
+ }
36
+ const deadline = Date.now() + 30_000;
37
+ let events = [];
38
+ do {
39
+ await flushIngestQueue();
40
+ events = posts.flatMap(post => post.events || []);
41
+ if (events.filter(event => event.event_type === 'request_capture_manifest').length >= 2) break;
42
+ await sleep(100);
43
+ } while (Date.now() < deadline);
44
+ for (const mode of ['live', 'staging']) {
45
+ const scoped = events.filter(event => event.session_id === `S_${mode}`);
46
+ const request = scoped.find(event => event.event_type === 'backend_request')?.payload?.request;
47
+ assert(request, `missing ${mode} request`);
48
+ const values = scoped.flatMap(event => [...(event.payload?.traceValues || []), ...(event.payload?.requestValues || []), ...(event.payload?.dbValues || [])]);
49
+ if (request.respBodyValueCapture?.valueId) assert(values.some(value => value.id === request.respBodyValueCapture.valueId), 'response reference must resolve');
50
+ const body = values.find(value => value.id === request.respBodyValueCapture?.valueId)?.value || request.respBody;
51
+ assert(body?.rows, `missing ${mode} response body`);
52
+ assert.equal(body.rows.length, mode === 'live' ? 101 : 157, JSON.stringify({ mode, capture: request.respBodyValueCapture, valueTargets: values.map(value => value.target) }));
53
+ if (mode === 'live') {
54
+ assert.equal(body.rows[100].originalCount, 157);
55
+ assert.equal(request.respBodyValueCapture.complete, false);
56
+ }
57
+ const changes = scoped.filter(event => event.event_type === 'db_change').flatMap(event => event.payload?.db || []);
58
+ const read = changes.find(change => change.op === 'findOne');
59
+ assert(read, `missing ${mode} database query`);
60
+ const result = values.find(value => value.id === read.resultMetaValueCapture?.valueId)?.value || read.resultMeta;
61
+ const resultText = JSON.stringify(result);
62
+ assert(resultText?.includes('SHIP-LIMIT'), `missing ${mode} database result ${JSON.stringify(read)}`);
63
+ assert.equal(resultText.includes('"id":156'), mode === 'staging', 'query results must follow the same mode as responses');
64
+ if (mode === 'live') assert.equal(read.resultMetaValueCapture.complete, false);
65
+ const trace = scoped.flatMap(event => event.payload?.trace || []).find(event => event.fn === 'inspectShipment' && event.type === 'enter');
66
+ assert(trace, `missing ${mode} function arguments`);
67
+ const args = values.find(value => value.id === trace.argsValueCapture?.valueId)?.value || trace.args;
68
+ assert.equal(args[0].rows.length, mode === 'live' ? 101 : 157);
69
+ }
70
+ console.log('real Mongo + Express + instrumented traces: live bounds and exact staging evidence passed');
71
+ } finally {
72
+ if (app) await app.close();
73
+ await flushIngestQueue();
74
+ await new Promise(resolve => sink.close(resolve));
75
+ if (mongo) await mongo.stop();
76
+ }
77
+ }
78
+ main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -79,6 +79,7 @@ function requestNames(port, index) {
79
79
  'x-bug-session-id': 'S_policy_completion',
80
80
  'x-bug-action-id': `A_policy_completion_${index}`,
81
81
  'x-bug-request-start': String(Date.now()),
82
+ 'x-demo-actor-id': 'policy-completion-actor',
82
83
  },
83
84
  }, (response) => {
84
85
  const chunks = [];
@@ -152,10 +153,26 @@ async function main() {
152
153
  captureHeaders: false,
153
154
  privacy: {
154
155
  environment: 'dev',
155
- policy: largePolicy(),
156
+ },
157
+ liveMode: {
158
+ enabled: true,
159
+ backendOnly: true,
160
+ defineActorId: ({ req }) => req.headers['x-demo-actor-id'],
161
+ shouldCapture: () => true,
162
+ actorType: 'policy-test-user',
156
163
  },
157
164
  ingestBase: 'http://127.0.0.1:65535',
158
165
  };
166
+ __reproTestHooks.shareRuntimePrivacyStateForTest(
167
+ {
168
+ ...cfg,
169
+ privacy: {
170
+ environment: 'dev',
171
+ policy: largePolicy(),
172
+ },
173
+ },
174
+ cfg,
175
+ );
159
176
 
160
177
  const depotSchema = new mongoose.Schema({
161
178
  location: String,
@@ -0,0 +1,403 @@
1
+ process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '50';
2
+ process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
3
+ process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
4
+ process.env.REPRO_SDK_TRACE_INLINE_VALUE_MAX_SERIALIZED_CHARS = '256';
5
+ process.env.REPRO_TRACE_BATCH_MAX_SERIALIZED_BYTES = '65536';
6
+ process.env.TRACE_IDLE_FLUSH_MS = '10';
7
+ process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
8
+
9
+ const assert = require('node:assert/strict');
10
+ const { EventEmitter } = require('node:events');
11
+ const { initReproTracing, reproMiddleware, __reproTestHooks } = require('../dist');
12
+ const { flushIngestQueue } = require('../dist/ingest/client');
13
+
14
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+
16
+ function deferred() {
17
+ let resolve;
18
+ let reject;
19
+ const promise = new Promise((resolvePromise, rejectPromise) => {
20
+ resolve = resolvePromise;
21
+ reject = rejectPromise;
22
+ });
23
+ return { promise, resolve, reject };
24
+ }
25
+
26
+ function makeReqRes(sessionId) {
27
+ const req = new EventEmitter();
28
+ req.method = 'POST';
29
+ req.url = '/trace-observation-snapshots';
30
+ req.originalUrl = req.url;
31
+ req.headers = {
32
+ 'x-bug-session-id': sessionId,
33
+ 'x-bug-action-id': `A_${sessionId}`,
34
+ 'x-bug-request-start': String(Date.now()),
35
+ };
36
+ req.body = {};
37
+ req.params = {};
38
+ req.query = {};
39
+
40
+ const res = new EventEmitter();
41
+ res.statusCode = 204;
42
+ res.getHeader = () => undefined;
43
+ res.getHeaders = () => ({});
44
+ res.setHeader = () => {};
45
+ res.write = () => true;
46
+ res.end = function end() {
47
+ this.emit('finish');
48
+ return this;
49
+ };
50
+ return { req, res };
51
+ }
52
+
53
+ function makePayload(id, phase = 'before') {
54
+ return {
55
+ id,
56
+ phase,
57
+ nested: { count: 0 },
58
+ rows: Array.from({ length: 20 }, (_, index) => ({
59
+ index,
60
+ location: `${id}-LOCATION-${String(index).padStart(3, '0')}`,
61
+ state: 'initial',
62
+ })),
63
+ };
64
+ }
65
+
66
+ function mutateSyncPayload(payload) {
67
+ payload.phase = 'inside-sync';
68
+ payload.nested.count = 1;
69
+ payload.rows[0].state = 'inside-sync';
70
+ return payload;
71
+ }
72
+
73
+ function observeVersion(payload) {
74
+ return payload.phase;
75
+ }
76
+
77
+ function observeAliasedPayload(payload) {
78
+ return payload;
79
+ }
80
+
81
+ async function mutateAsyncPayload(payload, gate) {
82
+ payload.phase = 'started';
83
+ await gate;
84
+ payload.phase = 'settled';
85
+ payload.nested.count += 1;
86
+ payload.rows[0].state = 'settled';
87
+ return payload;
88
+ }
89
+
90
+ function mutateAndThrow(payload) {
91
+ payload.phase = 'before-throw';
92
+ payload.nested.count = 7;
93
+ throw new Error('expected trace snapshot failure path');
94
+ }
95
+
96
+ function observeHostilePayload(payload) {
97
+ return payload;
98
+ }
99
+
100
+ async function waitForCapture(posts) {
101
+ const deadline = Date.now() + 5000;
102
+ while (Date.now() < deadline) {
103
+ const events = posts.flatMap((post) => post.events || []);
104
+ if (events.some((event) => event.event_type === 'backend_request') &&
105
+ events.some((event) => event.event_type === 'trace_batch')) {
106
+ break;
107
+ }
108
+ await sleep(20);
109
+ }
110
+ await __reproTestHooks.drainSdkBackgroundQueueForTest();
111
+ await flushIngestQueue();
112
+ }
113
+
114
+ function inspectCapture(posts) {
115
+ const ingestEvents = posts.flatMap((post) => post.events || []);
116
+ const traceBatches = ingestEvents.filter((event) => event.event_type === 'trace_batch');
117
+ const events = traceBatches.flatMap((event) => event.payload?.trace || []);
118
+ const definitions = traceBatches.flatMap((event) => event.payload?.traceValues || []);
119
+ const definitionsById = new Map(definitions.map((definition) => [definition.id, definition]));
120
+
121
+ const resolveCaptured = (event, field) => {
122
+ const capture = event[field];
123
+ assert.equal(capture?.status, 'stored', `${event.fn} ${event.type} lacks ${field}`);
124
+ const definition = definitionsById.get(capture.valueId);
125
+ assert(definition, `missing definition ${capture.valueId}`);
126
+ return definition.value;
127
+ };
128
+
129
+ return { ingestEvents, traceBatches, events, definitions, definitionsById, resolveCaptured };
130
+ }
131
+
132
+ function eventsFor(capture, fn) {
133
+ return capture.events.filter((event) => event.fn === fn);
134
+ }
135
+
136
+ async function main() {
137
+ const config = {
138
+ tenantId: 'TENANT_trace_observation_snapshot',
139
+ appId: 'APP_trace_observation_snapshot',
140
+ appSecret: 'trace-observation-snapshot-secret',
141
+ captureHeaders: false,
142
+ privacy: { environment: 'dev', captureRaw: true },
143
+ ingestBase: 'http://127.0.0.1:65535',
144
+ };
145
+
146
+ initReproTracing({ instrument: false, fullValueCapture: true, logFunctionCalls: false });
147
+ __reproTestHooks.resetSdkBackgroundQueuesForTest();
148
+
149
+ const posts = [];
150
+ const previousFetch = global.fetch;
151
+ global.fetch = async (_url, init = {}) => {
152
+ posts.push(JSON.parse(String(init.body || '{}')));
153
+ return {
154
+ ok: true,
155
+ status: 202,
156
+ json: async () => ({ ok: true }),
157
+ text: async () => '{"ok":true}',
158
+ };
159
+ };
160
+
161
+ const sessionId = 'S_trace_observation_snapshot';
162
+ const middleware = reproMiddleware(config);
163
+ const { req, res } = makeReqRes(sessionId);
164
+
165
+ try {
166
+ await new Promise((resolve, reject) => {
167
+ middleware(req, res, async (middlewareError) => {
168
+ if (middlewareError) {
169
+ reject(middlewareError);
170
+ return;
171
+ }
172
+
173
+ try {
174
+ const syncPayload = makePayload('sync');
175
+ const syncResult = global.__repro_call(
176
+ mutateSyncPayload,
177
+ null,
178
+ [syncPayload],
179
+ '/app/trace-observation.service.ts',
180
+ 20,
181
+ 'mutateSyncPayload',
182
+ false,
183
+ );
184
+ assert.strictEqual(syncResult, syncPayload, 'tracing changed sync return identity');
185
+ syncPayload.phase = 'after-sync-caller';
186
+ syncPayload.nested.count = 99;
187
+ syncPayload.rows[0].state = 'after-sync-caller';
188
+
189
+ const versionedPayload = makePayload('versioned', 'v1');
190
+ for (const phase of ['v1', 'v2', 'v1']) {
191
+ versionedPayload.phase = phase;
192
+ const observed = global.__repro_call(
193
+ observeVersion,
194
+ null,
195
+ [versionedPayload],
196
+ '/app/trace-observation.service.ts',
197
+ 40,
198
+ 'observeVersion',
199
+ false,
200
+ );
201
+ assert.equal(observed, phase);
202
+ }
203
+ versionedPayload.phase = 'after-version-caller';
204
+
205
+ const shared = {
206
+ value: 'alias-before',
207
+ rows: makePayload('alias-rows').rows,
208
+ };
209
+ const aliasedPayload = { id: 'alias', left: shared, right: shared };
210
+ aliasedPayload.self = aliasedPayload;
211
+ const aliasResult = global.__repro_call(
212
+ observeAliasedPayload,
213
+ null,
214
+ [aliasedPayload],
215
+ '/app/trace-observation.service.ts',
216
+ 60,
217
+ 'observeAliasedPayload',
218
+ false,
219
+ );
220
+ assert.strictEqual(aliasResult, aliasedPayload, 'tracing changed aliased return identity');
221
+ shared.value = 'alias-after-caller';
222
+
223
+ const gateA = deferred();
224
+ const gateB = deferred();
225
+ const asyncPayloadA = makePayload('async-a');
226
+ const asyncPayloadB = makePayload('async-b');
227
+ const pendingA = global.__repro_call(
228
+ mutateAsyncPayload,
229
+ null,
230
+ [asyncPayloadA, gateA.promise],
231
+ '/app/trace-observation.service.ts',
232
+ 80,
233
+ 'mutateAsyncPayloadA',
234
+ false,
235
+ );
236
+ const pendingB = global.__repro_call(
237
+ mutateAsyncPayload,
238
+ null,
239
+ [asyncPayloadB, gateB.promise],
240
+ '/app/trace-observation.service.ts',
241
+ 90,
242
+ 'mutateAsyncPayloadB',
243
+ false,
244
+ );
245
+ gateB.resolve();
246
+ assert.strictEqual(await pendingB, asyncPayloadB);
247
+ asyncPayloadB.phase = 'after-async-b-caller';
248
+ asyncPayloadB.nested.count = 99;
249
+ gateA.resolve();
250
+ assert.strictEqual(await pendingA, asyncPayloadA);
251
+ asyncPayloadA.phase = 'after-async-a-caller';
252
+ asyncPayloadA.nested.count = 99;
253
+
254
+ const throwingPayload = makePayload('throwing');
255
+ assert.throws(() => global.__repro_call(
256
+ mutateAndThrow,
257
+ null,
258
+ [throwingPayload],
259
+ '/app/trace-observation.service.ts',
260
+ 110,
261
+ 'mutateAndThrow',
262
+ false,
263
+ ), /expected trace snapshot failure path/);
264
+ throwingPayload.phase = 'after-throw-caller';
265
+ throwingPayload.nested.count = 99;
266
+
267
+ const hostilePayload = new Proxy(
268
+ { id: 'hostile-payload' },
269
+ {
270
+ ownKeys() {
271
+ throw new Error('hostile ownKeys trap');
272
+ },
273
+ },
274
+ );
275
+ const hostileResult = global.__repro_call(
276
+ observeHostilePayload,
277
+ null,
278
+ [hostilePayload],
279
+ '/app/trace-observation.service.ts',
280
+ 130,
281
+ 'observeHostilePayload',
282
+ false,
283
+ );
284
+ assert.strictEqual(hostileResult, hostilePayload, 'snapshot failure changed application identity');
285
+
286
+ res.end();
287
+ resolve();
288
+ } catch (error) {
289
+ reject(error);
290
+ }
291
+ });
292
+ });
293
+
294
+ await waitForCapture(posts);
295
+ const capture = inspectCapture(posts);
296
+
297
+ const syncEvents = eventsFor(capture, 'mutateSyncPayload');
298
+ assert.equal(syncEvents.length, 2);
299
+ const syncEnterArgs = capture.resolveCaptured(syncEvents.find((event) => event.type === 'enter'), 'argsValueCapture');
300
+ const syncExit = syncEvents.find((event) => event.type === 'exit');
301
+ const syncExitArgs = capture.resolveCaptured(syncExit, 'argsValueCapture');
302
+ const syncReturn = capture.resolveCaptured(syncExit, 'returnValueCapture');
303
+ assert.deepEqual(
304
+ [syncEnterArgs[0].phase, syncEnterArgs[0].nested.count, syncEnterArgs[0].rows[0].state],
305
+ ['before', 0, 'initial'],
306
+ );
307
+ assert.deepEqual(
308
+ [syncExitArgs[0].phase, syncExitArgs[0].nested.count, syncExitArgs[0].rows[0].state],
309
+ ['inside-sync', 1, 'inside-sync'],
310
+ );
311
+ assert.deepEqual(
312
+ [syncReturn.phase, syncReturn.nested.count, syncReturn.rows[0].state],
313
+ ['inside-sync', 1, 'inside-sync'],
314
+ );
315
+
316
+ const versionEvents = eventsFor(capture, 'observeVersion');
317
+ assert.equal(versionEvents.length, 6);
318
+ const versionRefs = versionEvents.map((event) => event.argsValueCapture?.valueId);
319
+ assert(versionRefs.every((valueId) => typeof valueId === 'string'));
320
+ assert.equal(versionRefs[0], versionRefs[1], 'unchanged enter/exit observations must deduplicate');
321
+ assert.equal(versionRefs[2], versionRefs[3], 'second unchanged enter/exit observations must deduplicate');
322
+ assert.equal(versionRefs[4], versionRefs[5], 'restored enter/exit observations must deduplicate');
323
+ assert.equal(versionRefs[0], versionRefs[4], 'restoring prior content must restore its content ID');
324
+ assert.notEqual(versionRefs[0], versionRefs[2], 'same object with changed content needs a new content ID');
325
+ assert.deepEqual(
326
+ [0, 2, 4].map((index) => capture.resolveCaptured(versionEvents[index], 'argsValueCapture')[0].phase),
327
+ ['v1', 'v2', 'v1'],
328
+ );
329
+
330
+ const aliasEvents = eventsFor(capture, 'observeAliasedPayload');
331
+ assert.equal(aliasEvents.length, 2);
332
+ const aliasArgs = capture.resolveCaptured(aliasEvents[0], 'argsValueCapture');
333
+ assert.equal(aliasArgs[0].left.value, 'alias-before');
334
+ assert.deepEqual(aliasArgs[0].right, { __reproCircularRef: '$[0].left' });
335
+ assert.deepEqual(aliasArgs[0].self, { __reproCircularRef: '$[0]' });
336
+
337
+ for (const fn of ['mutateAsyncPayloadA', 'mutateAsyncPayloadB']) {
338
+ const asyncEvents = eventsFor(capture, fn);
339
+ assert.equal(asyncEvents.length, 2, `${fn} event count`);
340
+ const enterArgs = capture.resolveCaptured(asyncEvents.find((event) => event.type === 'enter'), 'argsValueCapture');
341
+ const exit = asyncEvents.find((event) => event.type === 'exit');
342
+ const exitArgs = capture.resolveCaptured(exit, 'argsValueCapture');
343
+ const returnValue = capture.resolveCaptured(exit, 'returnValueCapture');
344
+ assert.equal(enterArgs[0].phase, 'before', `${fn} enter was rewritten`);
345
+ assert.equal(enterArgs[0].nested.count, 0);
346
+ assert.equal(exitArgs[0].phase, 'settled', `${fn} exit was rewritten by caller mutation`);
347
+ assert.equal(exitArgs[0].nested.count, 1);
348
+ assert.equal(returnValue.phase, 'settled', `${fn} return was rewritten by caller mutation`);
349
+ assert.equal(returnValue.nested.count, 1);
350
+ }
351
+
352
+ const throwEvents = eventsFor(capture, 'mutateAndThrow');
353
+ assert.equal(throwEvents.length, 2);
354
+ const throwExit = throwEvents.find((event) => event.type === 'exit');
355
+ const throwArgs = capture.resolveCaptured(throwExit, 'argsValueCapture');
356
+ const throwError = capture.resolveCaptured(throwExit, 'errorValueCapture');
357
+ assert.equal(throwArgs[0].phase, 'before-throw');
358
+ assert.equal(throwArgs[0].nested.count, 7);
359
+ assert.equal(throwExit.threw, true);
360
+ assert.equal(throwError.message, 'expected trace snapshot failure path');
361
+
362
+ const hostileEvents = eventsFor(capture, 'observeHostilePayload');
363
+ assert.equal(hostileEvents.length, 2);
364
+ for (const hostileEvent of hostileEvents) {
365
+ assert.deepEqual(hostileEvent.args, [{
366
+ __kind: 'omitted',
367
+ reason: 'capture-snapshot-failed',
368
+ }]);
369
+ }
370
+ const manifests = capture.ingestEvents
371
+ .filter((event) => event.event_type === 'request_capture_manifest')
372
+ .map((event) => event.payload?.requestCaptureManifest ?? event.payload)
373
+ .filter(Boolean);
374
+ assert.equal(manifests.length, 1);
375
+ assert.equal(manifests[0].privacy?.status, 'failed');
376
+ assert(manifests[0].captureFailures?.some((failure) =>
377
+ failure.stage === 'trace.snapshot' &&
378
+ failure.target === 'trace.args' &&
379
+ failure.reason === 'snapshot_failed'
380
+ ), JSON.stringify(manifests[0]));
381
+
382
+ const serializedDefinitions = JSON.stringify(capture.definitions);
383
+ for (const forbidden of [
384
+ 'after-sync-caller',
385
+ 'after-version-caller',
386
+ 'alias-after-caller',
387
+ 'after-async-a-caller',
388
+ 'after-async-b-caller',
389
+ 'after-throw-caller',
390
+ ]) {
391
+ assert(!serializedDefinitions.includes(forbidden), `late mutation leaked into capture: ${forbidden}`);
392
+ }
393
+
394
+ console.log('trace observation snapshots preserve temporal values and application identity OK');
395
+ } finally {
396
+ global.fetch = previousFetch;
397
+ }
398
+ }
399
+
400
+ main().catch((error) => {
401
+ console.error(error);
402
+ process.exit(1);
403
+ });