@reproapp/node-sdk 0.0.39 → 0.0.42
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/README.md +4 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +592 -2
- package/dist/ingest/client.d.ts +1 -1
- package/dist/ingest/client.js +156 -8
- package/dist/ingest/mapper.js +2 -0
- package/dist/ingest/types.d.ts +1 -0
- package/package.json +10 -4
- package/src/index.ts +651 -1
- package/src/ingest/client.ts +187 -10
- package/src/ingest/mapper.ts +1 -0
- package/src/ingest/types.ts +1 -0
- package/test/backend-live-mode.test.js +20 -2
- package/test/client-performance-regression.test.js +5 -0
- package/test/ingest-batching.test.js +67 -0
- package/test/postgresql-integration.test.js +302 -0
- package/test/postgresql-integrations.testcontainers.test.js +261 -0
- package/test/request-capture-background.test.js +6 -1
- package/test/request-manifest-priority.test.js +6 -0
package/src/ingest/client.ts
CHANGED
|
@@ -13,6 +13,24 @@ const DEFAULT_QUEUE_MAX_ITEMS = (() => {
|
|
|
13
13
|
const DEFAULT_QUEUE_MAX_ATTEMPTS = 5;
|
|
14
14
|
const DEFAULT_QUEUE_BACKOFF_MAX_MS = 4000;
|
|
15
15
|
const DEFAULT_DEFER_DRAIN_MS = 25;
|
|
16
|
+
const DEFAULT_BATCH_MAX_ENTRIES = (() => {
|
|
17
|
+
const env = Number(typeof process !== 'undefined' ? (process as any)?.env?.REPRO_INGEST_BATCH_MAX_ENTRIES : undefined);
|
|
18
|
+
if (Number.isFinite(env) && env > 0) {
|
|
19
|
+
return Math.trunc(env);
|
|
20
|
+
}
|
|
21
|
+
return 100;
|
|
22
|
+
})();
|
|
23
|
+
const DEFAULT_BATCH_MAX_SERIALIZED_BYTES = (() => {
|
|
24
|
+
const env = Number(
|
|
25
|
+
typeof process !== 'undefined'
|
|
26
|
+
? (process as any)?.env?.REPRO_INGEST_BATCH_MAX_SERIALIZED_BYTES
|
|
27
|
+
: undefined,
|
|
28
|
+
);
|
|
29
|
+
if (Number.isFinite(env) && env > 0) {
|
|
30
|
+
return Math.trunc(env);
|
|
31
|
+
}
|
|
32
|
+
return 1024 * 1024;
|
|
33
|
+
})();
|
|
16
34
|
const DEFAULT_INGEST_REQUEST_TIMEOUT_MS = (() => {
|
|
17
35
|
const env = Number(typeof process !== 'undefined' ? (process as any)?.env?.REPRO_INGEST_REQUEST_TIMEOUT_MS : undefined);
|
|
18
36
|
if (Number.isFinite(env) && env >= 0) {
|
|
@@ -26,17 +44,21 @@ type QueuedIngestBatch = {
|
|
|
26
44
|
sessionId: string;
|
|
27
45
|
entries: LegacyEntry[];
|
|
28
46
|
attempts: number;
|
|
29
|
-
priority: 'normal' | 'high';
|
|
47
|
+
priority: 'normal' | 'high' | 'critical';
|
|
30
48
|
};
|
|
31
49
|
|
|
32
50
|
export type EnqueueIngestOptions = {
|
|
33
|
-
priority?: 'normal' | 'high';
|
|
51
|
+
priority?: 'normal' | 'high' | 'critical';
|
|
34
52
|
};
|
|
35
53
|
|
|
36
54
|
const ingestQueue: QueuedIngestBatch[] = [];
|
|
55
|
+
const criticalIngestQueue: QueuedIngestBatch[] = [];
|
|
37
56
|
let drainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
let criticalDrainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
58
|
let draining = false;
|
|
59
|
+
let criticalDraining = false;
|
|
39
60
|
let backoffMs = 0;
|
|
61
|
+
let criticalBackoffMs = 0;
|
|
40
62
|
let shouldDeferDrain: (() => boolean) | null = null;
|
|
41
63
|
let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
|
|
42
64
|
|
|
@@ -51,12 +73,16 @@ export const configureIngestQueueDrain = (options: {
|
|
|
51
73
|
};
|
|
52
74
|
|
|
53
75
|
export const kickIngestQueueDrain = (): void => {
|
|
76
|
+
if (criticalIngestQueue.length > 0 && !criticalDrainTimer) {
|
|
77
|
+
scheduleCriticalDrain();
|
|
78
|
+
}
|
|
54
79
|
if (ingestQueue.length > 0 && !drainTimer) {
|
|
55
80
|
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
56
81
|
}
|
|
57
82
|
};
|
|
58
83
|
|
|
59
|
-
const
|
|
84
|
+
const isHighPriorityIngestEntry = (entry: LegacyEntry): boolean => (
|
|
85
|
+
entry.requestCaptureStart !== undefined ||
|
|
60
86
|
entry.requestCaptureManifest !== undefined ||
|
|
61
87
|
entry.sessionCaptureManifest !== undefined ||
|
|
62
88
|
entry.request !== undefined ||
|
|
@@ -69,10 +95,89 @@ const resolveBatchPriority = (
|
|
|
69
95
|
entries: LegacyEntry[],
|
|
70
96
|
options: EnqueueIngestOptions,
|
|
71
97
|
): QueuedIngestBatch['priority'] => {
|
|
72
|
-
if (options.priority === 'high') {
|
|
73
|
-
return
|
|
98
|
+
if (options.priority === 'high' || options.priority === 'critical') {
|
|
99
|
+
return options.priority;
|
|
100
|
+
}
|
|
101
|
+
return entries.some(isHighPriorityIngestEntry) ? 'high' : 'normal';
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const sameIngestDestination = (
|
|
105
|
+
left: QueuedIngestBatch,
|
|
106
|
+
right: QueuedIngestBatch,
|
|
107
|
+
): boolean => (
|
|
108
|
+
left.sessionId === right.sessionId &&
|
|
109
|
+
left.priority === right.priority &&
|
|
110
|
+
left.config.appId === right.config.appId &&
|
|
111
|
+
left.config.tenantId === right.config.tenantId &&
|
|
112
|
+
left.config.appSecret === right.config.appSecret &&
|
|
113
|
+
left.config.appName === right.config.appName &&
|
|
114
|
+
left.config.serviceName === right.config.serviceName &&
|
|
115
|
+
left.config.ingestBase === right.config.ingestBase &&
|
|
116
|
+
left.config.ingestPath === right.config.ingestPath &&
|
|
117
|
+
left.config.actorId === right.config.actorId &&
|
|
118
|
+
left.config.actorType === right.config.actorType &&
|
|
119
|
+
JSON.stringify(left.config.actorLabels ?? null) ===
|
|
120
|
+
JSON.stringify(right.config.actorLabels ?? null) &&
|
|
121
|
+
left.config.source === right.config.source &&
|
|
122
|
+
left.config.schemaVersion === right.config.schemaVersion
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const serializedBytes = (value: unknown): number => {
|
|
126
|
+
try {
|
|
127
|
+
return Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
128
|
+
} catch {
|
|
129
|
+
return DEFAULT_BATCH_MAX_SERIALIZED_BYTES + 1;
|
|
74
130
|
}
|
|
75
|
-
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const selectSendBatch = (
|
|
134
|
+
queue: QueuedIngestBatch[],
|
|
135
|
+
): { batch: QueuedIngestBatch; queueItems: number } | null => {
|
|
136
|
+
const first = queue[0];
|
|
137
|
+
if (!first) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (first.priority === 'normal') {
|
|
141
|
+
return { batch: first, queueItems: 1 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const entries = first.entries.slice();
|
|
145
|
+
let bytes = serializedBytes(entries);
|
|
146
|
+
let queueItems = 1;
|
|
147
|
+
for (
|
|
148
|
+
let index = 1;
|
|
149
|
+
index < queue.length && entries.length < DEFAULT_BATCH_MAX_ENTRIES;
|
|
150
|
+
index += 1
|
|
151
|
+
) {
|
|
152
|
+
const candidate = queue[index];
|
|
153
|
+
if (!candidate || !sameIngestDestination(first, candidate)) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (entries.length + candidate.entries.length > DEFAULT_BATCH_MAX_ENTRIES) {
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
const candidateBytes = serializedBytes(candidate.entries);
|
|
160
|
+
if (bytes + candidateBytes > DEFAULT_BATCH_MAX_SERIALIZED_BYTES) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
entries.push(...candidate.entries);
|
|
164
|
+
bytes += candidateBytes;
|
|
165
|
+
queueItems += 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (queueItems === 1) {
|
|
169
|
+
return { batch: first, queueItems };
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
batch: {
|
|
173
|
+
...first,
|
|
174
|
+
entries,
|
|
175
|
+
attempts: Math.max(
|
|
176
|
+
...queue.slice(0, queueItems).map((item) => item.attempts),
|
|
177
|
+
),
|
|
178
|
+
},
|
|
179
|
+
queueItems,
|
|
180
|
+
};
|
|
76
181
|
};
|
|
77
182
|
|
|
78
183
|
const resolveIngestUrl = (config: IngestClientConfig): string => {
|
|
@@ -180,6 +285,59 @@ const scheduleDrain = (delayMs = 0): void => {
|
|
|
180
285
|
} catch {}
|
|
181
286
|
};
|
|
182
287
|
|
|
288
|
+
const scheduleCriticalDrain = (delayMs = 0): void => {
|
|
289
|
+
if (criticalDrainTimer) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
criticalDrainTimer = setTimeout(() => {
|
|
293
|
+
criticalDrainTimer = null;
|
|
294
|
+
void drainCriticalIngestQueue();
|
|
295
|
+
}, Math.max(0, delayMs));
|
|
296
|
+
try {
|
|
297
|
+
(criticalDrainTimer as any).unref?.();
|
|
298
|
+
} catch {}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const drainCriticalIngestQueue = async (): Promise<void> => {
|
|
302
|
+
if (criticalDraining) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
criticalDraining = true;
|
|
306
|
+
try {
|
|
307
|
+
while (criticalIngestQueue.length > 0) {
|
|
308
|
+
const selected = selectSendBatch(criticalIngestQueue);
|
|
309
|
+
if (!selected) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const ok = await sendIngestEntries(
|
|
313
|
+
selected.batch.config,
|
|
314
|
+
selected.batch.sessionId,
|
|
315
|
+
selected.batch.entries,
|
|
316
|
+
);
|
|
317
|
+
if (ok) {
|
|
318
|
+
criticalIngestQueue.splice(0, selected.queueItems);
|
|
319
|
+
criticalBackoffMs = 0;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (let index = 0; index < selected.queueItems; index += 1) {
|
|
324
|
+
criticalIngestQueue[index].attempts += 1;
|
|
325
|
+
}
|
|
326
|
+
criticalBackoffMs = Math.min(
|
|
327
|
+
DEFAULT_QUEUE_BACKOFF_MAX_MS,
|
|
328
|
+
criticalBackoffMs ? criticalBackoffMs * 2 : 250,
|
|
329
|
+
);
|
|
330
|
+
scheduleCriticalDrain(criticalBackoffMs);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
criticalDraining = false;
|
|
335
|
+
if (criticalIngestQueue.length > 0 && !criticalDrainTimer) {
|
|
336
|
+
scheduleCriticalDrain(criticalBackoffMs);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
183
341
|
const drainIngestQueue = async (): Promise<void> => {
|
|
184
342
|
if (draining) {
|
|
185
343
|
return;
|
|
@@ -195,16 +353,25 @@ const drainIngestQueue = async (): Promise<void> => {
|
|
|
195
353
|
scheduleDrain(deferDrainMs);
|
|
196
354
|
return;
|
|
197
355
|
}
|
|
198
|
-
const
|
|
356
|
+
const selected = selectSendBatch(ingestQueue);
|
|
357
|
+
if (!selected) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const batch = selected.batch;
|
|
199
361
|
const ok = await sendIngestEntries(batch.config, batch.sessionId, batch.entries);
|
|
200
362
|
if (ok) {
|
|
201
|
-
ingestQueue.
|
|
363
|
+
ingestQueue.splice(0, selected.queueItems);
|
|
202
364
|
backoffMs = 0;
|
|
203
365
|
continue;
|
|
204
366
|
}
|
|
205
367
|
|
|
206
|
-
|
|
207
|
-
|
|
368
|
+
for (let index = 0; index < selected.queueItems; index += 1) {
|
|
369
|
+
ingestQueue[index].attempts += 1;
|
|
370
|
+
}
|
|
371
|
+
if (
|
|
372
|
+
ingestQueue[0].attempts >= DEFAULT_QUEUE_MAX_ATTEMPTS &&
|
|
373
|
+
ingestQueue[0].priority === 'normal'
|
|
374
|
+
) {
|
|
208
375
|
ingestQueue.shift();
|
|
209
376
|
backoffMs = 0;
|
|
210
377
|
continue;
|
|
@@ -242,6 +409,11 @@ export const enqueueIngestEntries = (
|
|
|
242
409
|
attempts: 0,
|
|
243
410
|
priority,
|
|
244
411
|
};
|
|
412
|
+
if (priority === 'critical') {
|
|
413
|
+
criticalIngestQueue.push(batch);
|
|
414
|
+
scheduleCriticalDrain();
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
245
417
|
if (priority === 'high') {
|
|
246
418
|
const firstNormalIndex = ingestQueue.findIndex((queued) => queued.priority !== 'high');
|
|
247
419
|
if (firstNormalIndex === -1) {
|
|
@@ -272,10 +444,15 @@ export const enqueueIngestEntries = (
|
|
|
272
444
|
};
|
|
273
445
|
|
|
274
446
|
export const flushIngestQueue = async (): Promise<void> => {
|
|
447
|
+
if (criticalDrainTimer) {
|
|
448
|
+
clearTimeout(criticalDrainTimer);
|
|
449
|
+
criticalDrainTimer = null;
|
|
450
|
+
}
|
|
275
451
|
if (drainTimer) {
|
|
276
452
|
clearTimeout(drainTimer);
|
|
277
453
|
drainTimer = null;
|
|
278
454
|
}
|
|
455
|
+
await drainCriticalIngestQueue();
|
|
279
456
|
await drainIngestQueue();
|
|
280
457
|
};
|
|
281
458
|
|
package/src/ingest/mapper.ts
CHANGED
|
@@ -7,6 +7,7 @@ const toIso = (timestamp: unknown): string => {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
const inferEventType = (entry: LegacyEntry): string => {
|
|
10
|
+
if (entry.requestCaptureStart !== undefined) return 'request_capture_start';
|
|
10
11
|
if (entry.requestCaptureManifest !== undefined) return 'request_capture_manifest';
|
|
11
12
|
if (entry.sessionCaptureManifest !== undefined) return 'session_capture_manifest';
|
|
12
13
|
if (entry.request !== undefined) return 'backend_request';
|
package/src/ingest/types.ts
CHANGED
|
@@ -262,8 +262,18 @@ async function main() {
|
|
|
262
262
|
.filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
|
|
263
263
|
.flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
|
|
264
264
|
return claims.length >= 1 &&
|
|
265
|
-
events.some((event) =>
|
|
266
|
-
|
|
265
|
+
events.some((event) =>
|
|
266
|
+
event.event_type === 'request_capture_start' &&
|
|
267
|
+
event.session_id === 'S_claim_child_actor-1'
|
|
268
|
+
) &&
|
|
269
|
+
events.some((event) =>
|
|
270
|
+
event.event_type === 'backend_request' &&
|
|
271
|
+
event.session_id === 'S_claim_child_actor-1'
|
|
272
|
+
) &&
|
|
273
|
+
events.some((event) =>
|
|
274
|
+
event.event_type === 'request_capture_manifest' &&
|
|
275
|
+
event.session_id === 'S_claim_child_actor-1'
|
|
276
|
+
);
|
|
267
277
|
}, 2000);
|
|
268
278
|
|
|
269
279
|
const claims = posts
|
|
@@ -277,6 +287,14 @@ async function main() {
|
|
|
277
287
|
const ingestEvents = posts
|
|
278
288
|
.filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
|
|
279
289
|
.flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
|
|
290
|
+
assert(
|
|
291
|
+
ingestEvents.some((event) =>
|
|
292
|
+
event.event_type === 'request_capture_start' &&
|
|
293
|
+
event.session_id === 'S_claim_child_actor-1' &&
|
|
294
|
+
event.actor_id === 'actor-1'
|
|
295
|
+
),
|
|
296
|
+
'request capture start should fence the claimed backend session',
|
|
297
|
+
);
|
|
280
298
|
assert(
|
|
281
299
|
ingestEvents.some((event) =>
|
|
282
300
|
event.event_type === 'backend_request' &&
|
|
@@ -119,6 +119,11 @@ async function main() {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
await sleep(2200);
|
|
122
|
+
assert.equal(
|
|
123
|
+
eventsOf(capturedBodies, 'request_capture_start').length,
|
|
124
|
+
1,
|
|
125
|
+
`request-open fence was deferred with background work: ${JSON.stringify(capturedBodies)}`,
|
|
126
|
+
);
|
|
122
127
|
assert.equal(
|
|
123
128
|
eventsOf(capturedBodies, 'backend_request').length,
|
|
124
129
|
0,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
process.env.REPRO_INGEST_BATCH_MAX_ENTRIES = '100';
|
|
2
|
+
process.env.REPRO_INGEST_BATCH_MAX_SERIALIZED_BYTES = String(1024 * 1024);
|
|
3
|
+
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const originalFetch = global.fetch;
|
|
7
|
+
const capturedBodies = [];
|
|
8
|
+
|
|
9
|
+
global.fetch = async (_url, init = {}) => {
|
|
10
|
+
capturedBodies.push(JSON.parse(String(init.body || '{}')));
|
|
11
|
+
return {
|
|
12
|
+
ok: true,
|
|
13
|
+
status: 202,
|
|
14
|
+
json: async () => ({ ok: true }),
|
|
15
|
+
text: async () => '{"ok":true}',
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const { enqueueIngestEntries, flushIngestQueue } = require('../dist/ingest/client');
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const cfg = {
|
|
23
|
+
tenantId: 'TENANT_test',
|
|
24
|
+
appId: 'APP_test',
|
|
25
|
+
appSecret: 'secret',
|
|
26
|
+
serviceName: 'batching-test',
|
|
27
|
+
ingestBase: 'http://127.0.0.1:65535',
|
|
28
|
+
};
|
|
29
|
+
const sessionId = 'S_ingest_batching';
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < 209; index += 1) {
|
|
32
|
+
enqueueIngestEntries(cfg, sessionId, [{
|
|
33
|
+
actionId: 'A_ingest_batching',
|
|
34
|
+
requestRid: 'R_ingest_batching',
|
|
35
|
+
db: [{
|
|
36
|
+
collection: 'subjects',
|
|
37
|
+
op: 'findOne',
|
|
38
|
+
query: { index },
|
|
39
|
+
}],
|
|
40
|
+
t: Date.now() + index,
|
|
41
|
+
}], { priority: 'high' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await flushIngestQueue();
|
|
45
|
+
|
|
46
|
+
const events = capturedBodies.flatMap((body) =>
|
|
47
|
+
Array.isArray(body?.events) ? body.events : [],
|
|
48
|
+
);
|
|
49
|
+
assert.equal(events.length, 209);
|
|
50
|
+
assert(events.every((event) => event.event_type === 'db_change'));
|
|
51
|
+
assert.equal(
|
|
52
|
+
capturedBodies.length,
|
|
53
|
+
3,
|
|
54
|
+
`209 small evidence entries should use three bounded requests, got ${capturedBodies.length}`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
console.log('ingest batching OK');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
main()
|
|
61
|
+
.catch((error) => {
|
|
62
|
+
console.error(error);
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
global.fetch = originalFetch;
|
|
67
|
+
});
|