@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.
@@ -0,0 +1,177 @@
1
+ import { IncomingMessage, ServerResponse } from 'http';
2
+ import { mongooseDocumentData, projectSemanticInfrastructureValue } from './semantic-serialization';
3
+
4
+ export type CaptureOmission = {
5
+ reason: 'max-depth' | 'max-keys' | 'max-entries' | 'accessor' | 'uninspectable';
6
+ path: string;
7
+ limit?: number;
8
+ originalCount?: number;
9
+ capturedCount: number;
10
+ };
11
+ export const LIVE_CAPTURE_LIMITS = Object.freeze({ maxDepth: 10, maxKeys: 100, maxEntries: 100 });
12
+ const marker = (omission: CaptureOmission) => ({ __kind: 'repro-omitted', complete: false, ...omission });
13
+ const childPath = (path: string, key: string | number) => `${path}[${JSON.stringify(key)}]`;
14
+ function parseStructuredString(value: unknown): any {
15
+ if (typeof value !== 'string' || !/^[\s]*[\[{]/.test(value)) return undefined;
16
+ try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : undefined; }
17
+ catch { return undefined; }
18
+ }
19
+
20
+ /** Snapshot only data descriptors: capture must never execute application getters. */
21
+ export function limitLiveCapture(value: unknown, rootPath = '$'): { value: any; omissions: CaptureOmission[] } {
22
+ const omissions: CaptureOmission[] = [];
23
+ const seen = new WeakMap<object, { path: string; depth: number }>();
24
+ const omit = (detail: CaptureOmission) => { omissions.push(detail); return marker(detail); };
25
+ const walk = (input: any, depth: number, path: string): any => {
26
+ if (input === null || input === undefined) return input;
27
+ if (typeof input === 'string') {
28
+ const parsed = parseStructuredString(input);
29
+ if (parsed === undefined) return input;
30
+ const before = omissions.length;
31
+ const bounded = walk(parsed, depth, path);
32
+ // Preserve the original string exactly unless its structured data exceeded a limit.
33
+ return omissions.length === before ? input : JSON.stringify(bounded);
34
+ }
35
+ if (typeof input === 'bigint' || typeof input === 'symbol') return String(input);
36
+ if (typeof input === 'function') return '[Function]';
37
+ if (typeof input !== 'object') return input;
38
+ const previous = seen.get(input);
39
+ if (previous && previous.depth <= depth) return { __reproCircularRef: previous.path };
40
+ seen.set(input, { path, depth });
41
+ const descriptor = (key: string) => Object.getOwnPropertyDescriptor(input, key);
42
+ const data = (key: string) => descriptor(key)?.value;
43
+ if (data('__kind') === 'repro-omitted') {
44
+ const detail = { reason: data('reason'), path: data('path'), limit: data('limit'), originalCount: data('originalCount'), capturedCount: data('capturedCount') };
45
+ omissions.push(detail);
46
+ return marker(detail);
47
+ }
48
+ if (input instanceof Date) return Number.isNaN(Date.prototype.getTime.call(input)) ? 'Invalid Date' : Date.prototype.toISOString.call(input);
49
+ if (input instanceof RegExp) return RegExp.prototype.toString.call(input);
50
+ if (input instanceof IncomingMessage) {
51
+ // The business body is its own root, not depth one in a socket graph.
52
+ return { __kind: 'http-request', method: data('method'), url: data('originalUrl') ?? data('url'),
53
+ headers: walk(data('headers') ?? Object.getOwnPropertyDescriptor(IncomingMessage.prototype, 'headers')?.get?.call(input), 0, childPath(path, 'headers')),
54
+ query: walk(data('query'), 0, childPath(path, 'query')),
55
+ body: walk(data('body'), 0, childPath(path, 'body')),
56
+ params: walk(data('params'), 0, childPath(path, 'params')) };
57
+ }
58
+ if (input instanceof ServerResponse) return { __kind: 'http-response', statusCode: data('statusCode') };
59
+ const projected = projectSemanticInfrastructureValue(input);
60
+ if (projected) return projected.value;
61
+ // Mongoose stores business fields in _doc; never recurse through $__ state.
62
+ const documentData = mongooseDocumentData(input);
63
+ if (documentData) return walk(documentData, depth, path);
64
+ if (depth > LIVE_CAPTURE_LIMITS.maxDepth) return omit({ reason: 'max-depth', path, limit: LIVE_CAPTURE_LIMITS.maxDepth, capturedCount: 0 });
65
+ if (input instanceof Error) {
66
+ const details: Record<string, any> = Object.create(null);
67
+ for (const key of ['message', 'stack', 'cause', 'errors', ...Object.keys(input)]) {
68
+ const item = descriptor(key);
69
+ if (item) Object.defineProperty(details, key, { ...item, enumerable: true, configurable: true });
70
+ }
71
+ return walk(details, depth, path);
72
+ }
73
+ if (input instanceof ArrayBuffer || (typeof SharedArrayBuffer !== 'undefined' && input instanceof SharedArrayBuffer) || input instanceof DataView) {
74
+ let bytes: Uint8Array;
75
+ if (input instanceof DataView) {
76
+ const read = (key: string) => Object.getOwnPropertyDescriptor(DataView.prototype, key)!.get!.call(input);
77
+ bytes = new Uint8Array(read('buffer'), read('byteOffset'), read('byteLength'));
78
+ } else bytes = new Uint8Array(input);
79
+ return { __type: input instanceof DataView ? 'DataView' : 'ArrayBuffer', values: walk(bytes, depth, path).values };
80
+ }
81
+ if (input instanceof Map) {
82
+ const entries: any[] = [];
83
+ const size = Object.getOwnPropertyDescriptor(Map.prototype, 'size')!.get!.call(input);
84
+ for (const [key, item] of Map.prototype.entries.call(input)) {
85
+ if (entries.length >= LIVE_CAPTURE_LIMITS.maxEntries) break;
86
+ const index = entries.length;
87
+ entries.push([walk(key, depth + 1, childPath(path, `key:${index}`)), walk(item, depth + 1, childPath(path, index))]);
88
+ }
89
+ if (size > entries.length) entries.push(omit({ reason: 'max-entries', path, limit: 100, originalCount: size, capturedCount: entries.length }));
90
+ return { __type: 'Map', entries };
91
+ }
92
+ if (input instanceof Set) {
93
+ const values: any[] = [];
94
+ const size = Object.getOwnPropertyDescriptor(Set.prototype, 'size')!.get!.call(input);
95
+ for (const item of Set.prototype.values.call(input)) {
96
+ if (values.length >= 100) break;
97
+ values.push(walk(item, depth + 1, childPath(path, values.length)));
98
+ }
99
+ if (size > values.length) values.push(omit({ reason: 'max-entries', path, limit: 100, originalCount: size, capturedCount: values.length }));
100
+ return { __type: 'Set', values };
101
+ }
102
+ if (Array.isArray(input) || (ArrayBuffer.isView(input) && !(input instanceof DataView))) {
103
+ const length = (input as any).length;
104
+ const previousMarker = Array.isArray(input) && length === 101 ? descriptor('100')?.value : null;
105
+ const prior = previousMarker && Object.getOwnPropertyDescriptors(previousMarker);
106
+ const hasPriorLimit = prior?.__kind?.value === 'repro-omitted' && prior?.reason?.value === 'max-entries';
107
+ const values: any[] = [];
108
+ for (let i = 0; i < Math.min(length, 100); i++) {
109
+ const itemDescriptor = descriptor(String(i));
110
+ values.push(itemDescriptor && !('value' in itemDescriptor)
111
+ ? omit({ reason: 'accessor', path: childPath(path, i), capturedCount: 0 })
112
+ : walk(itemDescriptor?.value, depth + 1, childPath(path, i)));
113
+ }
114
+ if (length > 100) values.push(hasPriorLimit
115
+ ? walk(previousMarker, depth + 1, path)
116
+ : omit({ reason: 'max-entries', path, limit: 100, originalCount: length, capturedCount: 100 }));
117
+ return Array.isArray(input) ? values : { __type: 'TypedArray', values };
118
+ }
119
+ const keys = Object.keys(input);
120
+ const previousMetadataKey = keys.find(key => {
121
+ if (!/^__reproCapture\$*$/.test(key)) return false;
122
+ const candidate = descriptor(key)?.value;
123
+ return candidate && Object.getOwnPropertyDescriptor(candidate, '__kind')?.value === 'repro-omitted'
124
+ && Object.getOwnPropertyDescriptor(candidate, 'reason')?.value === 'max-keys';
125
+ });
126
+ const businessKeys = keys.filter(key => key !== previousMetadataKey);
127
+ const out: Record<string, any> = Object.create(null);
128
+ for (const key of businessKeys.slice(0, 100)) {
129
+ const itemDescriptor = descriptor(key)!;
130
+ out[key] = 'value' in itemDescriptor
131
+ ? walk(itemDescriptor.value, depth + 1, childPath(path, key))
132
+ : omit({ reason: 'accessor', path: childPath(path, key), capturedCount: 0 });
133
+ }
134
+ if (businessKeys.length > 100 || previousMetadataKey) {
135
+ const detail = previousMetadataKey
136
+ ? walk(descriptor(previousMetadataKey)?.value, depth + 1, path)
137
+ : omit({ reason: 'max-keys', path, limit: 100, originalCount: businessKeys.length, capturedCount: 100 });
138
+ let metadataKey = '__reproCapture';
139
+ while (Object.prototype.hasOwnProperty.call(out, metadataKey)) metadataKey += '$';
140
+ out[metadataKey] = detail;
141
+ }
142
+ return out;
143
+ };
144
+ try { return { value: walk(value, 0, rootPath), omissions }; }
145
+ catch { return { value: omit({ reason: 'uninspectable', path: rootPath, capturedCount: 0 }), omissions }; }
146
+ }
147
+
148
+ export function collectCaptureOmissions(value: any): CaptureOmission[] {
149
+ const result: CaptureOmission[] = [];
150
+ const seen = new WeakSet();
151
+ const pending = [value];
152
+ while (pending.length) {
153
+ const item = pending.pop();
154
+ const node = typeof item === 'string' ? parseStructuredString(item) : item;
155
+ if (!node || typeof node !== 'object' || seen.has(node)) continue;
156
+ seen.add(node);
157
+ const descriptors = Object.getOwnPropertyDescriptors(node);
158
+ if (descriptors.__kind?.value === 'repro-omitted') {
159
+ result.push({ reason: descriptors.reason?.value, path: descriptors.path?.value, limit: descriptors.limit?.value,
160
+ originalCount: descriptors.originalCount?.value, capturedCount: descriptors.capturedCount?.value });
161
+ continue;
162
+ }
163
+ for (const item of Object.values(descriptors)) if ('value' in item) pending.push(item.value);
164
+ }
165
+ return result;
166
+ }
167
+
168
+ export function limitLiveArguments(args: any[]): any[] {
169
+ const values = args.slice(0, 100).map((arg, index) => limitLiveCapture(arg, `$[${index}]`).value);
170
+ if (args.length > 100) {
171
+ const prior = Object.getOwnPropertyDescriptor(args, '100')?.value;
172
+ values.push(args.length === 101 && prior && Object.getOwnPropertyDescriptor(prior, '__kind')?.value === 'repro-omitted'
173
+ ? limitLiveCapture(prior).value
174
+ : marker({ reason: 'max-entries', path: '$', limit: 100, originalCount: args.length, capturedCount: 100 }));
175
+ }
176
+ return values;
177
+ }
@@ -52,6 +52,12 @@ function method(value: unknown, key: string): boolean {
52
52
  }
53
53
  }
54
54
 
55
+ export function mongooseDocumentData(value: object): object | null {
56
+ const doc = Object.getOwnPropertyDescriptor(value, '_doc')?.value;
57
+ if (!doc || typeof doc !== 'object' || !Object.getOwnPropertyDescriptor(value, '$__')?.value) return null;
58
+ return method(value, '$__init') && method(value, '$toObject') ? doc : null;
59
+ }
60
+
55
61
  function primitiveStateValue(value: unknown): string | number | boolean | null | undefined {
56
62
  if (value === null) return null;
57
63
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
@@ -86,6 +92,7 @@ function projectConfigurationService(value: object, className: string): Semantic
86
92
 
87
93
  function projectLogger(value: object, className: string): SemanticProjection | null {
88
94
  if (!/(Logger|LoggerService)$/i.test(className)) return null;
95
+ if (!method(value, 'log') && !method(value, 'info')) return null;
89
96
  const descriptors = ownDataDescriptors(value);
90
97
  const context = primitiveStateValue(descriptors.context?.value);
91
98
  return semanticValue('logger', className, context !== undefined ? { context } : {});
@@ -95,6 +102,14 @@ function projectNestInfrastructure(value: object, className: string): SemanticPr
95
102
  if (!/(ModuleRef|ModulesContainer|InstanceWrapper|NestContainer|Injector|DiscoveryService|ExecutionContextHost|ArgumentsHost|HttpAdapterHost)$/i.test(className)) {
96
103
  return null;
97
104
  }
105
+ const signatures: Record<string, string[]> = {
106
+ ModuleRef: ['get', 'resolve'], ModulesContainer: ['get', 'set'],
107
+ InstanceWrapper: ['getInstanceByContextId'], NestContainer: ['getModules'],
108
+ Injector: ['loadInstance'], DiscoveryService: ['getProviders'],
109
+ ExecutionContextHost: ['switchToHttp', 'getArgs'], ArgumentsHost: ['switchToHttp', 'getArgs'],
110
+ HttpAdapterHost: ['listen$'],
111
+ };
112
+ if (!(signatures[className] ?? []).some(key => method(value, key))) return null;
98
113
  return semanticValue('nestjs-infrastructure', className);
99
114
  }
100
115
 
@@ -159,6 +174,20 @@ function projectTypeOrm(value: object, className: string): SemanticProjection |
159
174
  return null;
160
175
  }
161
176
 
177
+ function projectMongoInfrastructure(value: object, className: string): SemanticProjection | null {
178
+ const signatures: Record<string, string[]> = {
179
+ MongoClient: ['connect', 'db', 'close'],
180
+ ClientSession: ['startTransaction', 'endSession'],
181
+ ConnectionPool: ['checkOut', 'checkIn'],
182
+ NativeConnection: ['openUri', 'model', 'close'],
183
+ Schema: ['add', 'path', 'plugin'],
184
+ };
185
+ const required = signatures[className];
186
+ return required?.every(key => method(value, key))
187
+ ? semanticValue('mongodb-infrastructure', className)
188
+ : null;
189
+ }
190
+
162
191
  /**
163
192
  * Projects known runtime machinery into stable, non-recursive descriptions.
164
193
  * Unknown application values deliberately return null and remain fully captured.
@@ -176,6 +205,7 @@ export function projectSemanticInfrastructureValue(value: unknown): SemanticProj
176
205
  projectRedis(value, className) ||
177
206
  projectKafka(value, className) ||
178
207
  projectAmqp(value, className) ||
208
+ projectMongoInfrastructure(value, className) ||
179
209
  projectPostgres(value, className) ||
180
210
  projectTypeOrm(value, className)
181
211
  );
@@ -0,0 +1,100 @@
1
+ process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '2000';
2
+ process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
3
+ process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '200';
4
+ process.env.TRACE_IDLE_FLUSH_MS = '10';
5
+ process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
6
+
7
+ const assert = require('node:assert/strict');
8
+ const http = require('node:http');
9
+ const { GenericContainer, Wait } = require('testcontainers');
10
+ const { initRepro } = require('../dist');
11
+ const { flushIngestQueue } = require('../dist/ingest/client');
12
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
13
+
14
+ async function main() {
15
+ const events = [];
16
+ let rejectedOnce = false;
17
+ const sink = http.createServer((req, res) => {
18
+ const chunks = [];
19
+ req.on('data', chunk => chunks.push(chunk));
20
+ req.on('end', () => {
21
+ const batch = JSON.parse(Buffer.concat(chunks)).events;
22
+ if (!rejectedOnce && batch.some(event => event.event_type === 'backend_request')) {
23
+ rejectedOnce = true;
24
+ res.writeHead(503); res.end('{}'); return;
25
+ }
26
+ events.push(...batch);
27
+ res.writeHead(202); res.end('{}');
28
+ });
29
+ });
30
+ await new Promise(resolve => sink.listen(0, '127.0.0.1', resolve));
31
+ let mongo, app, polling;
32
+ let stopped = false, polls = 0, maxPollGap = 0;
33
+ try {
34
+ mongo = await new GenericContainer('mongo:7').withExposedPorts(27017)
35
+ .withWaitStrategy(Wait.forListeningPorts()).start();
36
+ const config = { tenantId: 'TENANT_busy', appId: 'APP_busy', appSecret: 'test',
37
+ privacy: { environment: 'dev', captureRaw: true },
38
+ ingestBase: `http://127.0.0.1:${sink.address().port}`,
39
+ tracing: { fullValueCapture: true, disableFunctionTypes: ['constructor'], logFunctionCalls: false } };
40
+ await initRepro(config);
41
+ app = await require('./fixtures/live-capture-limits-server').start(config,
42
+ `mongodb://${mongo.getHost()}:${mongo.getMappedPort(27017)}/busy`);
43
+ const base = `http://127.0.0.1:${app.server.address().port}`;
44
+ polling = (async () => {
45
+ let previous = Date.now();
46
+ while (!stopped) {
47
+ // Untagged, short requests also participate in the SDK's activity tracking.
48
+ const response = await fetch(`${base}/uncorrelated`);
49
+ await response.text();
50
+ maxPollGap = Math.max(maxPollGap, Date.now() - previous);
51
+ previous = Date.now();
52
+ polls++;
53
+ await sleep(20);
54
+ }
55
+ })();
56
+ for (const mode of ['live', 'staging']) {
57
+ const response = await fetch(`${base}/shipment?__repro_sid=S_busy_${mode}&__repro_aid=A_${mode}&__repro_mode=${mode}`);
58
+ assert.equal(response.status, 200);
59
+ const body = await response.json();
60
+ assert.equal(body.rows.length, 157, 'capture must not change business responses');
61
+ assert.equal(body.rows[156].id, 156);
62
+ }
63
+ const complete = () => ['live', 'staging'].every(mode => {
64
+ const scoped = events.filter(event => event.session_id === `S_busy_${mode}`);
65
+ return ['request_capture_start', 'backend_request', 'request_capture_manifest', 'trace_batch', 'db_change']
66
+ .every(type => scoped.some(event => event.event_type === type));
67
+ });
68
+ // Do not call flushIngestQueue: automatic delivery must work while traffic continues.
69
+ const deadline = Date.now() + 6000;
70
+ while (!complete() && Date.now() < deadline) await sleep(25);
71
+ assert(polls > 5, 'continuous traffic must overlap capture finalization');
72
+ assert(maxPollGap < 2000, `traffic must not accidentally create an idle window: ${maxPollGap}ms`);
73
+ assert(complete(), `uploads starved during short requests: ${JSON.stringify(events.map(e => [e.session_id, e.event_type]))}`);
74
+ assert(rejectedOnce, 'the test must exercise a real collector retry');
75
+ for (const mode of ['live', 'staging']) {
76
+ const scoped = events.filter(event => event.session_id === `S_busy_${mode}`);
77
+ const requests = scoped.filter(event => event.event_type === 'backend_request');
78
+ assert.equal(requests.length, 1, 'finish and close must not duplicate requests');
79
+ const request = requests[0].payload.request;
80
+ const values = scoped.flatMap(e => [...(e.payload.requestValues || []), ...(e.payload.traceValues || []), ...(e.payload.dbValues || [])]);
81
+ const body = values.find(v => v.id === request.respBodyValueCapture?.valueId)?.value || request.respBody;
82
+ assert.equal(body.rows.length, mode === 'live' ? 101 : 157);
83
+ const trace = scoped.flatMap(e => e.payload.trace || []).find(e => e.fn === 'inspectShipment' && e.type === 'enter');
84
+ assert(trace, 'instrumented function arguments must survive background delivery');
85
+ const args = values.find(v => v.id === trace.argsValueCapture?.valueId)?.value || trace.args;
86
+ assert.equal(args[0].rows.length, mode === 'live' ? 101 : 157);
87
+ assert(scoped.flatMap(e => e.payload.db || []).some(change => change.op === 'findOne'));
88
+ }
89
+ console.log(`continuous short HTTP traffic: complete live/staging captures delivered during ${polls} requests`);
90
+ } finally {
91
+ stopped = true;
92
+ if (polling) await polling;
93
+ if (app) await app.close();
94
+ await sleep(2100);
95
+ await flushIngestQueue();
96
+ await new Promise(resolve => sink.close(resolve));
97
+ if (mongo) await mongo.stop();
98
+ }
99
+ }
100
+ main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,21 @@
1
+ const express = require('express');
2
+ const mongoose = require('mongoose');
3
+ const { reproMiddleware, reproMongoosePlugin } = require('../../dist');
4
+
5
+ function inspectShipment(shipment) { return shipment; }
6
+ async function start(config, uri) {
7
+ const connection = await mongoose.createConnection(uri).asPromise();
8
+ const schema = new mongoose.Schema({ reference: String, rows: [Object] });
9
+ schema.plugin(reproMongoosePlugin, config);
10
+ const Shipment = connection.model('LiveCaptureShipment', schema);
11
+ await Shipment.create({ reference: 'SHIP-LIMIT', rows: Array.from({ length: 157 }, (_, id) => ({ id, status: 'available' })) });
12
+ const app = express();
13
+ app.use(reproMiddleware(config));
14
+ app.get('/shipment', async (_req, res) => {
15
+ const shipment = await Shipment.findOne({ reference: 'SHIP-LIMIT' }).lean().exec();
16
+ res.json(inspectShipment(shipment));
17
+ });
18
+ const server = await new Promise(resolve => { const server = app.listen(0, '127.0.0.1', () => resolve(server)); });
19
+ return { server, close: async () => { await new Promise(resolve => server.close(resolve)); await connection.close(); } };
20
+ }
21
+ module.exports = { start };
@@ -0,0 +1,41 @@
1
+ const assert = require('node:assert/strict');
2
+ const http = require('node:http');
3
+ const { once } = require('node:events');
4
+ const { enqueueIngestEntries, flushIngestQueue } = require('../dist/ingest/client');
5
+
6
+ async function main() {
7
+ let attempts = 0;
8
+ const accepted = [];
9
+ const terminalAttempts = new Map();
10
+ const app = http.createServer(async (req, res) => {
11
+ const chunks = []; for await (const chunk of req) chunks.push(chunk);
12
+ const body = JSON.parse(Buffer.concat(chunks));
13
+ const sid = body.events[0]?.session_id;
14
+ if (sid === 'S_quota_denied' || sid === 'S_deleted') {
15
+ terminalAttempts.set(sid, (terminalAttempts.get(sid) ?? 0) + 1);
16
+ res.writeHead(sid === 'S_deleted' ? 410 : 402); res.end('{}'); return;
17
+ }
18
+ attempts++;
19
+ if (attempts === 1) { res.writeHead(429, { 'Retry-After': '1' }); res.end('{}'); }
20
+ else { accepted.push(body); res.writeHead(202); res.end('{}'); }
21
+ });
22
+ app.listen(0, '127.0.0.1'); await once(app, 'listening');
23
+ try {
24
+ const cfg = { tenantId: 'T', appId: 'A', ingestBase: `http://127.0.0.1:${app.address().port}` };
25
+ enqueueIngestEntries(cfg, 'S_throttle', [{ request: { rid: 'R1', method: 'POST', url: '/shipment', status: 200 }, t: Date.now() }]);
26
+ await flushIngestQueue();
27
+ for (let i = 0; i < 8; i++) await flushIngestQueue();
28
+ assert.equal(attempts, 1, 'forced flushes must not hammer the collector or use up retries during cooldown');
29
+ await new Promise(resolve => setTimeout(resolve, 1100));
30
+ await flushIngestQueue();
31
+ assert.equal(attempts, 2);
32
+ assert.equal(accepted.flatMap(batch => batch.events).filter(event => event.event_type === 'backend_request').length, 1);
33
+ for (const sid of ['S_quota_denied', 'S_deleted']) {
34
+ enqueueIngestEntries(cfg, sid, [{ requestCaptureManifest: { requestRid: sid, closedAt: Date.now() }, t: Date.now() }]);
35
+ for (let attempt = 0; attempt < 4; attempt++) await flushIngestQueue();
36
+ assert.equal(terminalAttempts.get(sid), 1, 'terminal refusal must not retry critical manifests indefinitely');
37
+ }
38
+ console.log('HTTP 429 respects Retry-After and retains the exact pending request');
39
+ } finally { app.closeAllConnections(); await new Promise(resolve => app.close(resolve)); }
40
+ }
41
+ main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,86 @@
1
+ process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '20';
2
+ process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
3
+ process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
4
+ const assert = require('node:assert/strict');
5
+ const http = require('node:http');
6
+ const { once } = require('node:events');
7
+ const { GenericContainer, Wait } = require('testcontainers');
8
+ const { KafkaContainer } = require('@testcontainers/kafka');
9
+ const { Kafka, logLevel } = require('kafkajs');
10
+ const amqp = require('amqplib');
11
+ const { __reproTestHooks } = require('../dist');
12
+ const { flushIngestQueue } = require('../dist/ingest/client');
13
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
14
+
15
+ async function main() {
16
+ const posts = [];
17
+ const sink = http.createServer(async (req, res) => {
18
+ const buffers = [];
19
+ for await (const chunk of req) buffers.push(chunk);
20
+ posts.push(JSON.parse(Buffer.concat(buffers)));
21
+ res.writeHead(202); res.end('{}');
22
+ });
23
+ sink.listen(0, '127.0.0.1'); await once(sink, 'listening');
24
+ let kafkaContainer, rabbitContainer, consumer, producer, connection, channel;
25
+ let consumed = 0;
26
+ const rows = Array.from({ length: 157 }, (_, id) => ({ id, state: 'available' }));
27
+ const payload = Buffer.from(JSON.stringify({ rows }));
28
+ const cfg = { tenantId: 'TENANT_broker_limits', appId: 'APP_limits', appSecret: 'test',
29
+ privacy: { captureRaw: true, environment: 'dev' }, ingestBase: `http://127.0.0.1:${sink.address().port}` };
30
+ const headers = mode => ({ 'x-bug-session-id': `S_broker_${mode}`, 'x-bug-action-id': `A_${mode}`, 'x-repro-capture-mode': mode });
31
+ async function businessWork(buffer) {
32
+ await sleep(15);
33
+ const value = JSON.parse(buffer.toString());
34
+ assert.equal(value.rows.length, 157, 'instrumentation must not modify the delivered message');
35
+ assert.equal(value.rows[156].id, 156);
36
+ consumed++;
37
+ }
38
+ try {
39
+ kafkaContainer = await new KafkaContainer('confluentinc/cp-kafka:7.6.1').withKraft().start();
40
+ const kafka = new Kafka({ brokers: [`${kafkaContainer.getHost()}:${kafkaContainer.getMappedPort(9093)}`], logLevel: logLevel.NOTHING });
41
+ producer = kafka.producer(); consumer = kafka.consumer({ groupId: 'repro-live-limit-test' });
42
+ await producer.connect(); await consumer.connect();
43
+ const admin = kafka.admin(); await admin.connect();
44
+ try { await admin.createTopics({ topics: [{ topic: 'capture-limits', numPartitions: 1, replicationFactor: 1 }] }); }
45
+ finally { await admin.disconnect(); }
46
+ await consumer.subscribe({ topic: 'capture-limits', fromBeginning: true });
47
+ await consumer.run({ eachMessage: __reproTestHooks.wrapKafkaEachMessageHandlerForTest(
48
+ async ({ message }) => businessWork(message.value), cfg, { __repro_group_id: 'repro-live-limit-test' },
49
+ ) });
50
+ for (const mode of ['live', 'staging']) await producer.send({ topic: 'capture-limits', messages: [{ key: mode, value: payload, headers: headers(mode) }] });
51
+
52
+ rabbitContainer = await new GenericContainer('rabbitmq:3.13-alpine')
53
+ .withEnvironment({ RABBITMQ_DEFAULT_USER: 'repro', RABBITMQ_DEFAULT_PASS: 'test' })
54
+ .withExposedPorts(5672).withWaitStrategy(Wait.forLogMessage('Server startup complete')).start();
55
+ connection = await amqp.connect(`amqp://repro:test@${rabbitContainer.getHost()}:${rabbitContainer.getMappedPort(5672)}`);
56
+ channel = await connection.createChannel(); await channel.assertQueue('capture-limits', { durable: false });
57
+ await channel.consume('capture-limits', __reproTestHooks.wrapAmqplibConsumeHandlerForTest(async message => {
58
+ await businessWork(message.content); channel.ack(message);
59
+ }, cfg, 'capture-limits'));
60
+ for (const mode of ['live', 'staging']) channel.sendToQueue('capture-limits', payload, { headers: headers(mode) });
61
+ const deadline = Date.now() + 30_000;
62
+ let requests;
63
+ do {
64
+ await __reproTestHooks.drainSdkBackgroundQueueForTest(); await flushIngestQueue();
65
+ requests = posts.flatMap(post => post.events || []).filter(event => event.event_type === 'backend_request');
66
+ if (requests.length >= 4 && consumed === 4) break;
67
+ await sleep(50);
68
+ } while (Date.now() < deadline);
69
+ assert.equal(consumed, 4); assert.equal(requests.length, 4);
70
+ for (const method of ['KAFKA_CONSUME', 'AMQP_CONSUME']) for (const mode of ['live', 'staging']) {
71
+ const request = requests.find(event => event.session_id === `S_broker_${mode}` && event.payload.request.method === method)?.payload.request;
72
+ assert(request, `${method} ${mode} missing`);
73
+ assert.equal(request.body.value.rows.length, mode === 'live' ? 101 : 157, `${method} ${mode}`);
74
+ assert.equal(JSON.parse(request.body.rawValue).rows.length, mode === 'live' ? 101 : 157, 'raw JSON copy must not bypass live limits');
75
+ if (mode === 'live') assert.equal(request.body.value.rows[100].originalCount, 157);
76
+ }
77
+ console.log('real Kafka and RabbitMQ: live bounds after asynchronous consumers; staging and broker values unchanged');
78
+ } finally {
79
+ if (consumer) await consumer.disconnect(); if (producer) await producer.disconnect();
80
+ if (channel) await channel.close(); if (connection) await connection.close();
81
+ await __reproTestHooks.drainSdkBackgroundQueueForTest(); await flushIngestQueue();
82
+ sink.closeAllConnections(); await new Promise(resolve => sink.close(resolve));
83
+ if (rabbitContainer) await rabbitContainer.stop(); if (kafkaContainer) await kafkaContainer.stop();
84
+ }
85
+ }
86
+ main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,99 @@
1
+ const assert = require('node:assert/strict');
2
+ const { limitLiveCapture, limitLiveArguments, collectCaptureOmissions } = require('../dist/live-capture-limits');
3
+ const { projectSemanticInfrastructureValue } = require('../dist/semantic-serialization');
4
+ const { IncomingMessage } = require('node:http');
5
+ const { Socket } = require('node:net');
6
+ const mongoose = require('mongoose');
7
+
8
+ let getterCalls = 0;
9
+ const payload = { nested: { rows: Array.from({ length: 157 }, (_, id) => ({ id, secret: `value-${id}` })) } };
10
+ Object.defineProperty(payload, 'danger', { enumerable: true, get() { getterCalls++; throw new Error('not safe'); } });
11
+ payload.self = payload;
12
+ payload.alias = payload.nested;
13
+ const limited = limitLiveCapture(payload);
14
+ assert.equal(getterCalls, 0);
15
+ assert.equal(payload.nested.rows.length, 157);
16
+ assert.equal(limited.value.nested.rows.length, 101);
17
+ assert.equal(limited.value.nested.rows[99].id, 99);
18
+ assert.equal(limited.value.nested.rows[100].__kind, 'repro-omitted');
19
+ assert.equal(limited.value.nested.rows[100].originalCount, 157);
20
+ assert.equal(limited.value.alias.__reproCircularRef, '$["nested"]');
21
+ assert.equal(limited.value.self.__reproCircularRef, '$');
22
+ assert.equal(limited.omissions.length, 2);
23
+ assert.equal(limitLiveCapture(limited.value).value.nested.rows[100].originalCount, 157);
24
+ assert.equal(collectCaptureOmissions(limited.value).length, 2);
25
+
26
+ const native = limitLiveCapture({
27
+ map: new Map(Array.from({ length: 150 }, (_, i) => [{ id: i }, { value: i }])),
28
+ set: new Set(Array.from({ length: 101 }, (_, i) => i)),
29
+ bytes: new Uint8Array(130).fill(7),
30
+ }).value;
31
+ assert.deepEqual(native.map.entries[99], [{ id: 99 }, { value: 99 }].map(x => Object.assign(Object.create(null), x)));
32
+ assert.equal(native.map.entries[100].originalCount, 150);
33
+ assert.equal(native.set.values[100].originalCount, 101);
34
+ assert.equal(native.bytes.values[100].originalCount, 130);
35
+ const dataBuffer = new Uint8Array(200).fill(8).buffer;
36
+ const dataView = limitLiveCapture(new DataView(dataBuffer, 10, 140)).value;
37
+ assert.equal(dataView.values[99], 8);
38
+ assert.equal(dataView.values[100].originalCount, 140);
39
+ assert.equal(limitLiveCapture(dataBuffer).value.values[100].originalCount, 200);
40
+ const error = new Error('observed failure', { cause: { rows: payload.nested.rows } });
41
+ const capturedError = limitLiveCapture(error).value;
42
+ assert.equal(capturedError.message, 'observed failure');
43
+ assert.equal(capturedError.cause.rows[100].originalCount, 157);
44
+ const deep = {};
45
+ let node = deep;
46
+ for (let i = 0; i < 12; i++) node = node.next = { level: i + 1 };
47
+ const depth = limitLiveCapture(deep);
48
+ assert.equal(depth.omissions[0].reason, 'max-depth');
49
+ assert.equal(depth.omissions[0].limit, 10);
50
+ assert.equal(depth.omissions[0].path.split('["next"]').length - 1, 11);
51
+ const aliasTarget = { important: 'must remain visible through the shallower alias' };
52
+ node.next = aliasTarget;
53
+ deep.shallowAlias = aliasTarget;
54
+ assert.equal(limitLiveCapture(deep).value.shallowAlias.important, aliasTarget.important);
55
+ const wide = Object.fromEntries(Array.from({ length: 150 }, (_, i) => [`key${i}`, i]));
56
+ wide.__reproCapture = 'business field';
57
+ const width = limitLiveCapture(wide);
58
+ assert.equal(width.value.key99, 99);
59
+ assert.equal(width.value.key100, undefined);
60
+ assert.equal(width.omissions[0].originalCount, 151);
61
+ assert.equal(limitLiveCapture(width.value).omissions[0].originalCount, 151);
62
+ const tooManyArgs = Array.from({ length: 157 }, (_, i) => i);
63
+ assert.equal(limitLiveArguments(tooManyArgs)[100].originalCount, 157);
64
+ assert.equal(limitLiveArguments(limitLiveArguments(tooManyArgs))[100].originalCount, 157);
65
+ const rawJson = JSON.stringify({ rows: payload.nested.rows });
66
+ const boundedJson = limitLiveCapture(rawJson).value;
67
+ assert.equal(typeof boundedJson, 'string');
68
+ assert.equal(JSON.parse(boundedJson).rows[100].originalCount, 157);
69
+ assert.equal(collectCaptureOmissions(boundedJson)[0].originalCount, 157);
70
+ const smallJson = '{ "business": [1, 2, 3] }';
71
+ assert.equal(limitLiveCapture(smallJson).value, smallJson, 'unlimited strings retain exact formatting');
72
+ const MongoClient = class MongoClient { connect() {} db() {} close() {} };
73
+ const actualInfrastructure = new MongoClient();
74
+ actualInfrastructure.password = 'never-serialize-runtime-config';
75
+ assert.equal(limitLiveCapture(actualInfrastructure).value.category, 'mongodb-infrastructure');
76
+ assert.equal(limitLiveCapture(actualInfrastructure).value.password, undefined);
77
+ const unknownMongoClass = class MongoClient { constructor() { this.orderId = 'business'; } };
78
+ assert.equal(limitLiveCapture(new unknownMongoClass()).value.orderId, 'business');
79
+ const Logger = class Logger { constructor() { this.importantBusinessField = true; } };
80
+ const ModuleRef = class ModuleRef { constructor() { this.invoice = 'INV-1'; } };
81
+ assert.equal(projectSemanticInfrastructureValue(new Logger()), null);
82
+ assert.equal(projectSemanticInfrastructureValue(new ModuleRef()), null);
83
+ const collision = { $__: { business: true }, _doc: { id: 'BUSINESS_DOC' }, invoice: 'INV-2' };
84
+ assert.equal(limitLiveCapture(collision).value.invoice, 'INV-2', 'business property names must not trigger infrastructure removal');
85
+ const Shipment = mongoose.model('LiveLimitShipment', new mongoose.Schema({ status: String, rows: [Number] }));
86
+ const shipment = new Shipment({ status: 'received', rows: Array.from({ length: 157 }, (_, i) => i) });
87
+ assert.equal(limitLiveCapture(shipment).value.status, 'received');
88
+ assert.equal(limitLiveCapture(shipment).value.rows[100].originalCount, 157);
89
+ assert.equal(limitLiveCapture(shipment).value.$__, undefined);
90
+ const req = new IncomingMessage(new Socket());
91
+ req.headers = { 'x-shipment': 'SHIP_1' }; req.body = { rows: [1, 2, 3] };
92
+ Object.defineProperty(req, 'dangerous', { get() { throw new Error('must not execute'); } });
93
+ assert.equal(limitLiveCapture(req).value.headers['x-shipment'], 'SHIP_1');
94
+ assert.deepEqual(limitLiveCapture(req).value.body.rows, [1, 2, 3]);
95
+ req.socket.destroy();
96
+ const withPrototypeKey = JSON.parse('{"__proto__":{"polluted":true},"valid":1}');
97
+ assert.equal(limitLiveCapture(withPrototypeKey).value.__proto__.polluted, true);
98
+ assert.equal({}.polluted, undefined);
99
+ console.log('live capture limits: native collections, depth, keys, getters, references and metadata passed');