@reproapp/node-sdk 0.0.24 → 0.0.25
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.d.ts +16 -0
- package/dist/index.js +780 -1
- package/package.json +2 -2
- package/src/index.ts +872 -2
- package/test/amqplib-integration.test.js +340 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
process.env.REPRO_INGEST_REQUEST_TIMEOUT_MS = '50';
|
|
2
|
+
process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '25';
|
|
3
|
+
process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
|
|
4
|
+
process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
|
|
5
|
+
process.env.TRACE_IDLE_FLUSH_MS = '10';
|
|
6
|
+
process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
|
|
7
|
+
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
const { flushIngestQueue } = require('../dist/ingest/client');
|
|
10
|
+
const { initReproTracing, __reproTestHooks } = require('../dist');
|
|
11
|
+
|
|
12
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
|
|
14
|
+
async function waitFor(predicate, timeoutMs = 2000) {
|
|
15
|
+
const deadline = Date.now() + timeoutMs;
|
|
16
|
+
while (Date.now() < deadline) {
|
|
17
|
+
if (predicate()) return;
|
|
18
|
+
await sleep(20);
|
|
19
|
+
}
|
|
20
|
+
assert(predicate(), 'timed out waiting for expected ingest payload');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function flattenEvents(posts) {
|
|
24
|
+
return posts.flatMap((body) => Array.isArray(body?.events) ? body.events : []);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function drainSdk() {
|
|
28
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest();
|
|
29
|
+
await flushIngestQueue();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function withCapturedFetch(fn) {
|
|
33
|
+
const capturedBodies = [];
|
|
34
|
+
const originalFetch = global.fetch;
|
|
35
|
+
global.fetch = async (_url, init) => {
|
|
36
|
+
capturedBodies.push(JSON.parse(String(init?.body ?? '{}')));
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
status: 202,
|
|
40
|
+
json: async () => ({ ok: true }),
|
|
41
|
+
text: async () => '{"ok":true}',
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await fn(capturedBodies);
|
|
47
|
+
} finally {
|
|
48
|
+
await drainSdk();
|
|
49
|
+
global.fetch = originalFetch;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeConfig(sessionId, actionId) {
|
|
54
|
+
return {
|
|
55
|
+
tenantId: 'TENANT_test',
|
|
56
|
+
appId: 'APP_test',
|
|
57
|
+
appSecret: 'secret',
|
|
58
|
+
captureHeaders: false,
|
|
59
|
+
privacy: { environment: 'dev' },
|
|
60
|
+
ingestBase: 'http://127.0.0.1:65535',
|
|
61
|
+
resolveContext: () => ({ sid: sessionId, aid: actionId }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function testPublishInjectsHeadersWithoutMutatingOptions() {
|
|
66
|
+
await withCapturedFetch(async (capturedBodies) => {
|
|
67
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
68
|
+
let observedPublish = null;
|
|
69
|
+
const channel = {
|
|
70
|
+
publish(exchange, routingKey, content, options) {
|
|
71
|
+
observedPublish = { exchange, routingKey, content, options };
|
|
72
|
+
return false;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const cfg = makeConfig('S_amqp_publish', 'A_amqp_publish');
|
|
76
|
+
__reproTestHooks.patchAmqplibChannelForTest(channel, cfg);
|
|
77
|
+
|
|
78
|
+
const originalOptions = {
|
|
79
|
+
contentType: 'application/json',
|
|
80
|
+
persistent: true,
|
|
81
|
+
headers: { existing: 'yes' },
|
|
82
|
+
};
|
|
83
|
+
const result = channel.publish(
|
|
84
|
+
'orders',
|
|
85
|
+
'created',
|
|
86
|
+
Buffer.from(JSON.stringify({ orderId: 'ord-1', status: 'created' })),
|
|
87
|
+
originalOptions,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
assert.strictEqual(result, false);
|
|
91
|
+
assert(observedPublish, 'expected original publish to run');
|
|
92
|
+
assert.notStrictEqual(observedPublish.options, originalOptions);
|
|
93
|
+
assert.strictEqual(originalOptions.headers['x-bug-session-id'], undefined);
|
|
94
|
+
assert.strictEqual(originalOptions.headers['x-repro-request-rid'], undefined);
|
|
95
|
+
assert.strictEqual(observedPublish.options.headers.existing, 'yes');
|
|
96
|
+
assert.strictEqual(observedPublish.options.headers['x-bug-session-id'], 'S_amqp_publish');
|
|
97
|
+
assert.strictEqual(observedPublish.options.headers['x-bug-action-id'], 'A_amqp_publish');
|
|
98
|
+
assert(observedPublish.options.headers['x-repro-request-rid']);
|
|
99
|
+
|
|
100
|
+
await waitFor(() => flattenEvents(capturedBodies)
|
|
101
|
+
.some((event) => event?.event_type === 'backend_request'));
|
|
102
|
+
await drainSdk();
|
|
103
|
+
|
|
104
|
+
const events = flattenEvents(capturedBodies);
|
|
105
|
+
const requestEvent = events.find((event) => event?.event_type === 'backend_request');
|
|
106
|
+
assert(requestEvent, 'expected AMQP publish backend request');
|
|
107
|
+
assert.strictEqual(requestEvent.payload.request.method, 'AMQP_PUBLISH');
|
|
108
|
+
assert.strictEqual(requestEvent.payload.request.path, 'amqp://orders/created');
|
|
109
|
+
assert.strictEqual(requestEvent.payload.request.headers.transport, 'amqp');
|
|
110
|
+
assert.strictEqual(requestEvent.payload.request.headers.backpressure, true);
|
|
111
|
+
assert.strictEqual(requestEvent.payload.request.body.message.value.orderId, 'ord-1');
|
|
112
|
+
assert.strictEqual(requestEvent.payload.request.body.message.value.status, 'created');
|
|
113
|
+
assert.strictEqual(requestEvent.payload.request.body.message.headers.existing, 'yes');
|
|
114
|
+
|
|
115
|
+
const manifestEvent = events.find((event) => event?.event_type === 'request_capture_manifest');
|
|
116
|
+
assert(manifestEvent, 'expected AMQP publish manifest');
|
|
117
|
+
assert.strictEqual(manifestEvent.payload.requestCaptureManifest.transport, 'amqp');
|
|
118
|
+
assert.strictEqual(manifestEvent.payload.requestCaptureManifest.expected.backendRequest, 1);
|
|
119
|
+
assert.strictEqual(manifestEvent.payload.requestCaptureManifest.expected.traceBatch, 0);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function testConsumeCapturesRequestTraceAndManifest() {
|
|
124
|
+
await withCapturedFetch(async (capturedBodies) => {
|
|
125
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
126
|
+
const tracer = initReproTracing({ instrument: false, functionLogs: false });
|
|
127
|
+
let registeredHandler = null;
|
|
128
|
+
const channel = {
|
|
129
|
+
consume(queue, onMessage, options) {
|
|
130
|
+
registeredHandler = { queue, onMessage, options };
|
|
131
|
+
return Promise.resolve({ consumerTag: 'consumer-1' });
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const cfg = makeConfig('S_unused_fallback', 'A_unused_fallback');
|
|
135
|
+
__reproTestHooks.patchAmqplibChannelForTest(channel, cfg);
|
|
136
|
+
|
|
137
|
+
const consumeResult = await channel.consume('jobs', async () => {
|
|
138
|
+
tracer.tracer.enter('amqpConsumerWork', { file: '/app/consumer.ts', line: 11 }, { args: ['ok'] });
|
|
139
|
+
tracer.tracer.exit(
|
|
140
|
+
{ fn: 'amqpConsumerWork', file: '/app/consumer.ts', line: 11 },
|
|
141
|
+
{ returnValue: 'done' },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
tracer.withTrace('parent-amqp-trace', () => {
|
|
145
|
+
tracer.tracer.enter('parentHttpWork', { file: '/app/controller.ts', line: 20 }, { args: ['wrong'] });
|
|
146
|
+
tracer.tracer.exit(
|
|
147
|
+
{ fn: 'parentHttpWork', file: '/app/controller.ts', line: 20 },
|
|
148
|
+
{ returnValue: 'wrong' },
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return 'handled';
|
|
153
|
+
}, { noAck: false });
|
|
154
|
+
|
|
155
|
+
assert.deepStrictEqual(consumeResult, { consumerTag: 'consumer-1' });
|
|
156
|
+
assert(registeredHandler, 'expected consume callback registration');
|
|
157
|
+
assert.strictEqual(registeredHandler.queue, 'jobs');
|
|
158
|
+
assert.deepStrictEqual(registeredHandler.options, { noAck: false });
|
|
159
|
+
|
|
160
|
+
const handlerResult = await registeredHandler.onMessage({
|
|
161
|
+
fields: {
|
|
162
|
+
consumerTag: 'consumer-1',
|
|
163
|
+
deliveryTag: 7,
|
|
164
|
+
exchange: 'orders',
|
|
165
|
+
routingKey: 'created',
|
|
166
|
+
redelivered: false,
|
|
167
|
+
},
|
|
168
|
+
properties: {
|
|
169
|
+
contentType: 'application/json',
|
|
170
|
+
messageId: 'msg-1',
|
|
171
|
+
correlationId: 'corr-1',
|
|
172
|
+
headers: {
|
|
173
|
+
'x-bug-session-id': Buffer.from('S_amqp_consume'),
|
|
174
|
+
'x-bug-action-id': Buffer.from('A_amqp_consume'),
|
|
175
|
+
'x-repro-trace-id': Buffer.from('parent-amqp-trace'),
|
|
176
|
+
'x-repro-request-rid': Buffer.from('parent-request-rid'),
|
|
177
|
+
'x-repro-span-id': Buffer.from('217'),
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
content: Buffer.from(JSON.stringify({
|
|
181
|
+
eventId: 'evt-amqp-consumed',
|
|
182
|
+
payload: { marker: 'FULL_AMQP_CONSUMED_VALUE_MARKER' },
|
|
183
|
+
})),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
assert.strictEqual(handlerResult, 'handled');
|
|
187
|
+
await waitFor(() => flattenEvents(capturedBodies)
|
|
188
|
+
.some((event) => event?.event_type === 'trace_batch'));
|
|
189
|
+
await drainSdk();
|
|
190
|
+
|
|
191
|
+
const events = flattenEvents(capturedBodies);
|
|
192
|
+
const consumeRequest = events.find((event) => event.event_type === 'backend_request');
|
|
193
|
+
assert(consumeRequest, 'expected AMQP consume request event');
|
|
194
|
+
assert.strictEqual(consumeRequest.payload.request.method, 'AMQP_CONSUME');
|
|
195
|
+
assert.strictEqual(consumeRequest.payload.request.path, 'amqp://queue/jobs');
|
|
196
|
+
assert.strictEqual(consumeRequest.payload.request.body.parentTraceId, 'parent-amqp-trace');
|
|
197
|
+
assert.strictEqual(consumeRequest.payload.request.body.parentRequestRid, 'parent-request-rid');
|
|
198
|
+
assert.strictEqual(consumeRequest.payload.request.body.value.eventId, 'evt-amqp-consumed');
|
|
199
|
+
assert.strictEqual(
|
|
200
|
+
consumeRequest.payload.request.body.value.payload.marker,
|
|
201
|
+
'FULL_AMQP_CONSUMED_VALUE_MARKER',
|
|
202
|
+
);
|
|
203
|
+
assert.strictEqual(consumeRequest.payload.request.body.properties.messageId, 'msg-1');
|
|
204
|
+
|
|
205
|
+
const traceFns = events
|
|
206
|
+
.filter((event) => event.event_type === 'trace_batch')
|
|
207
|
+
.flatMap((event) => Array.isArray(event.payload.trace) ? event.payload.trace : [])
|
|
208
|
+
.map((traceEvent) => traceEvent.fn);
|
|
209
|
+
assert(traceFns.includes('amqpConsumerWork'), traceFns.join(','));
|
|
210
|
+
assert(!traceFns.includes('parentHttpWork'), traceFns.join(','));
|
|
211
|
+
|
|
212
|
+
const manifestEvent = events.find((event) => event?.event_type === 'request_capture_manifest');
|
|
213
|
+
assert(manifestEvent, 'expected AMQP consume manifest');
|
|
214
|
+
assert.strictEqual(manifestEvent.payload.requestCaptureManifest.transport, 'amqp');
|
|
215
|
+
assert.strictEqual(manifestEvent.payload.requestCaptureManifest.expected.backendRequest, 1);
|
|
216
|
+
assert(manifestEvent.payload.requestCaptureManifest.expected.traceBatch >= 1);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function testCallbackApiConnectAndConfirmChannelArePatched() {
|
|
221
|
+
await withCapturedFetch(async (capturedBodies) => {
|
|
222
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
223
|
+
let observedPublish = null;
|
|
224
|
+
const confirmChannel = {
|
|
225
|
+
publish(exchange, routingKey, content, options, callback) {
|
|
226
|
+
observedPublish = { exchange, routingKey, content, options };
|
|
227
|
+
setImmediate(() => callback(null, { ok: true }));
|
|
228
|
+
return true;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const connection = {
|
|
232
|
+
createConfirmChannel(callback) {
|
|
233
|
+
callback(null, confirmChannel);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
const amqpModule = {
|
|
237
|
+
connect(_url, callback) {
|
|
238
|
+
callback(null, connection);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
const cfg = makeConfig('S_amqp_callback', 'A_amqp_callback');
|
|
242
|
+
__reproTestHooks.patchAmqplibModuleForTest(amqpModule, cfg);
|
|
243
|
+
|
|
244
|
+
const channel = await new Promise((resolve, reject) => {
|
|
245
|
+
amqpModule.connect('amqp://localhost', (connectError, conn) => {
|
|
246
|
+
if (connectError) {
|
|
247
|
+
reject(connectError);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
conn.createConfirmChannel((channelError, ch) => {
|
|
251
|
+
if (channelError) reject(channelError);
|
|
252
|
+
else resolve(ch);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await new Promise((resolve, reject) => {
|
|
258
|
+
channel.publish(
|
|
259
|
+
'events',
|
|
260
|
+
'confirmed',
|
|
261
|
+
Buffer.from('hello'),
|
|
262
|
+
{ headers: { existing: 'yes' } },
|
|
263
|
+
(error) => {
|
|
264
|
+
if (error) reject(error);
|
|
265
|
+
else resolve();
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
assert(observedPublish, 'expected confirm publish to run');
|
|
271
|
+
assert.strictEqual(observedPublish.options.headers.existing, 'yes');
|
|
272
|
+
assert.strictEqual(observedPublish.options.headers['x-bug-session-id'], 'S_amqp_callback');
|
|
273
|
+
assert.strictEqual(observedPublish.options.headers['x-bug-action-id'], 'A_amqp_callback');
|
|
274
|
+
assert(observedPublish.options.headers['x-repro-request-rid']);
|
|
275
|
+
|
|
276
|
+
await waitFor(() => flattenEvents(capturedBodies)
|
|
277
|
+
.some((event) => event?.event_type === 'backend_request'));
|
|
278
|
+
await drainSdk();
|
|
279
|
+
|
|
280
|
+
const requestEvent = flattenEvents(capturedBodies)
|
|
281
|
+
.find((event) => event?.event_type === 'backend_request');
|
|
282
|
+
assert(requestEvent, 'expected callback API AMQP publish event');
|
|
283
|
+
assert.strictEqual(requestEvent.payload.request.method, 'AMQP_PUBLISH');
|
|
284
|
+
assert.strictEqual(requestEvent.payload.request.path, 'amqp://events/confirmed');
|
|
285
|
+
assert.strictEqual(requestEvent.payload.request.body.message.value, 'hello');
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function testNullConsumeMessagePassesThrough() {
|
|
290
|
+
let received = 'not-called';
|
|
291
|
+
const wrapped = __reproTestHooks.wrapAmqplibConsumeHandlerForTest((message) => {
|
|
292
|
+
received = message;
|
|
293
|
+
return 'cancelled';
|
|
294
|
+
}, makeConfig('S_null', 'A_null'), 'jobs');
|
|
295
|
+
|
|
296
|
+
const result = wrapped(null);
|
|
297
|
+
assert.strictEqual(result, 'cancelled');
|
|
298
|
+
assert.strictEqual(received, null);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function testSyncConsumeThrowIsNotRetried() {
|
|
302
|
+
initReproTracing({ instrument: false, functionLogs: false });
|
|
303
|
+
let calls = 0;
|
|
304
|
+
const expectedError = new Error('consume failed once');
|
|
305
|
+
const wrapped = __reproTestHooks.wrapAmqplibConsumeHandlerForTest(() => {
|
|
306
|
+
calls += 1;
|
|
307
|
+
throw expectedError;
|
|
308
|
+
}, {
|
|
309
|
+
tenantId: 'TENANT_test',
|
|
310
|
+
appId: 'APP_test',
|
|
311
|
+
appSecret: 'secret',
|
|
312
|
+
captureHeaders: false,
|
|
313
|
+
privacy: { environment: 'dev' },
|
|
314
|
+
}, 'jobs');
|
|
315
|
+
|
|
316
|
+
assert.throws(() => wrapped({
|
|
317
|
+
fields: { deliveryTag: 99 },
|
|
318
|
+
properties: {
|
|
319
|
+
headers: {
|
|
320
|
+
'x-repro-trace-id': Buffer.from('parent-trace'),
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
content: Buffer.from('hello'),
|
|
324
|
+
}), expectedError);
|
|
325
|
+
assert.strictEqual(calls, 1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function main() {
|
|
329
|
+
await testPublishInjectsHeadersWithoutMutatingOptions();
|
|
330
|
+
await testConsumeCapturesRequestTraceAndManifest();
|
|
331
|
+
await testCallbackApiConnectAndConfirmChannelArePatched();
|
|
332
|
+
await testNullConsumeMessagePassesThrough();
|
|
333
|
+
await testSyncConsumeThrowIsNotRetried();
|
|
334
|
+
console.log('amqplib integration OK');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
main().catch((error) => {
|
|
338
|
+
console.error(error);
|
|
339
|
+
process.exitCode = 1;
|
|
340
|
+
});
|