@reproapp/node-sdk 0.0.54 → 0.0.55

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/dist/index.js CHANGED
@@ -7870,7 +7870,7 @@ function scheduleAmqpPublishCapture(params) {
7870
7870
  url: params.path,
7871
7871
  path: params.path,
7872
7872
  status: params.status,
7873
- durMs: Date.now() - params.t0,
7873
+ durMs: params.durationMs,
7874
7874
  key: params.key,
7875
7875
  headers: {
7876
7876
  transport: 'amqp',
@@ -7890,7 +7890,7 @@ function scheduleAmqpPublishCapture(params) {
7890
7890
  entries: [{
7891
7891
  actionId: params.aid ?? null,
7892
7892
  request: requestPayload,
7893
- t: alignedNow(),
7893
+ t: params.completedAt,
7894
7894
  }],
7895
7895
  });
7896
7896
  post(params.cfg, params.sid, {
@@ -7952,6 +7952,9 @@ function patchAmqplibPublishMethod(channel, cfg, methodName) {
7952
7952
  if (finished)
7953
7953
  return;
7954
7954
  finished = true;
7955
+ // Capture queue and privacy processing time are not broker latency.
7956
+ const durationMs = Math.max(0, Date.now() - t0);
7957
+ const completedAt = alignedNow();
7955
7958
  scheduleAmqpPublishCapture({
7956
7959
  cfg,
7957
7960
  sid,
@@ -7967,7 +7970,8 @@ function patchAmqplibPublishMethod(channel, cfg, methodName) {
7967
7970
  content,
7968
7971
  options: nextOptions,
7969
7972
  span,
7970
- t0,
7973
+ durationMs,
7974
+ completedAt,
7971
7975
  status,
7972
7976
  error,
7973
7977
  result,
@@ -8100,6 +8104,8 @@ function wrapAmqplibConsumeHandler(onMessage, cfg, queue) {
8100
8104
  finished = true;
8101
8105
  if (!sid)
8102
8106
  return;
8107
+ const durationMs = Math.max(0, Date.now() - t0);
8108
+ const completedAt = alignedNow();
8103
8109
  const fields = message?.fields ?? {};
8104
8110
  const messagePayload = buildAmqpMessagePayload(message?.content, properties);
8105
8111
  const body = {
@@ -8129,7 +8135,7 @@ function wrapAmqplibConsumeHandler(onMessage, cfg, queue) {
8129
8135
  url: `amqp://queue/${queueName}`,
8130
8136
  path: `amqp://queue/${queueName}`,
8131
8137
  status: threw ? 500 : 200,
8132
- durMs: Date.now() - t0,
8138
+ durMs: durationMs,
8133
8139
  key: `AMQP_CONSUME ${queueName}`,
8134
8140
  headers: normalizeAmqpHeadersForPayload(headers),
8135
8141
  body: bodyMaterialization.value,
@@ -8140,7 +8146,7 @@ function wrapAmqplibConsumeHandler(onMessage, cfg, queue) {
8140
8146
  entries: [{
8141
8147
  actionId: aid ?? null,
8142
8148
  request: requestPayload,
8143
- t: alignedNow(),
8149
+ t: completedAt,
8144
8150
  }],
8145
8151
  });
8146
8152
  scheduleAmqpTraceFlush(cfg, amqpCtx, aid, consumeRid, rawTraceEvents, maskReq, captureScope);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reproapp/node-sdk",
3
- "version": "0.0.54",
3
+ "version": "0.0.55",
4
4
  "description": "Repro Nest SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,6 +13,7 @@
13
13
  "test:latency": "npm run build && node test/privacy-fetch-timeout.test.js && node test/capture-latency.testcontainers.test.js",
14
14
  "test:live-capture": "npm run build && node test/twelve-element-capture.testcontainers.test.js && node test/live-capture-limits.test.js && node test/ingest-throttle-retention.test.js && node test/live-capture-limits.testcontainers.test.js",
15
15
  "test:live-brokers": "npm run build && node test/live-broker-limits.testcontainers.test.js",
16
+ "test:amqp-timing": "npm run build && node test/amqplib-integration.test.js && node test/amqplib-timing.testcontainers.test.js",
16
17
  "build": "tsc -p tsconfig.json",
17
18
  "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
18
19
  "prepublishOnly": "npm run build",
package/src/index.ts CHANGED
@@ -9498,7 +9498,8 @@ function scheduleAmqpPublishCapture(params: {
9498
9498
  content: any;
9499
9499
  options: any;
9500
9500
  span: SpanContext | null;
9501
- t0: number;
9501
+ durationMs: number;
9502
+ completedAt: number;
9502
9503
  status: number;
9503
9504
  error?: any;
9504
9505
  result?: any;
@@ -9540,7 +9541,7 @@ function scheduleAmqpPublishCapture(params: {
9540
9541
  url: params.path,
9541
9542
  path: params.path,
9542
9543
  status: params.status,
9543
- durMs: Date.now() - params.t0,
9544
+ durMs: params.durationMs,
9544
9545
  key: params.key,
9545
9546
  headers: {
9546
9547
  transport: 'amqp',
@@ -9561,7 +9562,7 @@ function scheduleAmqpPublishCapture(params: {
9561
9562
  entries: [{
9562
9563
  actionId: params.aid ?? null,
9563
9564
  request: requestPayload,
9564
- t: alignedNow(),
9565
+ t: params.completedAt,
9565
9566
  }],
9566
9567
  });
9567
9568
  post(params.cfg, params.sid!, {
@@ -9634,6 +9635,9 @@ function patchAmqplibPublishMethod(
9634
9635
  const finish = (status: number, error?: any, result?: any) => {
9635
9636
  if (finished) return;
9636
9637
  finished = true;
9638
+ // Capture queue and privacy processing time are not broker latency.
9639
+ const durationMs = Math.max(0, Date.now() - t0);
9640
+ const completedAt = alignedNow();
9637
9641
  scheduleAmqpPublishCapture({
9638
9642
  cfg,
9639
9643
  sid,
@@ -9649,7 +9653,8 @@ function patchAmqplibPublishMethod(
9649
9653
  content,
9650
9654
  options: nextOptions,
9651
9655
  span,
9652
- t0,
9656
+ durationMs,
9657
+ completedAt,
9653
9658
  status,
9654
9659
  error,
9655
9660
  result,
@@ -9779,6 +9784,8 @@ function wrapAmqplibConsumeHandler(onMessage: any, cfg: AmqplibPatchConfig, queu
9779
9784
  if (finished) return;
9780
9785
  finished = true;
9781
9786
  if (!sid) return;
9787
+ const durationMs = Math.max(0, Date.now() - t0);
9788
+ const completedAt = alignedNow();
9782
9789
  const fields = message?.fields ?? {};
9783
9790
  const messagePayload = buildAmqpMessagePayload(message?.content, properties);
9784
9791
  const body = {
@@ -9814,7 +9821,7 @@ function wrapAmqplibConsumeHandler(onMessage: any, cfg: AmqplibPatchConfig, queu
9814
9821
  url: `amqp://queue/${queueName}`,
9815
9822
  path: `amqp://queue/${queueName}`,
9816
9823
  status: threw ? 500 : 200,
9817
- durMs: Date.now() - t0,
9824
+ durMs: durationMs,
9818
9825
  key: `AMQP_CONSUME ${queueName}`,
9819
9826
  headers: normalizeAmqpHeadersForPayload(headers),
9820
9827
  body: bodyMaterialization.value,
@@ -9826,7 +9833,7 @@ function wrapAmqplibConsumeHandler(onMessage: any, cfg: AmqplibPatchConfig, queu
9826
9833
  entries: [{
9827
9834
  actionId: aid ?? null,
9828
9835
  request: requestPayload,
9829
- t: alignedNow(),
9836
+ t: completedAt,
9830
9837
  }],
9831
9838
  });
9832
9839
  scheduleAmqpTraceFlush(cfg, amqpCtx, aid, consumeRid, rawTraceEvents, maskReq, captureScope);
@@ -0,0 +1,198 @@
1
+ process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '0';
2
+ process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
3
+ process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
4
+ process.env.TRACE_IDLE_FLUSH_MS = '0';
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 { once } = require('node:events');
10
+ const { GenericContainer, Wait } = require('testcontainers');
11
+ const amqp = require('amqplib');
12
+ const { __reproTestHooks: hooks } = require('../dist');
13
+ const { flushIngestQueue } = require('../dist/ingest/client');
14
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
15
+ const deferred = () => {
16
+ let resolve;
17
+ const promise = new Promise(done => { resolve = done; });
18
+ return { promise, resolve };
19
+ };
20
+ async function within(promise, label) {
21
+ let timer;
22
+ try {
23
+ return await Promise.race([promise, new Promise((_, reject) => {
24
+ timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), 10000);
25
+ })]);
26
+ } finally { clearTimeout(timer); }
27
+ }
28
+
29
+ async function main() {
30
+ const events = [], channels = [];
31
+ let rabbit, connection, release;
32
+ const sink = http.createServer(async (req, res) => {
33
+ const buffers = [];
34
+ for await (const chunk of req) buffers.push(chunk);
35
+ events.push(...(JSON.parse(Buffer.concat(buffers)).events || []));
36
+ res.writeHead(202); res.end('{}');
37
+ });
38
+ sink.listen(0, '127.0.0.1'); await once(sink, 'listening');
39
+ try {
40
+ rabbit = await new GenericContainer('rabbitmq:3.13-alpine')
41
+ .withEnvironment({ RABBITMQ_DEFAULT_USER: 'repro', RABBITMQ_DEFAULT_PASS: 'test' })
42
+ .withExposedPorts(5672).withWaitStrategy(Wait.forLogMessage('Server startup complete')).start();
43
+ connection = await amqp.connect(`amqp://repro:test@${rabbit.getHost()}:${rabbit.getMappedPort(5672)}`);
44
+ for (const mode of ['live', 'staging']) {
45
+ const sid = `S_amqp_timing_${mode}`;
46
+ const cfg = { tenantId: 'TENANT_timing', appId: 'APP_timing', appSecret: 'test',
47
+ privacy: { captureRaw: true, environment: 'dev' },
48
+ ingestBase: `http://127.0.0.1:${sink.address().port}`,
49
+ resolveContext: () => ({ sid, aid: `A_${mode}` }) };
50
+ const channel = await connection.createConfirmChannel(); channels.push(channel);
51
+ hooks.patchAmqplibChannelForTest(channel, cfg);
52
+ const queue = `timing-${mode}`;
53
+ await channel.assertQueue(queue, { durable: false });
54
+ const handled = deferred(), acknowledged = deferred(), gate = deferred(), started = deferred();
55
+ release = gate.resolve;
56
+ // Hold the real capture queue, not the broker or application callback.
57
+ hooks.scheduleSdkBackgroundWorkForTest(async () => {
58
+ started.resolve(); await gate.promise;
59
+ }, { priority: 'high' });
60
+ await within(started.promise, 'capture queue blocker');
61
+ let handlerStart, handlerEnd, publishEnd;
62
+ const input = { orderId: `order-${mode}`, lines: [{ sku: 'item', quantity: 3 }] };
63
+ const consumer = await channel.consume(queue, async message => {
64
+ handlerStart = Date.now();
65
+ assert.deepEqual(JSON.parse(message.content.toString()), input);
66
+ assert.equal(message.properties.headers['x-bug-session-id'], sid);
67
+ assert.equal(message.properties.headers['x-repro-capture-mode'], mode);
68
+ await sleep(60);
69
+ channel.ack(message);
70
+ handlerEnd = Date.now(); handled.resolve();
71
+ });
72
+ const publishStart = Date.now();
73
+ const options = { headers: { 'x-repro-capture-mode': mode }, persistent: false };
74
+ const result = hooks.runWithRequestContextForTest({ sid, aid: `A_${mode}`, captureScope: { liveCapture: mode === 'live' } },
75
+ () => channel.sendToQueue(queue, Buffer.from(JSON.stringify(input)), options, error => {
76
+ assert.ifError(error); publishEnd = Date.now(); acknowledged.resolve();
77
+ }));
78
+ assert.equal(typeof result, 'boolean', 'publish must preserve amqplib backpressure contract');
79
+ await within(Promise.all([handled.promise, acknowledged.promise]), 'broker confirm and consumer acknowledgement');
80
+ assert.equal(options.headers['x-bug-session-id'], undefined, 'caller options stay unchanged');
81
+ assert.equal(events.filter(e => e.session_id === sid && e.event_type === 'backend_request').length, 0);
82
+ await sleep(450);
83
+ const releasedAt = Date.now(); gate.resolve(); release = null;
84
+ const deadline = Date.now() + 10000;
85
+ let requests;
86
+ do {
87
+ await hooks.drainSdkBackgroundQueueForTest(); await flushIngestQueue();
88
+ requests = events.filter(e => e.session_id === sid && e.event_type === 'backend_request');
89
+ if (requests.length === 2) break;
90
+ await sleep(20);
91
+ } while (Date.now() < deadline);
92
+ assert.equal(requests.length, 2, 'exactly one publish and one consume, despite sendToQueue delegating to publish');
93
+ for (const [method, start, end] of [
94
+ ['AMQP_SEND_TO_QUEUE', publishStart, publishEnd],
95
+ ['AMQP_CONSUME', handlerStart, handlerEnd],
96
+ ]) {
97
+ const event = requests.find(e => e.payload.request.method === method);
98
+ assert(event, `${mode}: missing ${method}`);
99
+ const request = event.payload.request;
100
+ assert(request.durMs <= end - start + 30,
101
+ `${mode} ${method}: ${request.durMs}ms includes capture queue time; business took ${end - start}ms`);
102
+ assert(request.durMs >= end - start - 10, 'duration must still include the async business handler');
103
+ const timestamp = Date.parse(event.event_ts);
104
+ assert(timestamp <= end + 30 && timestamp >= start,
105
+ `event time must be operation completion, not background delivery: ${timestamp}, ${end}`);
106
+ assert(timestamp < releasedAt - 400);
107
+ assert.deepEqual(request.body.value, input, 'timing correction must not lose payload');
108
+ }
109
+ await channel.cancel(consumer.consumerTag);
110
+ console.log(`${mode}: broker confirms and async handler finish before capture drain; timings and payload exact`);
111
+ }
112
+ // Exercise failure/return contracts with real delivered envelopes. The
113
+ // adapter catches deliberate handler failures just as an application would.
114
+ const sid = 'S_amqp_failures';
115
+ const cfg = { tenantId: 'TENANT_timing', appId: 'APP_timing', appSecret: 'test',
116
+ privacy: { captureRaw: true, environment: 'dev' },
117
+ ingestBase: `http://127.0.0.1:${sink.address().port}`,
118
+ resolveContext: () => ({ sid, aid: 'A_failures' }) };
119
+ const raw = await connection.createChannel(); channels.push(raw);
120
+ await raw.assertQueue('timing-contracts', { durable: false });
121
+ const gate = deferred(), started = deferred(); release = gate.resolve;
122
+ hooks.scheduleSdkBackgroundWorkForTest(async () => { started.resolve(); await gate.promise; }, { priority: 'high' });
123
+ await within(started.promise, 'failure capture queue blocker');
124
+ const observations = new Map(), completed = deferred();
125
+ const identity = { result: 'unchanged' }, failure = new Error('application failure');
126
+ const wrapped = hooks.wrapAmqplibConsumeHandlerForTest(message => {
127
+ const { scenario } = JSON.parse(message.content.toString());
128
+ const observation = { start: Date.now(), calls: 1, scenario };
129
+ observations.set(scenario, observation);
130
+ const finish = () => {
131
+ observation.end = Date.now();
132
+ if (scenario.endsWith('error')) throw failure;
133
+ return identity;
134
+ };
135
+ return scenario.startsWith('async') ? sleep(60).then(finish) : finish();
136
+ }, cfg, 'timing-contracts');
137
+ let handled = 0;
138
+ const consumer = await raw.consume('timing-contracts', message => {
139
+ const { scenario } = JSON.parse(message.content.toString());
140
+ const complete = () => { raw.ack(message); if (++handled === 4) completed.resolve(); };
141
+ try {
142
+ const value = wrapped(message);
143
+ if (scenario.startsWith('async')) {
144
+ assert.equal(typeof value.then, 'function');
145
+ value.then(result => {
146
+ assert.strictEqual(result, identity); complete();
147
+ }, error => { assert.strictEqual(error, failure); complete(); });
148
+ } else { assert.strictEqual(value, identity); complete(); }
149
+ } catch (error) { assert.strictEqual(error, failure); complete(); }
150
+ });
151
+ for (const scenario of ['sync-success', 'async-success', 'sync-error', 'async-error']) {
152
+ raw.sendToQueue('timing-contracts', Buffer.from(JSON.stringify({ scenario })), { headers: {
153
+ 'x-bug-session-id': sid, 'x-bug-action-id': 'A_failures', 'x-repro-capture-mode': 'live',
154
+ } });
155
+ }
156
+ await within(completed.promise, 'handler return and error contracts');
157
+ const invalid = await connection.createConfirmChannel(); channels.push(invalid);
158
+ invalid.on('error', () => {});
159
+ hooks.patchAmqplibChannelForTest(invalid, cfg);
160
+ const rejected = deferred();
161
+ const rejectStart = Date.now(); let rejectEnd;
162
+ invalid.publish('missing-exchange-timing', 'route', Buffer.from('message'), {}, error => {
163
+ assert(error, 'broker must reject the nonexistent exchange');
164
+ rejectEnd = Date.now(); rejected.resolve();
165
+ });
166
+ await within(rejected.promise, 'broker rejection');
167
+ await sleep(450); gate.resolve(); release = null;
168
+ const deadline = Date.now() + 10000;
169
+ let requests;
170
+ do {
171
+ await hooks.drainSdkBackgroundQueueForTest(); await flushIngestQueue();
172
+ requests = events.filter(e => e.session_id === sid && e.event_type === 'backend_request');
173
+ if (requests.length === 5) break;
174
+ await sleep(20);
175
+ } while (Date.now() < deadline);
176
+ assert.equal(requests.length, 5, 'no retries/double captures on exceptions or broker channel close');
177
+ for (const event of requests) {
178
+ const request = event.payload.request;
179
+ const observation = request.method === 'AMQP_PUBLISH'
180
+ ? { start: rejectStart, end: rejectEnd, scenario: 'broker-error' }
181
+ : observations.get(request.body.value.scenario);
182
+ assert(observation);
183
+ assert.equal(request.status, observation.scenario.endsWith('error') ? 500 : 200);
184
+ assert(request.durMs <= observation.end - observation.start + 30);
185
+ assert(Date.parse(event.event_ts) <= observation.end + 30);
186
+ }
187
+ await raw.cancel(consumer.consumerTag);
188
+ console.log('sync/async return and error identity preserved; broker rejection and failure timestamps exclude capture drain');
189
+ } finally {
190
+ release?.();
191
+ for (const channel of channels) await channel.close().catch(() => {});
192
+ if (connection) await connection.close();
193
+ await hooks.drainSdkBackgroundQueueForTest(); await flushIngestQueue();
194
+ sink.closeAllConnections(); await new Promise(resolve => sink.close(resolve));
195
+ if (rabbit) await rabbit.stop();
196
+ }
197
+ }
198
+ main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -25,7 +25,9 @@ async function main() {
25
25
  const results = [];
26
26
  try {
27
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']) {
28
+ const variants = ['off', 'live-raw', 'staging-raw', 'live-policy', 'staging-policy', 'live-policy-fallback', 'staging-policy-fallback', 'staging-policy-fault']
29
+ .filter(variant => !process.argv.includes('--raw-only') || !variant.includes('policy'));
30
+ for (const variant of variants) {
29
31
  const mode = variant.split('-')[0];
30
32
  child = fork(require.resolve('./fixtures/capture-latency-server'), [], {
31
33
  env: { ...process.env, CAPTURE_TEST_MODE: mode,