@reproapp/node-sdk 0.0.23 → 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 +786 -6
- package/dist/ingest/client.d.ts +1 -1
- package/dist/ingest/client.js +2 -1
- package/package.json +2 -2
- package/src/index.ts +879 -8
- package/src/ingest/client.ts +3 -2
- package/test/amqplib-integration.test.js +340 -0
package/src/ingest/client.ts
CHANGED
|
@@ -211,9 +211,9 @@ export const enqueueIngestEntries = (
|
|
|
211
211
|
sessionId: string,
|
|
212
212
|
entries: LegacyEntry[],
|
|
213
213
|
options: EnqueueIngestOptions = {},
|
|
214
|
-
):
|
|
214
|
+
): boolean => {
|
|
215
215
|
if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
|
|
216
|
-
return;
|
|
216
|
+
return false;
|
|
217
217
|
}
|
|
218
218
|
const priority = options.priority === 'high' ? 'high' : 'normal';
|
|
219
219
|
const batch: QueuedIngestBatch = {
|
|
@@ -246,6 +246,7 @@ export const enqueueIngestEntries = (
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
249
|
+
return ingestQueue.includes(batch);
|
|
249
250
|
};
|
|
250
251
|
|
|
251
252
|
export const flushIngestQueue = async (): Promise<void> => {
|
|
@@ -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
|
+
});
|