@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.
@@ -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 isCriticalIngestEntry = (entry: LegacyEntry): boolean => (
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 'high';
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
- return entries.some(isCriticalIngestEntry) ? 'high' : 'normal';
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 batch = ingestQueue[0];
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.shift();
363
+ ingestQueue.splice(0, selected.queueItems);
202
364
  backoffMs = 0;
203
365
  continue;
204
366
  }
205
367
 
206
- batch.attempts += 1;
207
- if (batch.attempts >= DEFAULT_QUEUE_MAX_ATTEMPTS && batch.priority !== 'high') {
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
 
@@ -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';
@@ -18,6 +18,7 @@ export interface LegacyEntry {
18
18
  requestRid?: string | null;
19
19
  request?: unknown;
20
20
  requestValues?: unknown[];
21
+ requestCaptureStart?: unknown;
21
22
  requestCaptureManifest?: unknown;
22
23
  sessionCaptureManifest?: unknown;
23
24
  db?: unknown;
@@ -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) => event.event_type === 'backend_request') &&
266
- events.some((event) => event.event_type === 'request_capture_manifest');
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
+ });