@replayio/self-healing-capture 0.1.0

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 ADDED
@@ -0,0 +1,92 @@
1
+ # @replayio/self-healing-capture
2
+
3
+ Browser capture for FullStory sessions. This package owns the producers for network exchanges,
4
+ clicks, input/change values, paste text, keyboard events, user identity, activity counts and page/session
5
+ context. Install the package rather than copying a recorder into your app.
6
+
7
+ ```sh
8
+ npm install @replayio/self-healing-capture @fullstory/browser
9
+ ```
10
+
11
+ Call once in your production browser entry point, before rendering the application:
12
+
13
+ ```ts
14
+ import { initCapture } from '@replayio/self-healing-capture'
15
+
16
+ export const capture = initCapture({
17
+ orgId: '<your FullStory organization ID>',
18
+ endpoint: '/api/self-healing/session', // same-origin server route
19
+ onError: error => console.error('Session capture failed', error),
20
+ })
21
+
22
+ // When the application authenticates a user:
23
+ capture.identify({ id: user.id, name: user.name, email: user.email })
24
+ ```
25
+
26
+ The package initializes FullStory itself. Remove the previous recorder/FullStory initialization
27
+ when migrating; do not install a second fetch wrapper. Do not call `initCapture` during SSR.
28
+ Repeated calls with the same org/endpoint return the existing controller. It captures fetch traffic
29
+ (browser-visible headers and bodies), not XMLHttpRequest or WebSockets. It preserves the existing
30
+ QA capture fields without introducing a redaction policy.
31
+
32
+ ## Server route
33
+
34
+ The browser has no provider or account credentials. Its same-origin POST endpoint uses the app's
35
+ existing access controls and forwards the JSON body to Self Healing's
36
+ `POST /api/v1/connection/sessions`, adding `Authorization: Bearer <SELF_HEALING_API_KEY>` server-side.
37
+ Preserve the upstream response status so the recorder can detect failures.
38
+
39
+ For a standalone QA integration, configure the endpoint to the app's QA registration proxy instead.
40
+ The wire format is QA's existing `{session_url, auxiliary_data}` envelope. No Self Healing server or
41
+ package import is required in QA's ingestion/review implementation.
42
+
43
+ ## Uploads and lifecycle
44
+
45
+ - Network, interaction and page-context artifacts use QA's version-1 namespace/key contracts.
46
+ `session/capture-producer` additionally identifies this package and version. That is provenance,
47
+ not authentication. QA recognizes network/interaction data by their artifact keys.
48
+ - The package batches whole events into UTF-8 JSON requests of at most 256 KiB. Uploads use the
49
+ original fetch, so they do not capture themselves. Network errors, 429, server errors and
50
+ Self Healing's `upload_busy` retry up to three attempts with identical bodies and event IDs.
51
+ - A single event larger than the request limit fails explicitly through `onError` and `flush()`;
52
+ it is not truncated to fit. Bodies above 1 MB are null and the per-page/session network budget
53
+ is 8 MB, matching the existing producer. Dropped counts are included in capture context.
54
+ Network and interaction counts each stop at 5,000 entries per page/session.
55
+ - `await capture.flush()` waits for current in-flight captures and pending uploads. It rejects
56
+ if FullStory has no session yet or any capture/upload has failed. There is no durable offline queue.
57
+ - `await capture.stop()` stops new capture, removes interaction listeners and flushes outstanding
58
+ data. It does not stop FullStory recording or seal the server's session, and it cannot be restarted
59
+ on the same page. `identify()` is a no-op after stopping.
60
+ - Session rollover preserves ownership of requests already in flight and resets producer counters.
61
+ `captured_at` is absolute milliseconds; `source_timestamp` is page-relative milliseconds.
62
+
63
+ Finalization is a server operation. Once a whole session has ended and all pages' uploads succeeded,
64
+ send `{session_url, auxiliary_data: [], complete: true}` to Self Healing. A page flush or unload alone
65
+ cannot establish that a multi-page FullStory session has ended.
66
+
67
+ ## Development and release
68
+
69
+ The source of truth is `packages/capture/src` in `replayio/self-healing`. Changes to producers belong
70
+ here. QA owns its independent ingestion schemas and compatibility tests, not another installer copy.
71
+
72
+ Publish locally with interactive npm authentication and 2FA:
73
+
74
+ ```sh
75
+ npm ci
76
+ npm run capture:publish
77
+ ```
78
+
79
+ Run from a terminal using Node 22.14+ and an npm account with publishing access to `@replayio`.
80
+ The script checks whether the manifest version exists, runs tests and the production build, packs
81
+ the tested output into a temporary directory, logs into npm if necessary, and publishes that exact
82
+ artifact. npm owns the browser login/security-key/2FA prompts. Credentials and OTPs are not script
83
+ arguments or repository secrets. Follow npm's authentication prompt when it appears.
84
+
85
+ Use `npm run capture:publish -- --dry-run` to test building and packaging without login or publication.
86
+ Registry errors fail the version check; already-published versions are skipped. After publishing,
87
+ the script checks that the version is visible on npm. If visibility verification fails, rerun: an
88
+ existing version is skipped, never overwritten. Temporary tarballs are removed when the script exits.
89
+
90
+ For later releases, update the package version and emitted producer metadata together, update the
91
+ root lockfile, and run the script from the reviewed revision. The producer test checks that metadata
92
+ matches the manifest. Installers receive updates through dependency upgrades and their lockfiles.
@@ -0,0 +1,19 @@
1
+ export interface CaptureOptions {
2
+ orgId: string;
3
+ /** Same-origin POST route holding the server-side credential. */
4
+ endpoint?: string;
5
+ onError?: (error: Error) => void;
6
+ }
7
+ export interface CaptureController {
8
+ identify(user: FullStoryUser | null): void;
9
+ /** Wait for in-flight captures and upload all known sessions. Rejects on capture/upload failure. */
10
+ flush(): Promise<void>;
11
+ /** Stop new capture and flush existing data; does not seal a FullStory session. */
12
+ stop(): Promise<void>;
13
+ }
14
+ export interface FullStoryUser {
15
+ id: string;
16
+ name: string;
17
+ email: string | null;
18
+ }
19
+ export declare function initCapture(options: CaptureOptions): CaptureController;
package/dist/index.js ADDED
@@ -0,0 +1,544 @@
1
+ import { FullStory, init } from "@fullstory/browser";
2
+ import { splitBatches } from "./transport.js";
3
+ let active;
4
+ let activeOptions;
5
+ const CAPTURED_SESSION_INTERACTION_EVENTS = [
6
+ "click",
7
+ "change",
8
+ "input",
9
+ "paste",
10
+ "keydown",
11
+ "keyup",
12
+ ];
13
+ function countsAsSessionInteraction(event) {
14
+ return (event.type === "click" ||
15
+ event.type === "change" ||
16
+ (event instanceof KeyboardEvent &&
17
+ event.type === "keydown" &&
18
+ ["Enter", "Escape"].includes(event.key)));
19
+ }
20
+ const MAX_BODY_BYTES = 1_000_000;
21
+ const MAX_CAPTURE_BYTES = 8_000_000;
22
+ const MAX_CAPTURED_INTERACTIONS = 5_000;
23
+ const ACTIONABLE_SELECTOR = 'button, a[href], input, select, textarea, summary, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="option"], [role="radio"], [role="switch"], [role="tab"], [contenteditable="true"]';
24
+ let setCapturedUserEmail = null;
25
+ let fullStoryInitialized = false;
26
+ /** Identify the authenticated user in FullStory and attach their email to Replay QA's session record. */
27
+ function identifyFullStoryUser(user) {
28
+ if (!fullStoryInitialized || !user)
29
+ return;
30
+ FullStory("setIdentity", {
31
+ uid: user.id,
32
+ properties: {
33
+ displayName: user.name,
34
+ ...(user.email ? { email: user.email } : {}),
35
+ },
36
+ });
37
+ if (user.email)
38
+ setCapturedUserEmail?.(user.email);
39
+ }
40
+ function shouldStopSessionUploads(status) {
41
+ return status < 200 || status >= 300;
42
+ }
43
+ export function initCapture(options) {
44
+ if (typeof window === "undefined")
45
+ throw new Error("initCapture must run in the browser");
46
+ if (active) {
47
+ if (options.orgId !== activeOptions?.orgId ||
48
+ options.endpoint !== activeOptions?.endpoint) {
49
+ throw new Error("Capture is already initialized with different options");
50
+ }
51
+ return active;
52
+ }
53
+ if (!options.orgId)
54
+ throw new Error("A FullStory organization ID is required");
55
+ const endpoint = new URL(options.endpoint ?? "/api/self-healing/session", window.location.href);
56
+ if (endpoint.origin !== window.location.origin ||
57
+ endpoint.username ||
58
+ endpoint.password) {
59
+ throw new Error("Capture endpoint must be same-origin");
60
+ }
61
+ const orgId = options.orgId;
62
+ let stopped = false;
63
+ let lastError;
64
+ const reportError = (error) => {
65
+ lastError = error instanceof Error ? error : new Error(String(error));
66
+ try {
67
+ (options.onError ?? console.error)(lastError);
68
+ }
69
+ catch {
70
+ /* observers cannot break the app */
71
+ }
72
+ };
73
+ const inFlight = new Set();
74
+ const nativeFetch = window.fetch.bind(window);
75
+ const pageId = crypto.randomUUID();
76
+ let userEmail = null;
77
+ let fullStoryReady = false;
78
+ let syntheticExchange = null;
79
+ // All auxiliary producers use this same session object, including work finishing after rollover.
80
+ function createCaptureSession(sessionUrl, previousSessionUrl) {
81
+ return {
82
+ sessionUrl,
83
+ previousSessionUrl,
84
+ capturedExchanges: [],
85
+ capturedInteractions: [],
86
+ capturedBytes: 0,
87
+ interactionCount: 0,
88
+ queuedExchangeCount: 0,
89
+ queuedInteractionCount: -1,
90
+ queuedCapturedInteractionCount: 0,
91
+ uploadsStopped: false,
92
+ userEmail,
93
+ queuedUserEmail: null,
94
+ uploadTimer: null,
95
+ uploadChain: Promise.resolve(),
96
+ droppedNetworkCount: 0,
97
+ droppedInteractionCount: 0,
98
+ queuedContext: "",
99
+ };
100
+ }
101
+ let currentSession = createCaptureSession(null, null);
102
+ const sessions = new Set([currentSession]);
103
+ function synchronizeSession(sessionUrl) {
104
+ if (currentSession.sessionUrl !== sessionUrl) {
105
+ if (currentSession.sessionUrl === null) {
106
+ // Requests made before SDK readiness belong to the first session, even if still in flight.
107
+ currentSession.sessionUrl = sessionUrl;
108
+ }
109
+ else {
110
+ const previous = currentSession;
111
+ if (previous.uploadTimer)
112
+ clearTimeout(previous.uploadTimer);
113
+ previous.uploadTimer = null;
114
+ void queueCaptureUpload(previous);
115
+ currentSession = createCaptureSession(sessionUrl, previous.sessionUrl);
116
+ sessions.add(currentSession);
117
+ }
118
+ uploadCapture(currentSession);
119
+ }
120
+ return currentSession;
121
+ }
122
+ function captureSession() {
123
+ if (!fullStoryReady)
124
+ return currentSession;
125
+ // Read at the event boundary: an idle tab can resume into a new FullStory session without reload.
126
+ try {
127
+ const sessionUrl = FullStory("getSession", { format: "url" });
128
+ return sessionUrl ? synchronizeSession(sessionUrl) : null;
129
+ }
130
+ catch {
131
+ // Stopped/unavailable recording must not assign new events to a stale session.
132
+ return null;
133
+ }
134
+ }
135
+ setCapturedUserEmail = (email) => {
136
+ userEmail = email;
137
+ const session = captureSession();
138
+ if (!session)
139
+ return;
140
+ session.userEmail = email;
141
+ uploadCapture(session);
142
+ };
143
+ async function boundedBody(value) {
144
+ const bytes = await value.clone().arrayBuffer();
145
+ if (bytes.byteLength > MAX_BODY_BYTES)
146
+ return null;
147
+ return new TextDecoder().decode(bytes);
148
+ }
149
+ async function sendCaptureBatch(body) {
150
+ for (let attempt = 0;; attempt++) {
151
+ try {
152
+ const response = await nativeFetch(endpoint.href, {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json" },
155
+ body,
156
+ });
157
+ if (attempt === 2)
158
+ return response;
159
+ const busy = response.status === 409 &&
160
+ (await response
161
+ .clone()
162
+ .json()
163
+ .catch(() => null))?.error?.code === "upload_busy";
164
+ if (response.status < 500 && response.status !== 429 && !busy)
165
+ return response;
166
+ }
167
+ catch (error) {
168
+ if (attempt === 2)
169
+ throw error;
170
+ }
171
+ await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
172
+ }
173
+ }
174
+ function queueCaptureUpload(session) {
175
+ if (!session.sessionUrl || session.uploadsStopped)
176
+ return Promise.resolve();
177
+ const exchanges = session.capturedExchanges
178
+ .slice(session.queuedExchangeCount)
179
+ .map(({ startup_body: _body, status_text: _status, ...exchange }) => exchange)
180
+ .sort((a, b) => a.source_timestamp - b.source_timestamp);
181
+ const context = JSON.stringify({
182
+ id: pageId,
183
+ page_started_at: performance.timeOrigin,
184
+ previous_session_url: session.previousSessionUrl,
185
+ dropped_network_count: session.droppedNetworkCount,
186
+ dropped_interaction_count: session.droppedInteractionCount,
187
+ });
188
+ const auxiliaryData = [
189
+ {
190
+ namespace: "session",
191
+ key: "capture-producer",
192
+ schema_version: 1,
193
+ payload: { name: "@replayio/self-healing-capture", version: "0.1.0" },
194
+ },
195
+ ...(context !== session.queuedContext
196
+ ? [
197
+ {
198
+ namespace: "session",
199
+ key: "capture-context",
200
+ schema_version: 1,
201
+ payload: {
202
+ version: 1,
203
+ pages: [
204
+ {
205
+ id: pageId,
206
+ page_started_at: performance.timeOrigin,
207
+ previous_session_url: session.previousSessionUrl,
208
+ dropped_network_count: session.droppedNetworkCount,
209
+ dropped_interaction_count: session.droppedInteractionCount,
210
+ },
211
+ ],
212
+ },
213
+ },
214
+ ]
215
+ : []),
216
+ ...(exchanges.length > 0
217
+ ? [
218
+ {
219
+ namespace: "network",
220
+ key: "captured-exchanges",
221
+ schema_version: 1,
222
+ payload: { version: 1, exchanges },
223
+ },
224
+ ]
225
+ : []),
226
+ ...(session.capturedInteractions.length !==
227
+ session.queuedCapturedInteractionCount
228
+ ? [
229
+ {
230
+ namespace: "interaction",
231
+ key: "captured-interactions",
232
+ schema_version: 1,
233
+ payload: {
234
+ version: 1,
235
+ interactions: session.capturedInteractions.slice(session.queuedCapturedInteractionCount),
236
+ },
237
+ },
238
+ ]
239
+ : []),
240
+ ...(session.interactionCount !== session.queuedInteractionCount
241
+ ? [
242
+ {
243
+ namespace: "session",
244
+ key: "metrics",
245
+ schema_version: 1,
246
+ payload: {
247
+ version: 1,
248
+ interaction_count: session.interactionCount,
249
+ },
250
+ },
251
+ ]
252
+ : []),
253
+ ...(session.userEmail && session.userEmail !== session.queuedUserEmail
254
+ ? [
255
+ {
256
+ namespace: "session",
257
+ key: "identity",
258
+ schema_version: 1,
259
+ payload: { version: 1, email: session.userEmail },
260
+ },
261
+ ]
262
+ : []),
263
+ ];
264
+ if (auxiliaryData.length === 1)
265
+ return session.uploadChain;
266
+ session.queuedContext = context;
267
+ session.queuedExchangeCount = session.capturedExchanges.length;
268
+ session.queuedInteractionCount = session.interactionCount;
269
+ session.queuedCapturedInteractionCount =
270
+ session.capturedInteractions.length;
271
+ session.queuedUserEmail = session.userEmail;
272
+ const body = {
273
+ session_url: session.sessionUrl,
274
+ auxiliary_data: auxiliaryData,
275
+ };
276
+ // Each immutable batch is retried with the same event IDs; the server deduplicates it.
277
+ session.uploadChain = session.uploadChain
278
+ .then(async () => {
279
+ if (session.uploadsStopped)
280
+ return;
281
+ for (const batch of splitBatches(body)) {
282
+ const response = await sendCaptureBatch(batch);
283
+ if (shouldStopSessionUploads(response.status)) {
284
+ session.uploadsStopped = true;
285
+ throw new Error(`Capture upload failed: ${response.status}`);
286
+ }
287
+ }
288
+ })
289
+ .catch((error) => {
290
+ session.uploadsStopped = true;
291
+ reportError(error);
292
+ });
293
+ return session.uploadChain;
294
+ }
295
+ function uploadCapture(session) {
296
+ if (!session.sessionUrl || session.uploadsStopped)
297
+ return;
298
+ if (session.uploadTimer)
299
+ return;
300
+ session.uploadTimer = setTimeout(() => {
301
+ session.uploadTimer = null;
302
+ void queueCaptureUpload(session);
303
+ }, 250);
304
+ }
305
+ function selectorFor(element) {
306
+ const tag = element.tagName.toLowerCase();
307
+ const testId = element.getAttribute("data-testid");
308
+ const candidate = (selector) => selector.length <= 2_000 ? selector : null;
309
+ if (testId)
310
+ return candidate(`[data-testid=${JSON.stringify(testId)}]`);
311
+ if (element.id)
312
+ return candidate(`#${CSS.escape(element.id)}`);
313
+ const ariaLabel = element.getAttribute("aria-label");
314
+ if (ariaLabel)
315
+ return candidate(`${tag}[aria-label=${JSON.stringify(ariaLabel)}]`);
316
+ const name = element.getAttribute("name");
317
+ if (name)
318
+ return candidate(`${tag}[name=${JSON.stringify(name)}]`);
319
+ const label = (element.textContent ?? "")
320
+ .replace(/\s+/g, " ")
321
+ .trim()
322
+ .slice(0, 500);
323
+ return label
324
+ ? candidate(`${tag}:has-text(${JSON.stringify(label)})`)
325
+ : null;
326
+ }
327
+ function recordInteraction(event) {
328
+ if (stopped || !event.isTrusted)
329
+ return;
330
+ const session = captureSession();
331
+ if (!session)
332
+ return;
333
+ if (countsAsSessionInteraction(event))
334
+ session.interactionCount++;
335
+ if (session.capturedInteractions.length >= MAX_CAPTURED_INTERACTIONS) {
336
+ session.droppedInteractionCount++;
337
+ return uploadCapture(session);
338
+ }
339
+ const path = event
340
+ .composedPath()
341
+ .filter((item) => item instanceof Element);
342
+ const target = path.find((element) => element.matches(ACTIONABLE_SELECTOR)) ?? path[0];
343
+ const selector = target ? selectorFor(target) : null;
344
+ const label = target
345
+ ? (target.getAttribute("aria-label") ||
346
+ target.textContent ||
347
+ target.tagName)
348
+ .replace(/\s+/g, " ")
349
+ .trim()
350
+ .slice(0, 500)
351
+ : null;
352
+ const textTarget = target instanceof HTMLInputElement ||
353
+ target instanceof HTMLTextAreaElement ||
354
+ target instanceof HTMLSelectElement;
355
+ const editable = target instanceof HTMLElement && target.isContentEditable;
356
+ const keyboard = event instanceof KeyboardEvent
357
+ ? {
358
+ key: event.key,
359
+ code: event.code,
360
+ altKey: event.altKey,
361
+ ctrlKey: event.ctrlKey,
362
+ metaKey: event.metaKey,
363
+ shiftKey: event.shiftKey,
364
+ repeat: event.repeat,
365
+ isComposing: event.isComposing,
366
+ }
367
+ : {};
368
+ session.capturedInteractions.push({
369
+ id: crypto.randomUUID(),
370
+ captured_at: performance.timeOrigin + performance.now(),
371
+ source_timestamp: Math.round(performance.now()),
372
+ kind: event.type,
373
+ selector,
374
+ label: label || null,
375
+ tag_name: target?.tagName.toLowerCase(),
376
+ ...keyboard,
377
+ ...(["input", "change"].includes(event.type) && (textTarget || editable)
378
+ ? { value: textTarget ? target.value : (target.textContent ?? "") }
379
+ : {}),
380
+ ...(event instanceof ClipboardEvent && event.type === "paste"
381
+ ? { text: event.clipboardData?.getData("text/plain") ?? "" }
382
+ : {}),
383
+ ...(event instanceof InputEvent
384
+ ? { inputType: event.inputType, isComposing: event.isComposing }
385
+ : {}),
386
+ });
387
+ uploadCapture(session);
388
+ }
389
+ for (const eventName of CAPTURED_SESSION_INTERACTION_EVENTS) {
390
+ window.addEventListener(eventName, recordInteraction, { capture: true });
391
+ }
392
+ async function replayForFullStory(exchange) {
393
+ syntheticExchange = exchange;
394
+ try {
395
+ // FullStory observes this synthetic response without issuing a second backend request.
396
+ await window.fetch(exchange.url, {
397
+ method: exchange.method,
398
+ headers: { "x-fs-recapture": "1" },
399
+ });
400
+ }
401
+ finally {
402
+ syntheticExchange = null;
403
+ }
404
+ }
405
+ window.fetch = async (input, init) => {
406
+ if (stopped)
407
+ return nativeFetch(input, init);
408
+ const request = new Request(input instanceof Request
409
+ ? input
410
+ : new URL(String(input), window.location.href), init);
411
+ if (request.url === endpoint.href)
412
+ return nativeFetch(request);
413
+ const synthetic = syntheticExchange;
414
+ if (synthetic &&
415
+ request.headers.get("x-fs-recapture") === "1" &&
416
+ request.method === synthetic.method &&
417
+ request.url === synthetic.url) {
418
+ return new Response(synthetic.startup_body?.byteLength
419
+ ? synthetic.startup_body.slice(0)
420
+ : null, {
421
+ status: synthetic.status,
422
+ statusText: synthetic.status_text,
423
+ headers: synthetic.response_headers,
424
+ });
425
+ }
426
+ const session = captureSession();
427
+ if (!session)
428
+ return nativeFetch(request);
429
+ const startedBeforeReady = !fullStoryReady;
430
+ const sourceTimestamp = Math.round(performance.now());
431
+ const capturedAt = performance.timeOrigin + sourceTimestamp;
432
+ const exchangeId = crypto.randomUUID();
433
+ const requestBody = request.method !== "GET" && request.method !== "HEAD"
434
+ ? boundedBody(request).catch(() => null)
435
+ : Promise.resolve(null);
436
+ const responsePromise = nativeFetch(request);
437
+ inFlight.add(responsePromise);
438
+ let response;
439
+ try {
440
+ response = await responsePromise;
441
+ }
442
+ finally {
443
+ inFlight.delete(responsePromise);
444
+ }
445
+ const capture = (async () => {
446
+ const clone = response.clone();
447
+ const responseBytes = await clone.arrayBuffer().catch(() => null);
448
+ const capturedRequestBody = await requestBody;
449
+ const exchangeBytes = (responseBytes?.byteLength ?? 0) +
450
+ new TextEncoder().encode(capturedRequestBody ?? "").byteLength;
451
+ if (session.capturedBytes + exchangeBytes > MAX_CAPTURE_BYTES ||
452
+ session.capturedExchanges.length >= 5_000) {
453
+ session.droppedNetworkCount++;
454
+ uploadCapture(session);
455
+ return;
456
+ }
457
+ const exchange = {
458
+ id: exchangeId,
459
+ captured_at: capturedAt,
460
+ source_timestamp: sourceTimestamp,
461
+ method: request.method,
462
+ url: request.url,
463
+ status: clone.status,
464
+ request_headers: Object.fromEntries(request.headers.entries()),
465
+ request_body: capturedRequestBody,
466
+ response_headers: Object.fromEntries(clone.headers.entries()),
467
+ response_body: responseBytes && responseBytes.byteLength <= MAX_BODY_BYTES
468
+ ? new TextDecoder().decode(responseBytes)
469
+ : null,
470
+ ...(startedBeforeReady &&
471
+ (request.method === "GET" || request.method === "HEAD") &&
472
+ responseBytes &&
473
+ responseBytes.byteLength <= MAX_BODY_BYTES
474
+ ? { startup_body: responseBytes, status_text: clone.statusText }
475
+ : {}),
476
+ };
477
+ session.capturedBytes += exchangeBytes;
478
+ session.capturedExchanges.push(exchange);
479
+ uploadCapture(session);
480
+ if (!stopped && fullStoryReady && exchange.startup_body) {
481
+ await replayForFullStory(exchange);
482
+ delete exchange.startup_body;
483
+ delete exchange.status_text;
484
+ }
485
+ })().catch(reportError);
486
+ inFlight.add(capture);
487
+ void capture.finally(() => inFlight.delete(capture));
488
+ return response;
489
+ };
490
+ const controller = {
491
+ identify: (user) => {
492
+ if (!stopped)
493
+ identifyFullStoryUser(user);
494
+ },
495
+ async flush() {
496
+ while (inFlight.size)
497
+ await Promise.allSettled([...inFlight]);
498
+ for (const session of sessions) {
499
+ if (session.uploadTimer)
500
+ clearTimeout(session.uploadTimer);
501
+ session.uploadTimer = null;
502
+ await queueCaptureUpload(session);
503
+ }
504
+ if (lastError)
505
+ throw lastError;
506
+ if (!currentSession.sessionUrl)
507
+ throw new Error("FullStory session is not ready");
508
+ },
509
+ async stop() {
510
+ stopped = true;
511
+ for (const eventName of CAPTURED_SESSION_INTERACTION_EVENTS) {
512
+ window.removeEventListener(eventName, recordInteraction, {
513
+ capture: true,
514
+ });
515
+ }
516
+ await controller.flush();
517
+ },
518
+ };
519
+ active = controller;
520
+ activeOptions = { ...options };
521
+ init({ orgId }, async ({ sessionUrl }) => {
522
+ if (stopped && currentSession.sessionUrl !== null)
523
+ return;
524
+ const session = synchronizeSession(sessionUrl);
525
+ const firstStart = !fullStoryReady;
526
+ fullStoryReady = true;
527
+ if (firstStart) {
528
+ for (const exchange of session.capturedExchanges.filter((item) => !stopped && item.startup_body)) {
529
+ await replayForFullStory(exchange);
530
+ delete exchange.startup_body;
531
+ delete exchange.status_text;
532
+ }
533
+ }
534
+ if (session.uploadTimer)
535
+ clearTimeout(session.uploadTimer);
536
+ session.uploadTimer = null;
537
+ await queueCaptureUpload(session);
538
+ });
539
+ // init() above registers the SDK synchronously and queues any API calls made before the session is
540
+ // ready, so identify is safe from here on. Setting the flag outside the ready callback keeps an
541
+ // identify that lands between init() and session-ready working rather than crashing.
542
+ fullStoryInitialized = true;
543
+ return controller;
544
+ }
@@ -0,0 +1,14 @@
1
+ /** QA's versioned auxiliary artifact envelope. Consumers need no package dependency. */
2
+ export interface Artifact {
3
+ namespace: string;
4
+ key: string;
5
+ schema_version: number;
6
+ payload: Record<string, unknown>;
7
+ }
8
+ export interface CaptureBatch {
9
+ session_url: string;
10
+ auxiliary_data: Artifact[];
11
+ }
12
+ export declare const MAX_BATCH_BYTES: number;
13
+ /** Split between whole events, preserving IDs and payloads for identical retries. */
14
+ export declare function splitBatches(input: CaptureBatch, maxBytes?: number): string[];
@@ -0,0 +1,65 @@
1
+ export const MAX_BATCH_BYTES = 256 * 1024;
2
+ /** Split between whole events, preserving IDs and payloads for identical retries. */
3
+ export function splitBatches(input, maxBytes = MAX_BATCH_BYTES) {
4
+ const encode = (artifacts) => JSON.stringify({
5
+ session_url: input.session_url,
6
+ auxiliary_data: artifacts,
7
+ });
8
+ const fits = (artifacts) => new TextEncoder().encode(encode(artifacts)).byteLength <= maxBytes;
9
+ const result = [];
10
+ let pending = [];
11
+ for (const artifact of input.auxiliary_data) {
12
+ const field = artifact.namespace === "network" && artifact.key === "captured-exchanges"
13
+ ? "exchanges"
14
+ : artifact.namespace === "interaction" &&
15
+ artifact.key === "captured-interactions"
16
+ ? "interactions"
17
+ : artifact.namespace === "session" &&
18
+ artifact.key === "capture-context"
19
+ ? "pages"
20
+ : undefined;
21
+ const entries = field ? artifact.payload[field] : undefined;
22
+ if (field && Array.isArray(entries) && entries.length > 0) {
23
+ let chunk = [];
24
+ const withEntries = (values) => ({
25
+ ...artifact,
26
+ payload: { ...artifact.payload, [field]: values },
27
+ });
28
+ for (const entry of entries) {
29
+ const candidate = [...chunk, entry];
30
+ if (fits([...pending, withEntries(candidate)])) {
31
+ chunk = candidate;
32
+ continue;
33
+ }
34
+ if (chunk.length)
35
+ pending.push(withEntries(chunk));
36
+ if (pending.length) {
37
+ result.push(encode(pending));
38
+ pending = [];
39
+ }
40
+ if (!fits([withEntries([entry])]))
41
+ throw new Error(`A ${artifact.namespace}/${artifact.key} event exceeds the ${maxBytes}-byte upload limit`);
42
+ chunk = [entry];
43
+ }
44
+ if (chunk.length)
45
+ pending.push(withEntries(chunk));
46
+ }
47
+ else {
48
+ if (!fits([artifact]))
49
+ throw new Error(`Artifact ${artifact.namespace}/${artifact.key} exceeds the upload limit`);
50
+ if (!fits([...pending, artifact])) {
51
+ result.push(encode(pending));
52
+ pending = [];
53
+ }
54
+ pending.push(artifact);
55
+ }
56
+ // QA accepts at most ten artifacts per registration.
57
+ if (pending.length >= 10) {
58
+ result.push(encode(pending));
59
+ pending = [];
60
+ }
61
+ }
62
+ if (pending.length)
63
+ result.push(encode(pending));
64
+ return result;
65
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@replayio/self-healing-capture",
3
+ "version": "0.1.0",
4
+ "description": "FullStory auxiliary capture for Self Healing and compatible session ingesters",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "sideEffects": false,
19
+ "license": "UNLICENSED",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/replayio/self-healing.git",
23
+ "directory": "packages/capture"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "peerDependencies": {
29
+ "@fullstory/browser": "^2.1.0"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.json",
33
+ "prepack": "npm run build"
34
+ }
35
+ }