@reproapp/node-sdk 0.0.38 → 0.0.39
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 +225 -310
- package/dist/index.d.ts +42 -0
- package/dist/index.js +447 -20
- package/package.json +2 -2
- package/src/index.ts +670 -20
- package/test/backend-live-mode.test.js +403 -0
|
@@ -0,0 +1,403 @@
|
|
|
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) => event.event_type === 'backend_request') &&
|
|
266
|
+
events.some((event) => event.event_type === 'request_capture_manifest');
|
|
267
|
+
}, 2000);
|
|
268
|
+
|
|
269
|
+
const claims = posts
|
|
270
|
+
.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim')
|
|
271
|
+
.map((post) => post.body);
|
|
272
|
+
assert.equal(claims.length, 1, 'backend-only request should claim one central live session');
|
|
273
|
+
assert.equal(claims[0].captureMode, 'backend-only');
|
|
274
|
+
assert.equal(claims[0].actorId, 'actor-1');
|
|
275
|
+
assert.deepEqual(claims[0].actorIds, ['actor-1']);
|
|
276
|
+
|
|
277
|
+
const ingestEvents = posts
|
|
278
|
+
.filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
|
|
279
|
+
.flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
|
|
280
|
+
assert(
|
|
281
|
+
ingestEvents.some((event) =>
|
|
282
|
+
event.event_type === 'backend_request' &&
|
|
283
|
+
event.session_id === 'S_claim_child_actor-1' &&
|
|
284
|
+
event.actor_id === 'actor-1'
|
|
285
|
+
),
|
|
286
|
+
'backend request event should be captured for claimed child session and actor',
|
|
287
|
+
);
|
|
288
|
+
assert(
|
|
289
|
+
ingestEvents.some((event) =>
|
|
290
|
+
event.event_type === 'request_capture_manifest' &&
|
|
291
|
+
event.session_id === 'S_claim_child_actor-1'
|
|
292
|
+
),
|
|
293
|
+
'request capture manifest should be emitted for claimed backend session',
|
|
294
|
+
);
|
|
295
|
+
assert.equal(
|
|
296
|
+
posts.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions').length,
|
|
297
|
+
0,
|
|
298
|
+
'SDK should not locally create backend-only sessions after central claim',
|
|
299
|
+
);
|
|
300
|
+
assert.equal(
|
|
301
|
+
posts.some((post) => String(post.url).includes('/finish')),
|
|
302
|
+
false,
|
|
303
|
+
'SDK should not locally finish centrally claimed backend-only sessions',
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
console.log('backend live mode capture OK');
|
|
307
|
+
} finally {
|
|
308
|
+
await close(server);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
posts.length = 0;
|
|
312
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
313
|
+
__reproTestHooks.setBackendLiveTimingForTest({
|
|
314
|
+
sessionMaxDurationMs: 30,
|
|
315
|
+
segmentMaxDurationMs: 30,
|
|
316
|
+
segmentIdleMs: 1,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const overlapCfg = {
|
|
320
|
+
...cfg,
|
|
321
|
+
appId: 'APP_backend_live_overlap',
|
|
322
|
+
liveMode: {
|
|
323
|
+
...cfg.liveMode,
|
|
324
|
+
defineActorId: () => 'actor-overlap',
|
|
325
|
+
shouldCapture: ({ path }) => path !== '/skip',
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const overlapApp = express();
|
|
330
|
+
overlapApp.use(express.json());
|
|
331
|
+
overlapApp.use(reproMiddleware(overlapCfg));
|
|
332
|
+
overlapApp.post('/slow', async (_req, res) => {
|
|
333
|
+
await sleep(80);
|
|
334
|
+
res.json({ ok: true, kind: 'slow' });
|
|
335
|
+
});
|
|
336
|
+
overlapApp.post('/fast', (_req, res) => {
|
|
337
|
+
res.json({ ok: true, kind: 'fast' });
|
|
338
|
+
});
|
|
339
|
+
const overlapServer = http.createServer(overlapApp);
|
|
340
|
+
const overlapPort = await listen(overlapServer);
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const slowRequest = request(overlapPort, '/slow');
|
|
344
|
+
await sleep(50);
|
|
345
|
+
await request(overlapPort, '/fast');
|
|
346
|
+
await slowRequest;
|
|
347
|
+
|
|
348
|
+
await drainUntil(() => {
|
|
349
|
+
const claims = posts
|
|
350
|
+
.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim');
|
|
351
|
+
const events = posts
|
|
352
|
+
.filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
|
|
353
|
+
.flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
|
|
354
|
+
const requestSessionIds = new Set(
|
|
355
|
+
events
|
|
356
|
+
.filter((event) =>
|
|
357
|
+
event.event_type === 'backend_request' &&
|
|
358
|
+
event.actor_id === 'actor-overlap'
|
|
359
|
+
)
|
|
360
|
+
.map((event) => event.session_id),
|
|
361
|
+
);
|
|
362
|
+
return claims.length >= 2 && requestSessionIds.size >= 2;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const claims = posts
|
|
366
|
+
.filter((post) => post.url === 'http://repro-api.test/api/v1/live-sessions/claim')
|
|
367
|
+
.map((post) => post.body);
|
|
368
|
+
assert.equal(claims.length, 2, 'each backend-only request should ask the API for ownership');
|
|
369
|
+
assert(
|
|
370
|
+
claims.every((claim) => claim.actorId === 'actor-overlap'),
|
|
371
|
+
'overlap claims should keep the actor identity',
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const ingestEvents = posts
|
|
375
|
+
.filter((post) => post.url === 'http://log-collector.test/v1/ingest/events')
|
|
376
|
+
.flatMap((post) => Array.isArray(post.body?.events) ? post.body.events : []);
|
|
377
|
+
const requestSessionIds = new Set(
|
|
378
|
+
ingestEvents
|
|
379
|
+
.filter((event) =>
|
|
380
|
+
event.event_type === 'backend_request' &&
|
|
381
|
+
event.actor_id === 'actor-overlap'
|
|
382
|
+
)
|
|
383
|
+
.map((event) => event.session_id),
|
|
384
|
+
);
|
|
385
|
+
assert.deepEqual(
|
|
386
|
+
new Set(['S_claim_child_actor-overlap_1', 'S_claim_child_actor-overlap_2']),
|
|
387
|
+
requestSessionIds,
|
|
388
|
+
'backend requests should use exactly the child sessions returned by the API claim',
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
console.log('backend live mode central claim overlap OK');
|
|
392
|
+
} finally {
|
|
393
|
+
await close(overlapServer);
|
|
394
|
+
global.fetch = originalFetch;
|
|
395
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
396
|
+
__reproTestHooks.setBackendLiveTimingForTest(null);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
main().catch((err) => {
|
|
401
|
+
console.error(err);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
});
|