@replayio/self-healing-capture 0.1.0 → 0.1.1
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 +23 -63
- package/dist/index.js +54 -33
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,73 +1,22 @@
|
|
|
1
1
|
# @replayio/self-healing-capture
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
context. Install the package rather than copying a recorder into your app.
|
|
3
|
+
Temporary browser capture support for Self Healing while Subtext gains the accessors needed for
|
|
4
|
+
network exchanges, interactions, identity, metrics, and session context.
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
6
|
+
**Installation and integration instructions live in the
|
|
7
|
+
[Self Healing setup skill](https://self-healing.replay.io/api/v1/skills/setup-self-healing/SKILL.md).**
|
|
8
|
+
Follow that skill for account provisioning, browser initialization, server forwarding, automatic reviews,
|
|
9
|
+
and verification. This package is an implementation detail of that setup, not a separate installer.
|
|
31
10
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
11
|
+
QA consumes compatible versioned artifacts and recognizes the package/version provenance. Its
|
|
12
|
+
ingestion and review code does not import this package. When Subtext supplies the required accessors,
|
|
13
|
+
the Self Healing skills will describe the replacement and migration.
|
|
66
14
|
|
|
67
15
|
## Development and release
|
|
68
16
|
|
|
69
|
-
The
|
|
70
|
-
|
|
17
|
+
The capture implementation lives in `packages/capture/src`. Change producers here and keep QA’s
|
|
18
|
+
independent ingestion schemas and compatibility tests compatible. Installation instructions belong
|
|
19
|
+
only in the Self Healing skills.
|
|
71
20
|
|
|
72
21
|
Publish locally with interactive npm authentication and 2FA:
|
|
73
22
|
|
|
@@ -90,3 +39,14 @@ existing version is skipped, never overwritten. Temporary tarballs are removed w
|
|
|
90
39
|
For later releases, update the package version and emitted producer metadata together, update the
|
|
91
40
|
root lockfile, and run the script from the reviewed revision. The producer test checks that metadata
|
|
92
41
|
matches the manifest. Installers receive updates through dependency upgrades and their lockfiles.
|
|
42
|
+
|
|
43
|
+
## Capture failure behavior
|
|
44
|
+
|
|
45
|
+
Version 0.1.1 preserves valid UTF-8 body text exactly. Binary, invalid UTF-8, or NUL-containing
|
|
46
|
+
bodies are recorded as unavailable (`null`), retaining the exchange metadata. Both request and response
|
|
47
|
+
bodies follow this rule, including compressed requests advertised as `text/plain`.
|
|
48
|
+
|
|
49
|
+
An upload failure is reported through `onError` and retained for retry on the next capture upload or
|
|
50
|
+
explicit `flush()`. Later batches are still attempted. Retries preserve event IDs for server deduplication;
|
|
51
|
+
current session context and metrics are sent after retries so an older batch cannot leave stale counters.
|
|
52
|
+
`flush()` rejects while any batch remains undelivered, and succeeds once they have all been accepted.
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FullStory, init } from "@fullstory/browser";
|
|
2
|
-
import { splitBatches } from "./transport.js";
|
|
2
|
+
import { decodeCaptureBody, splitBatches } from "./transport.js";
|
|
3
3
|
let active;
|
|
4
4
|
let activeOptions;
|
|
5
5
|
const CAPTURED_SESSION_INTERACTION_EVENTS = [
|
|
@@ -37,9 +37,6 @@ function identifyFullStoryUser(user) {
|
|
|
37
37
|
if (user.email)
|
|
38
38
|
setCapturedUserEmail?.(user.email);
|
|
39
39
|
}
|
|
40
|
-
function shouldStopSessionUploads(status) {
|
|
41
|
-
return status < 200 || status >= 300;
|
|
42
|
-
}
|
|
43
40
|
export function initCapture(options) {
|
|
44
41
|
if (typeof window === "undefined")
|
|
45
42
|
throw new Error("initCapture must run in the browser");
|
|
@@ -88,7 +85,8 @@ export function initCapture(options) {
|
|
|
88
85
|
queuedExchangeCount: 0,
|
|
89
86
|
queuedInteractionCount: -1,
|
|
90
87
|
queuedCapturedInteractionCount: 0,
|
|
91
|
-
|
|
88
|
+
pendingBatches: [],
|
|
89
|
+
uploadError: undefined,
|
|
92
90
|
userEmail,
|
|
93
91
|
queuedUserEmail: null,
|
|
94
92
|
uploadTimer: null,
|
|
@@ -144,7 +142,7 @@ export function initCapture(options) {
|
|
|
144
142
|
const bytes = await value.clone().arrayBuffer();
|
|
145
143
|
if (bytes.byteLength > MAX_BODY_BYTES)
|
|
146
144
|
return null;
|
|
147
|
-
return
|
|
145
|
+
return decodeCaptureBody(bytes);
|
|
148
146
|
}
|
|
149
147
|
async function sendCaptureBatch(body) {
|
|
150
148
|
for (let attempt = 0;; attempt++) {
|
|
@@ -172,8 +170,12 @@ export function initCapture(options) {
|
|
|
172
170
|
}
|
|
173
171
|
}
|
|
174
172
|
function queueCaptureUpload(session) {
|
|
175
|
-
if (!session.sessionUrl
|
|
173
|
+
if (!session.sessionUrl)
|
|
176
174
|
return Promise.resolve();
|
|
175
|
+
session.uploadChain = session.uploadChain.then(() => drainCaptureUpload(session));
|
|
176
|
+
return session.uploadChain;
|
|
177
|
+
}
|
|
178
|
+
async function drainCaptureUpload(session) {
|
|
177
179
|
const exchanges = session.capturedExchanges
|
|
178
180
|
.slice(session.queuedExchangeCount)
|
|
179
181
|
.map(({ startup_body: _body, status_text: _status, ...exchange }) => exchange)
|
|
@@ -190,9 +192,9 @@ export function initCapture(options) {
|
|
|
190
192
|
namespace: "session",
|
|
191
193
|
key: "capture-producer",
|
|
192
194
|
schema_version: 1,
|
|
193
|
-
payload: { name: "@replayio/self-healing-capture", version: "0.1.
|
|
195
|
+
payload: { name: "@replayio/self-healing-capture", version: "0.1.1" },
|
|
194
196
|
},
|
|
195
|
-
...(context !== session.queuedContext
|
|
197
|
+
...(context !== session.queuedContext || session.pendingBatches.length > 0
|
|
196
198
|
? [
|
|
197
199
|
{
|
|
198
200
|
namespace: "session",
|
|
@@ -237,7 +239,8 @@ export function initCapture(options) {
|
|
|
237
239
|
},
|
|
238
240
|
]
|
|
239
241
|
: []),
|
|
240
|
-
...(session.interactionCount !== session.queuedInteractionCount
|
|
242
|
+
...(session.interactionCount !== session.queuedInteractionCount ||
|
|
243
|
+
session.pendingBatches.length > 0
|
|
241
244
|
? [
|
|
242
245
|
{
|
|
243
246
|
namespace: "session",
|
|
@@ -250,7 +253,9 @@ export function initCapture(options) {
|
|
|
250
253
|
},
|
|
251
254
|
]
|
|
252
255
|
: []),
|
|
253
|
-
...(session.userEmail &&
|
|
256
|
+
...(session.userEmail &&
|
|
257
|
+
(session.userEmail !== session.queuedUserEmail ||
|
|
258
|
+
session.pendingBatches.length > 0)
|
|
254
259
|
? [
|
|
255
260
|
{
|
|
256
261
|
namespace: "session",
|
|
@@ -261,39 +266,51 @@ export function initCapture(options) {
|
|
|
261
266
|
]
|
|
262
267
|
: []),
|
|
263
268
|
];
|
|
264
|
-
if (auxiliaryData.length === 1)
|
|
265
|
-
return
|
|
269
|
+
if (auxiliaryData.length === 1 && !session.pendingBatches.length)
|
|
270
|
+
return;
|
|
271
|
+
let batches;
|
|
272
|
+
try {
|
|
273
|
+
batches = splitBatches({
|
|
274
|
+
session_url: session.sessionUrl,
|
|
275
|
+
auxiliary_data: auxiliaryData,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
reportError(error);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
266
282
|
session.queuedContext = context;
|
|
267
283
|
session.queuedExchangeCount = session.capturedExchanges.length;
|
|
268
284
|
session.queuedInteractionCount = session.interactionCount;
|
|
269
285
|
session.queuedCapturedInteractionCount =
|
|
270
286
|
session.capturedInteractions.length;
|
|
271
287
|
session.queuedUserEmail = session.userEmail;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
.then(async () => {
|
|
279
|
-
if (session.uploadsStopped)
|
|
280
|
-
return;
|
|
281
|
-
for (const batch of splitBatches(body)) {
|
|
288
|
+
session.pendingBatches.push(...batches);
|
|
289
|
+
const failed = [];
|
|
290
|
+
session.uploadError = undefined;
|
|
291
|
+
// Retry identical event IDs, but a rejected batch must not block later interactions.
|
|
292
|
+
for (const batch of session.pendingBatches) {
|
|
293
|
+
try {
|
|
282
294
|
const response = await sendCaptureBatch(batch);
|
|
283
|
-
if (
|
|
284
|
-
session.uploadsStopped = true;
|
|
295
|
+
if (!response.ok)
|
|
285
296
|
throw new Error(`Capture upload failed: ${response.status}`);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
failed.push(batch);
|
|
300
|
+
session.uploadError =
|
|
301
|
+
error instanceof Error ? error : new Error(String(error));
|
|
302
|
+
try {
|
|
303
|
+
(options.onError ?? console.error)(session.uploadError);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
/* observers cannot break the app */
|
|
286
307
|
}
|
|
287
308
|
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
session.uploadsStopped = true;
|
|
291
|
-
reportError(error);
|
|
292
|
-
});
|
|
293
|
-
return session.uploadChain;
|
|
309
|
+
}
|
|
310
|
+
session.pendingBatches = failed;
|
|
294
311
|
}
|
|
295
312
|
function uploadCapture(session) {
|
|
296
|
-
if (!session.sessionUrl
|
|
313
|
+
if (!session.sessionUrl)
|
|
297
314
|
return;
|
|
298
315
|
if (session.uploadTimer)
|
|
299
316
|
return;
|
|
@@ -465,7 +482,7 @@ export function initCapture(options) {
|
|
|
465
482
|
request_body: capturedRequestBody,
|
|
466
483
|
response_headers: Object.fromEntries(clone.headers.entries()),
|
|
467
484
|
response_body: responseBytes && responseBytes.byteLength <= MAX_BODY_BYTES
|
|
468
|
-
?
|
|
485
|
+
? decodeCaptureBody(responseBytes)
|
|
469
486
|
: null,
|
|
470
487
|
...(startedBeforeReady &&
|
|
471
488
|
(request.method === "GET" || request.method === "HEAD") &&
|
|
@@ -503,6 +520,10 @@ export function initCapture(options) {
|
|
|
503
520
|
}
|
|
504
521
|
if (lastError)
|
|
505
522
|
throw lastError;
|
|
523
|
+
for (const session of sessions) {
|
|
524
|
+
if (session.uploadError)
|
|
525
|
+
throw session.uploadError;
|
|
526
|
+
}
|
|
506
527
|
if (!currentSession.sessionUrl)
|
|
507
528
|
throw new Error("FullStory session is not ready");
|
|
508
529
|
},
|
package/dist/transport.d.ts
CHANGED
|
@@ -12,3 +12,5 @@ export interface CaptureBatch {
|
|
|
12
12
|
export declare const MAX_BATCH_BYTES: number;
|
|
13
13
|
/** Split between whole events, preserving IDs and payloads for identical retries. */
|
|
14
14
|
export declare function splitBatches(input: CaptureBatch, maxBytes?: number): string[];
|
|
15
|
+
/** Binary or NUL-containing bodies cannot be represented in the text-only capture schema. */
|
|
16
|
+
export declare function decodeCaptureBody(bytes: ArrayBuffer): string | null;
|
package/dist/transport.js
CHANGED
|
@@ -63,3 +63,13 @@ export function splitBatches(input, maxBytes = MAX_BATCH_BYTES) {
|
|
|
63
63
|
result.push(encode(pending));
|
|
64
64
|
return result;
|
|
65
65
|
}
|
|
66
|
+
/** Binary or NUL-containing bodies cannot be represented in the text-only capture schema. */
|
|
67
|
+
export function decodeCaptureBody(bytes) {
|
|
68
|
+
try {
|
|
69
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
70
|
+
return text.includes("\0") ? null : text;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|