@reproapp/node-sdk 0.0.38 → 0.0.41

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.
@@ -57,6 +57,7 @@ export const kickIngestQueueDrain = (): void => {
57
57
  };
58
58
 
59
59
  const isCriticalIngestEntry = (entry: LegacyEntry): boolean => (
60
+ entry.requestCaptureStart !== undefined ||
60
61
  entry.requestCaptureManifest !== undefined ||
61
62
  entry.sessionCaptureManifest !== undefined ||
62
63
  entry.request !== undefined ||
@@ -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;
@@ -0,0 +1,421 @@
1
+ const assert = require('assert');
2
+ const express = require('express');
3
+ const http = require('http');
4
+
5
+ process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
6
+ process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
7
+
8
+ const {
9
+ reproMiddleware,
10
+ __reproTestHooks,
11
+ } = require('../dist');
12
+ const { flushIngestQueue } = require('../dist/ingest/client');
13
+
14
+ async function listen(server) {
15
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
16
+ return server.address().port;
17
+ }
18
+
19
+ async function close(server) {
20
+ await new Promise((resolve, reject) => {
21
+ server.close((err) => (err ? reject(err) : resolve()));
22
+ });
23
+ }
24
+
25
+ async function request(port, path = '/untagged', options = {}) {
26
+ const method = options.method || 'POST';
27
+ const headers = {
28
+ ...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
29
+ ...(options.headers || {}),
30
+ };
31
+ await new Promise((resolve, reject) => {
32
+ const req = http.request({
33
+ host: '127.0.0.1',
34
+ port,
35
+ path,
36
+ method,
37
+ headers,
38
+ }, (res) => {
39
+ res.resume();
40
+ res.on('end', resolve);
41
+ });
42
+ req.on('error', reject);
43
+ if (method === 'POST') {
44
+ req.end(JSON.stringify({ ok: true }));
45
+ } else {
46
+ req.end();
47
+ }
48
+ });
49
+ }
50
+
51
+ async function drainAll() {
52
+ await __reproTestHooks.drainSdkBackgroundQueueForTest();
53
+ await flushIngestQueue();
54
+ }
55
+
56
+ async function drainUntil(predicate, timeoutMs = 1000) {
57
+ const deadline = Date.now() + timeoutMs;
58
+ while (Date.now() <= deadline) {
59
+ await drainAll();
60
+ if (predicate()) return true;
61
+ await new Promise((resolve) => setTimeout(resolve, 10));
62
+ }
63
+ await drainAll();
64
+ return predicate();
65
+ }
66
+
67
+ function sleep(ms) {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
69
+ }
70
+
71
+ async function main() {
72
+ const posts = [];
73
+ const claimCounts = new Map();
74
+ const originalFetch = global.fetch;
75
+ global.fetch = async (url, init = {}) => {
76
+ const body = init.body ? JSON.parse(String(init.body)) : null;
77
+ posts.push({ url: String(url), body, headers: init.headers || {} });
78
+ if (String(url) === 'http://repro-api.test/api/v1/live-sessions/claim') {
79
+ const actor = String(body?.actorId || 'unknown');
80
+ const count = (claimCounts.get(actor) || 0) + 1;
81
+ claimCounts.set(actor, count);
82
+ const safeActor = actor.replace(/[^A-Za-z0-9_.:-]/g, '_');
83
+ const childSuffix = actor === 'actor-overlap' ? `_${count}` : '';
84
+ const childSessionId = `S_claim_child_${safeActor}${childSuffix}`;
85
+ return {
86
+ ok: true,
87
+ status: 200,
88
+ json: async () => ({
89
+ parentSessionId: `S_claim_parent_${safeActor}`,
90
+ childSessionId,
91
+ sessionId: childSessionId,
92
+ actionId: `A_claim_${safeActor}_${count}`,
93
+ clockOffsetMs: 0,
94
+ }),
95
+ text: async () => '{"ok":true}',
96
+ };
97
+ }
98
+ return {
99
+ ok: true,
100
+ status: 202,
101
+ json: async () => ({ ok: true }),
102
+ text: async () => '{"ok":true}',
103
+ };
104
+ };
105
+
106
+ __reproTestHooks.resetSdkBackgroundQueuesForTest();
107
+ __reproTestHooks.setBackendLiveTimingForTest({
108
+ segmentIdleMs: 1,
109
+ sessionMaxDurationMs: 20,
110
+ segmentMaxDurationMs: 20,
111
+ });
112
+
113
+ const cfg = {
114
+ appId: 'APP_backend_live',
115
+ tenantId: 'TENANT_backend_live',
116
+ appSecret: 'secret',
117
+ appName: 'Backend Live Test',
118
+ serviceName: 'Backend API',
119
+ apiBase: 'http://repro-api.test/api',
120
+ ingestBase: 'http://log-collector.test',
121
+ liveMode: {
122
+ enabled: true,
123
+ backendOnly: true,
124
+ defineActorId: ({ req }) => req.headers['x-test-actor'] || 'actor-1',
125
+ shouldCapture: ({ path }) => path !== '/skip',
126
+ actorType: 'user',
127
+ actorLabels: { tier: 'enterprise' },
128
+ },
129
+ };
130
+
131
+ const app = express();
132
+ app.use(express.json());
133
+ app.use(reproMiddleware(cfg));
134
+ app.post('/untagged', (_req, res) => {
135
+ res.json({ ok: true });
136
+ });
137
+ const server = http.createServer(app);
138
+ const port = await listen(server);
139
+
140
+ try {
141
+ await request(port, '/preflight', {
142
+ method: 'OPTIONS',
143
+ headers: {
144
+ origin: 'http://client.test',
145
+ 'access-control-request-method': 'POST',
146
+ 'access-control-request-headers': 'x-bug-session-id,x-bug-action-id,x-bug-request-start',
147
+ },
148
+ });
149
+ await drainAll();
150
+ assert.equal(
151
+ posts.length,
152
+ 0,
153
+ 'uncorrelated OPTIONS preflight should not create live sessions or ingest events',
154
+ );
155
+
156
+ await request(port, '/preflight', {
157
+ method: 'OPTIONS',
158
+ headers: {
159
+ 'x-bug-request-start': String(Date.now()),
160
+ },
161
+ });
162
+ await drainAll();
163
+ assert.equal(
164
+ posts.length,
165
+ 0,
166
+ 'OPTIONS with only Repro timing should not create backend-only live sessions',
167
+ );
168
+
169
+ const correlatedOptionsSid = 'S_frontend_options';
170
+ const correlatedOptionsAid = 'A_frontend_options';
171
+ await request(port, '/preflight', {
172
+ method: 'OPTIONS',
173
+ headers: {
174
+ 'x-bug-session-id': correlatedOptionsSid,
175
+ 'x-bug-action-id': correlatedOptionsAid,
176
+ 'x-bug-request-start': String(Date.now()),
177
+ },
178
+ });
179
+ await drainUntil(() => {
180
+ const events = posts
181
+ .filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
182
+ .flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
183
+ return events.some((event) =>
184
+ event.event_type === 'backend_request' &&
185
+ event.session_id === correlatedOptionsSid &&
186
+ event.payload?.action_id === correlatedOptionsAid
187
+ );
188
+ }, 2000);
189
+ assert(
190
+ posts
191
+ .filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
192
+ .flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : [])
193
+ .some((event) =>
194
+ event.event_type === 'backend_request' &&
195
+ event.session_id === correlatedOptionsSid &&
196
+ event.payload?.action_id === correlatedOptionsAid
197
+ ),
198
+ 'correlated OPTIONS request should still be captured',
199
+ );
200
+ assert.equal(
201
+ posts.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions').length,
202
+ 0,
203
+ 'frontend-correlated OPTIONS should not create a backend-only live parent',
204
+ );
205
+ assert.equal(
206
+ posts.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim').length,
207
+ 0,
208
+ 'frontend-correlated OPTIONS should not claim a backend-only live session',
209
+ );
210
+
211
+ posts.length = 0;
212
+ const correlatedPostSid = 'S_frontend_post';
213
+ const correlatedPostAid = 'A_frontend_post';
214
+ await request(port, '/untagged', {
215
+ headers: {
216
+ 'x-bug-session-id': correlatedPostSid,
217
+ 'x-bug-action-id': correlatedPostAid,
218
+ 'x-bug-request-start': String(Date.now()),
219
+ 'x-test-actor': 'backend-user-1',
220
+ },
221
+ });
222
+ await drainUntil(() => {
223
+ const events = posts
224
+ .filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
225
+ .flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
226
+ return events.some((event) =>
227
+ event.event_type === 'backend_event' &&
228
+ event.session_id === correlatedPostSid &&
229
+ event.payload?.sessionActor?.actorId === 'backend-user-1'
230
+ );
231
+ }, 2000);
232
+ const correlatedEvents = posts
233
+ .filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
234
+ .flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
235
+ assert(
236
+ correlatedEvents.some((event) =>
237
+ event.event_type === 'backend_event' &&
238
+ event.session_id === correlatedPostSid &&
239
+ event.actor_id === 'backend-user-1' &&
240
+ event.payload?.sessionActor?.actorId === 'backend-user-1'
241
+ ),
242
+ 'frontend-correlated request should emit backend actor alias evidence',
243
+ );
244
+ assert.equal(
245
+ posts.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions').length,
246
+ 0,
247
+ 'frontend-correlated request should not create backend-only live sessions',
248
+ );
249
+ assert.equal(
250
+ posts.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim').length,
251
+ 0,
252
+ 'frontend-correlated request should not claim backend-only live sessions',
253
+ );
254
+
255
+ posts.length = 0;
256
+ await request(port);
257
+ await drainUntil(() => {
258
+ const claims = posts
259
+ .filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim')
260
+ .map((post) => post.body);
261
+ const events = posts
262
+ .filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
263
+ .flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
264
+ return claims.length >= 1 &&
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
+ );
277
+ }, 2000);
278
+
279
+ const claims = posts
280
+ .filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim')
281
+ .map((post) => post.body);
282
+ assert.equal(claims.length, 1, 'backend-only request should claim one central live session');
283
+ assert.equal(claims[0].captureMode, 'backend-only');
284
+ assert.equal(claims[0].actorId, 'actor-1');
285
+ assert.deepEqual(claims[0].actorIds, ['actor-1']);
286
+
287
+ const ingestEvents = posts
288
+ .filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
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
+ );
298
+ assert(
299
+ ingestEvents.some((event) =>
300
+ event.event_type === 'backend_request' &&
301
+ event.session_id === 'S_claim_child_actor-1' &&
302
+ event.actor_id === 'actor-1'
303
+ ),
304
+ 'backend request event should be captured for claimed child session and actor',
305
+ );
306
+ assert(
307
+ ingestEvents.some((event) =>
308
+ event.event_type === 'request_capture_manifest' &&
309
+ event.session_id === 'S_claim_child_actor-1'
310
+ ),
311
+ 'request capture manifest should be emitted for claimed backend session',
312
+ );
313
+ assert.equal(
314
+ posts.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions').length,
315
+ 0,
316
+ 'SDK should not locally create backend-only sessions after central claim',
317
+ );
318
+ assert.equal(
319
+ posts.some((post) => String(post.url).includes('/finish')),
320
+ false,
321
+ 'SDK should not locally finish centrally claimed backend-only sessions',
322
+ );
323
+
324
+ console.log('backend live mode capture OK');
325
+ } finally {
326
+ await close(server);
327
+ }
328
+
329
+ posts.length = 0;
330
+ __reproTestHooks.resetSdkBackgroundQueuesForTest();
331
+ __reproTestHooks.setBackendLiveTimingForTest({
332
+ sessionMaxDurationMs: 30,
333
+ segmentMaxDurationMs: 30,
334
+ segmentIdleMs: 1,
335
+ });
336
+
337
+ const overlapCfg = {
338
+ ...cfg,
339
+ appId: 'APP_backend_live_overlap',
340
+ liveMode: {
341
+ ...cfg.liveMode,
342
+ defineActorId: () => 'actor-overlap',
343
+ shouldCapture: ({ path }) => path !== '/skip',
344
+ },
345
+ };
346
+
347
+ const overlapApp = express();
348
+ overlapApp.use(express.json());
349
+ overlapApp.use(reproMiddleware(overlapCfg));
350
+ overlapApp.post('/slow', async (_req, res) => {
351
+ await sleep(80);
352
+ res.json({ ok: true, kind: 'slow' });
353
+ });
354
+ overlapApp.post('/fast', (_req, res) => {
355
+ res.json({ ok: true, kind: 'fast' });
356
+ });
357
+ const overlapServer = http.createServer(overlapApp);
358
+ const overlapPort = await listen(overlapServer);
359
+
360
+ try {
361
+ const slowRequest = request(overlapPort, '/slow');
362
+ await sleep(50);
363
+ await request(overlapPort, '/fast');
364
+ await slowRequest;
365
+
366
+ await drainUntil(() => {
367
+ const claims = posts
368
+ .filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim');
369
+ const events = posts
370
+ .filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
371
+ .flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
372
+ const requestSessionIds = new Set(
373
+ events
374
+ .filter((event) =>
375
+ event.event_type === 'backend_request' &&
376
+ event.actor_id === 'actor-overlap'
377
+ )
378
+ .map((event) => event.session_id),
379
+ );
380
+ return claims.length >= 2 && requestSessionIds.size >= 2;
381
+ });
382
+
383
+ const claims = posts
384
+ .filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim')
385
+ .map((post) => post.body);
386
+ assert.equal(claims.length, 2, 'each backend-only request should ask the API for ownership');
387
+ assert(
388
+ claims.every((claim) => claim.actorId === 'actor-overlap'),
389
+ 'overlap claims should keep the actor identity',
390
+ );
391
+
392
+ const ingestEvents = posts
393
+ .filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
394
+ .flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
395
+ const requestSessionIds = new Set(
396
+ ingestEvents
397
+ .filter((event) =>
398
+ event.event_type === 'backend_request' &&
399
+ event.actor_id === 'actor-overlap'
400
+ )
401
+ .map((event) => event.session_id),
402
+ );
403
+ assert.deepEqual(
404
+ new Set(['S_claim_child_actor-overlap_1', 'S_claim_child_actor-overlap_2']),
405
+ requestSessionIds,
406
+ 'backend requests should use exactly the child sessions returned by the API claim',
407
+ );
408
+
409
+ console.log('backend live mode central claim overlap OK');
410
+ } finally {
411
+ await close(overlapServer);
412
+ global.fetch = originalFetch;
413
+ __reproTestHooks.resetSdkBackgroundQueuesForTest();
414
+ __reproTestHooks.setBackendLiveTimingForTest(null);
415
+ }
416
+ }
417
+
418
+ main().catch((err) => {
419
+ console.error(err);
420
+ process.exit(1);
421
+ });