@replayio/self-healing-capture 0.1.0 → 0.1.3
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 +37 -64
- package/UPGRADING.md +72 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +72 -43
- package/dist/transport.d.ts +6 -2
- package/dist/transport.js +28 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,73 +1,32 @@
|
|
|
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
|
-
npm install @replayio/self-healing-capture @fullstory/browser
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Call once in your production browser entry point, before rendering the application:
|
|
6
|
+
## Updating an existing installation
|
|
12
7
|
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
**Agents: read [UPGRADING.md](./UPGRADING.md) before updating this dependency.**
|
|
9
|
+
It ships in the npm package at
|
|
10
|
+
`node_modules/@replayio/self-healing-capture/UPGRADING.md` and covers changes to
|
|
11
|
+
application-owned initialization and forwarding code, not just the lockfile.
|
|
12
|
+
No original installer conversation is needed. In particular, old installations
|
|
13
|
+
may contain a 256 KiB forwarding check that a dependency update cannot remove.
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
// When the application authenticates a user:
|
|
23
|
-
capture.identify({ id: user.id, name: user.name, email: user.email })
|
|
24
|
-
```
|
|
15
|
+
**Installation and integration instructions live in the
|
|
16
|
+
[Self Healing setup skill](https://self-healing.replay.io/api/v1/skills/setup-self-healing/SKILL.md).**
|
|
17
|
+
Follow that skill for account provisioning, browser initialization, server forwarding, automatic reviews,
|
|
18
|
+
and verification. This package is an implementation detail of that setup, not a separate installer.
|
|
25
19
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
20
|
+
QA consumes compatible versioned artifacts and recognizes the package/version provenance. Its
|
|
21
|
+
ingestion and review code does not import this package. When Subtext supplies the required accessors,
|
|
22
|
+
the Self Healing skills will describe the replacement and migration.
|
|
66
23
|
|
|
67
24
|
## Development and release
|
|
68
25
|
|
|
69
|
-
The
|
|
70
|
-
|
|
26
|
+
The capture implementation lives in `packages/capture/src`. Change producers here and keep QA’s
|
|
27
|
+
independent ingestion schemas and compatibility tests compatible. Initial installation instructions live in the Self Healing skills. Every release
|
|
28
|
+
that changes application integration requirements must add a versioned migration
|
|
29
|
+
to UPGRADING.md and include verification steps.
|
|
71
30
|
|
|
72
31
|
Publish locally with interactive npm authentication and 2FA:
|
|
73
32
|
|
|
@@ -83,10 +42,24 @@ artifact. npm owns the browser login/security-key/2FA prompts. Credentials and O
|
|
|
83
42
|
arguments or repository secrets. Follow npm's authentication prompt when it appears.
|
|
84
43
|
|
|
85
44
|
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
|
|
87
|
-
the script checks
|
|
88
|
-
|
|
45
|
+
Registry errors fail the version check; already-published versions are skipped. After npm accepts publication,
|
|
46
|
+
the script briefly checks registry visibility. Delayed processing or registry read failures report
|
|
47
|
+
visibility as pending without treating the accepted publication as a failure. Use the printed
|
|
48
|
+
`npm view` command to check later; do not republish. Existing versions are skipped, never overwritten. Temporary tarballs are removed when the script exits.
|
|
89
49
|
|
|
90
50
|
For later releases, update the package version and emitted producer metadata together, update the
|
|
91
51
|
root lockfile, and run the script from the reviewed revision. The producer test checks that metadata
|
|
92
52
|
matches the manifest. Installers receive updates through dependency upgrades and their lockfiles.
|
|
53
|
+
|
|
54
|
+
## Capture failure behavior
|
|
55
|
+
|
|
56
|
+
Version 0.1.1 preserves valid UTF-8 body text exactly. Binary, invalid UTF-8, or NUL-containing
|
|
57
|
+
bodies are recorded as unavailable (`null`), retaining the exchange metadata. Both request and response
|
|
58
|
+
bodies follow this rule, including compressed requests advertised as `text/plain`.
|
|
59
|
+
|
|
60
|
+
An upload failure is reported through `onError` and retained for retry on the next capture upload or
|
|
61
|
+
explicit `flush()`. Later batches are still attempted. Retries preserve event IDs for server deduplication;
|
|
62
|
+
current session context and metrics are sent after retries so an older batch cannot leave stale counters.
|
|
63
|
+
`flush()` rejects while any batch remains undelivered, and succeeds once they have all been accepted.
|
|
64
|
+
|
|
65
|
+
Release tests and builds print concise progress; their full output is shown only if they fail.
|
package/UPGRADING.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Upgrading an existing Self Healing integration
|
|
2
|
+
|
|
3
|
+
This guide ships with `@replayio/self-healing-capture`. Coding agents should read it
|
|
4
|
+
when asked to update the package and reconcile the application integration as well
|
|
5
|
+
as its dependency. The original installation conversation is not required.
|
|
6
|
+
|
|
7
|
+
## Upgrade procedure
|
|
8
|
+
|
|
9
|
+
1. Read the installed package version and the version being installed. Use the
|
|
10
|
+
application's package manager to update the dependency and lockfile together.
|
|
11
|
+
Apply every migration below between those versions. If the old version is
|
|
12
|
+
unknown, inspect the integration against all migrations.
|
|
13
|
+
2. Locate the existing `initCapture` call, identity hook, configured same-origin
|
|
14
|
+
capture endpoint, and server forwarding handler. Search for
|
|
15
|
+
`@replayio/self-healing-capture`, `initCapture`, `SELF_HEALING_URL`, and
|
|
16
|
+
`/api/v1/connection/sessions`. Follow the actual configured endpoint rather than
|
|
17
|
+
assuming the example route name.
|
|
18
|
+
3. Update application-owned installation code as described below. Keep the existing
|
|
19
|
+
account, project connection, credentials, identity integration, and application
|
|
20
|
+
access controls. Do not repeat provisioning or rotate credentials for an upgrade.
|
|
21
|
+
4. Run the application's relevant checks. Verify through its actual forwarding
|
|
22
|
+
route that captured network and interaction artifacts reach Self Healing.
|
|
23
|
+
Once deployed, exercise a real browser session and confirm HTTP 200 with
|
|
24
|
+
`status: "stored"` and a nonempty `session_id`. An initial metadata-only upload
|
|
25
|
+
or a FullStory recording alone does not verify auxiliary capture.
|
|
26
|
+
5. Report the dependency version, application-code changes, checks, and session
|
|
27
|
+
delivery evidence. Distinguish local verification from deployed verification.
|
|
28
|
+
Follow the application's normal review/deployment process; do not claim the
|
|
29
|
+
deployed integration is updated until it has been verified.
|
|
30
|
+
|
|
31
|
+
For the current complete setup contract, see
|
|
32
|
+
https://self-healing.replay.io/api/v1/skills/setup-self-healing/SKILL.md.
|
|
33
|
+
Use it to reconcile the existing integration; do not treat an upgrade as a new setup.
|
|
34
|
+
|
|
35
|
+
## 0.1.3 — Packaged upgrade instructions
|
|
36
|
+
|
|
37
|
+
No runtime integration changes beyond the migrations below. This release includes
|
|
38
|
+
this guide so upgrades can be performed without access to the installer conversation.
|
|
39
|
+
|
|
40
|
+
## 0.1.2 — One configurable limit per network exchange
|
|
41
|
+
|
|
42
|
+
- The embedder option is `maxNetworkCaptureBytes`, defaulting to 1,000,000 bytes.
|
|
43
|
+
It measures the UTF-8 JSON encoding of each exchange, including both bodies,
|
|
44
|
+
headers, and event metadata. Omit the option to use the default; preserve an
|
|
45
|
+
intentional application-specific setting. If installation code uses the
|
|
46
|
+
pre-release `maxBatchBytes` option, replace it with this per-exchange policy.
|
|
47
|
+
- Oversized exchanges are skipped and counted in `dropped_network_count`; later
|
|
48
|
+
captures continue. Batching is internal and includes space for envelope overhead.
|
|
49
|
+
A full-size exchange therefore produces an upload slightly larger than its limit.
|
|
50
|
+
- Earlier setup examples installed a **256 KiB request-size check in the app's
|
|
51
|
+
forwarding handler**. Inspect for `256 * 1024`, `262144`, `413`, or the message
|
|
52
|
+
`Capture exceeds Self Healing request limit` along the capture route. Remove that
|
|
53
|
+
capture-specific check if present. Do not replace it with a 1 MB upload check.
|
|
54
|
+
Inspect capture-route body-parser configuration for the same obsolete restriction;
|
|
55
|
+
do not change unrelated application routes.
|
|
56
|
+
- Forward the original JSON body to `/api/v1/connection/sessions` using server-held
|
|
57
|
+
Self Healing credentials and return the upstream status/body. Do not truncate
|
|
58
|
+
capture fields, reimplement batching, or expose credentials in the browser.
|
|
59
|
+
- Verify an exchange with a body larger than 256 KiB but below the configured
|
|
60
|
+
exchange limit (for example, 500 KB under the default) is actually delivered
|
|
61
|
+
through the forwarding route with its body intact. Also verify a later small
|
|
62
|
+
exchange after an oversized exchange, and check the dropped counter increases.
|
|
63
|
+
|
|
64
|
+
Updating the npm dependency cannot modify an application's existing forwarding
|
|
65
|
+
handler. These application-code migrations are part of the package upgrade.
|
|
66
|
+
|
|
67
|
+
## 0.1.1 — Capture decoding and retry fixes
|
|
68
|
+
|
|
69
|
+
No application-code migration is required. Keep capture initialization and the
|
|
70
|
+
existing identity hook. Failed uploads remain retryable; later batches continue.
|
|
71
|
+
Binary, invalid UTF-8, and NUL-containing bodies are represented as unavailable
|
|
72
|
+
while their exchange metadata remains captured.
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export interface CaptureOptions {
|
|
|
2
2
|
orgId: string;
|
|
3
3
|
/** Same-origin POST route holding the server-side credential. */
|
|
4
4
|
endpoint?: string;
|
|
5
|
+
/** Maximum UTF-8 JSON bytes per network exchange (bodies, headers and metadata). Defaults to 1,000,000. */
|
|
6
|
+
maxNetworkCaptureBytes?: number;
|
|
5
7
|
onError?: (error: Error) => void;
|
|
6
8
|
}
|
|
7
9
|
export interface CaptureController {
|
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, DEFAULT_MAX_NETWORK_CAPTURE_BYTES, } from "./transport.js";
|
|
3
3
|
let active;
|
|
4
4
|
let activeOptions;
|
|
5
5
|
const CAPTURED_SESSION_INTERACTION_EVENTS = [
|
|
@@ -17,7 +17,6 @@ function countsAsSessionInteraction(event) {
|
|
|
17
17
|
event.type === "keydown" &&
|
|
18
18
|
["Enter", "Escape"].includes(event.key)));
|
|
19
19
|
}
|
|
20
|
-
const MAX_BODY_BYTES = 1_000_000;
|
|
21
20
|
const MAX_CAPTURE_BYTES = 8_000_000;
|
|
22
21
|
const MAX_CAPTURED_INTERACTIONS = 5_000;
|
|
23
22
|
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"]';
|
|
@@ -37,15 +36,19 @@ function identifyFullStoryUser(user) {
|
|
|
37
36
|
if (user.email)
|
|
38
37
|
setCapturedUserEmail?.(user.email);
|
|
39
38
|
}
|
|
40
|
-
function shouldStopSessionUploads(status) {
|
|
41
|
-
return status < 200 || status >= 300;
|
|
42
|
-
}
|
|
43
39
|
export function initCapture(options) {
|
|
44
40
|
if (typeof window === "undefined")
|
|
45
41
|
throw new Error("initCapture must run in the browser");
|
|
42
|
+
const maxNetworkCaptureBytes = options.maxNetworkCaptureBytes ?? DEFAULT_MAX_NETWORK_CAPTURE_BYTES;
|
|
43
|
+
if (!Number.isSafeInteger(maxNetworkCaptureBytes) ||
|
|
44
|
+
maxNetworkCaptureBytes <= 0)
|
|
45
|
+
throw new Error("maxNetworkCaptureBytes must be a positive safe integer");
|
|
46
46
|
if (active) {
|
|
47
47
|
if (options.orgId !== activeOptions?.orgId ||
|
|
48
|
-
options.endpoint !== activeOptions?.endpoint
|
|
48
|
+
options.endpoint !== activeOptions?.endpoint ||
|
|
49
|
+
maxNetworkCaptureBytes !==
|
|
50
|
+
(activeOptions?.maxNetworkCaptureBytes ??
|
|
51
|
+
DEFAULT_MAX_NETWORK_CAPTURE_BYTES)) {
|
|
49
52
|
throw new Error("Capture is already initialized with different options");
|
|
50
53
|
}
|
|
51
54
|
return active;
|
|
@@ -88,7 +91,8 @@ export function initCapture(options) {
|
|
|
88
91
|
queuedExchangeCount: 0,
|
|
89
92
|
queuedInteractionCount: -1,
|
|
90
93
|
queuedCapturedInteractionCount: 0,
|
|
91
|
-
|
|
94
|
+
pendingBatches: [],
|
|
95
|
+
uploadError: undefined,
|
|
92
96
|
userEmail,
|
|
93
97
|
queuedUserEmail: null,
|
|
94
98
|
uploadTimer: null,
|
|
@@ -140,11 +144,9 @@ export function initCapture(options) {
|
|
|
140
144
|
session.userEmail = email;
|
|
141
145
|
uploadCapture(session);
|
|
142
146
|
};
|
|
143
|
-
async function
|
|
147
|
+
async function captureBody(value) {
|
|
144
148
|
const bytes = await value.clone().arrayBuffer();
|
|
145
|
-
|
|
146
|
-
return null;
|
|
147
|
-
return new TextDecoder().decode(bytes);
|
|
149
|
+
return decodeCaptureBody(bytes);
|
|
148
150
|
}
|
|
149
151
|
async function sendCaptureBatch(body) {
|
|
150
152
|
for (let attempt = 0;; attempt++) {
|
|
@@ -172,8 +174,12 @@ export function initCapture(options) {
|
|
|
172
174
|
}
|
|
173
175
|
}
|
|
174
176
|
function queueCaptureUpload(session) {
|
|
175
|
-
if (!session.sessionUrl
|
|
177
|
+
if (!session.sessionUrl)
|
|
176
178
|
return Promise.resolve();
|
|
179
|
+
session.uploadChain = session.uploadChain.then(() => drainCaptureUpload(session));
|
|
180
|
+
return session.uploadChain;
|
|
181
|
+
}
|
|
182
|
+
async function drainCaptureUpload(session) {
|
|
177
183
|
const exchanges = session.capturedExchanges
|
|
178
184
|
.slice(session.queuedExchangeCount)
|
|
179
185
|
.map(({ startup_body: _body, status_text: _status, ...exchange }) => exchange)
|
|
@@ -190,9 +196,9 @@ export function initCapture(options) {
|
|
|
190
196
|
namespace: "session",
|
|
191
197
|
key: "capture-producer",
|
|
192
198
|
schema_version: 1,
|
|
193
|
-
payload: { name: "@replayio/self-healing-capture", version: "0.1.
|
|
199
|
+
payload: { name: "@replayio/self-healing-capture", version: "0.1.3" },
|
|
194
200
|
},
|
|
195
|
-
...(context !== session.queuedContext
|
|
201
|
+
...(context !== session.queuedContext || session.pendingBatches.length > 0
|
|
196
202
|
? [
|
|
197
203
|
{
|
|
198
204
|
namespace: "session",
|
|
@@ -237,7 +243,8 @@ export function initCapture(options) {
|
|
|
237
243
|
},
|
|
238
244
|
]
|
|
239
245
|
: []),
|
|
240
|
-
...(session.interactionCount !== session.queuedInteractionCount
|
|
246
|
+
...(session.interactionCount !== session.queuedInteractionCount ||
|
|
247
|
+
session.pendingBatches.length > 0
|
|
241
248
|
? [
|
|
242
249
|
{
|
|
243
250
|
namespace: "session",
|
|
@@ -250,7 +257,9 @@ export function initCapture(options) {
|
|
|
250
257
|
},
|
|
251
258
|
]
|
|
252
259
|
: []),
|
|
253
|
-
...(session.userEmail &&
|
|
260
|
+
...(session.userEmail &&
|
|
261
|
+
(session.userEmail !== session.queuedUserEmail ||
|
|
262
|
+
session.pendingBatches.length > 0)
|
|
254
263
|
? [
|
|
255
264
|
{
|
|
256
265
|
namespace: "session",
|
|
@@ -261,39 +270,51 @@ export function initCapture(options) {
|
|
|
261
270
|
]
|
|
262
271
|
: []),
|
|
263
272
|
];
|
|
264
|
-
if (auxiliaryData.length === 1)
|
|
265
|
-
return
|
|
273
|
+
if (auxiliaryData.length === 1 && !session.pendingBatches.length)
|
|
274
|
+
return;
|
|
275
|
+
let batches;
|
|
276
|
+
try {
|
|
277
|
+
batches = splitBatches({
|
|
278
|
+
session_url: session.sessionUrl,
|
|
279
|
+
auxiliary_data: auxiliaryData,
|
|
280
|
+
}, maxNetworkCaptureBytes);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
reportError(error);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
266
286
|
session.queuedContext = context;
|
|
267
287
|
session.queuedExchangeCount = session.capturedExchanges.length;
|
|
268
288
|
session.queuedInteractionCount = session.interactionCount;
|
|
269
289
|
session.queuedCapturedInteractionCount =
|
|
270
290
|
session.capturedInteractions.length;
|
|
271
291
|
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)) {
|
|
292
|
+
session.pendingBatches.push(...batches);
|
|
293
|
+
const failed = [];
|
|
294
|
+
session.uploadError = undefined;
|
|
295
|
+
// Retry identical event IDs, but a rejected batch must not block later interactions.
|
|
296
|
+
for (const batch of session.pendingBatches) {
|
|
297
|
+
try {
|
|
282
298
|
const response = await sendCaptureBatch(batch);
|
|
283
|
-
if (
|
|
284
|
-
session.uploadsStopped = true;
|
|
299
|
+
if (!response.ok)
|
|
285
300
|
throw new Error(`Capture upload failed: ${response.status}`);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
failed.push(batch);
|
|
304
|
+
session.uploadError =
|
|
305
|
+
error instanceof Error ? error : new Error(String(error));
|
|
306
|
+
try {
|
|
307
|
+
(options.onError ?? console.error)(session.uploadError);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
/* observers cannot break the app */
|
|
286
311
|
}
|
|
287
312
|
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
session.uploadsStopped = true;
|
|
291
|
-
reportError(error);
|
|
292
|
-
});
|
|
293
|
-
return session.uploadChain;
|
|
313
|
+
}
|
|
314
|
+
session.pendingBatches = failed;
|
|
294
315
|
}
|
|
295
316
|
function uploadCapture(session) {
|
|
296
|
-
if (!session.sessionUrl
|
|
317
|
+
if (!session.sessionUrl)
|
|
297
318
|
return;
|
|
298
319
|
if (session.uploadTimer)
|
|
299
320
|
return;
|
|
@@ -431,7 +452,7 @@ export function initCapture(options) {
|
|
|
431
452
|
const capturedAt = performance.timeOrigin + sourceTimestamp;
|
|
432
453
|
const exchangeId = crypto.randomUUID();
|
|
433
454
|
const requestBody = request.method !== "GET" && request.method !== "HEAD"
|
|
434
|
-
?
|
|
455
|
+
? captureBody(request).catch(() => null)
|
|
435
456
|
: Promise.resolve(null);
|
|
436
457
|
const responsePromise = nativeFetch(request);
|
|
437
458
|
inFlight.add(responsePromise);
|
|
@@ -464,16 +485,20 @@ export function initCapture(options) {
|
|
|
464
485
|
request_headers: Object.fromEntries(request.headers.entries()),
|
|
465
486
|
request_body: capturedRequestBody,
|
|
466
487
|
response_headers: Object.fromEntries(clone.headers.entries()),
|
|
467
|
-
response_body: responseBytes
|
|
468
|
-
? new TextDecoder().decode(responseBytes)
|
|
469
|
-
: null,
|
|
488
|
+
response_body: responseBytes ? decodeCaptureBody(responseBytes) : null,
|
|
470
489
|
...(startedBeforeReady &&
|
|
471
490
|
(request.method === "GET" || request.method === "HEAD") &&
|
|
472
|
-
responseBytes
|
|
473
|
-
responseBytes.byteLength <= MAX_BODY_BYTES
|
|
491
|
+
responseBytes
|
|
474
492
|
? { startup_body: responseBytes, status_text: clone.statusText }
|
|
475
493
|
: {}),
|
|
476
494
|
};
|
|
495
|
+
const { startup_body: _startup, status_text: _status, ...uploadedExchange } = exchange;
|
|
496
|
+
if (new TextEncoder().encode(JSON.stringify(uploadedExchange)).byteLength >
|
|
497
|
+
maxNetworkCaptureBytes) {
|
|
498
|
+
session.droppedNetworkCount++;
|
|
499
|
+
uploadCapture(session);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
477
502
|
session.capturedBytes += exchangeBytes;
|
|
478
503
|
session.capturedExchanges.push(exchange);
|
|
479
504
|
uploadCapture(session);
|
|
@@ -503,6 +528,10 @@ export function initCapture(options) {
|
|
|
503
528
|
}
|
|
504
529
|
if (lastError)
|
|
505
530
|
throw lastError;
|
|
531
|
+
for (const session of sessions) {
|
|
532
|
+
if (session.uploadError)
|
|
533
|
+
throw session.uploadError;
|
|
534
|
+
}
|
|
506
535
|
if (!currentSession.sessionUrl)
|
|
507
536
|
throw new Error("FullStory session is not ready");
|
|
508
537
|
},
|
package/dist/transport.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export interface CaptureBatch {
|
|
|
9
9
|
session_url: string;
|
|
10
10
|
auxiliary_data: Artifact[];
|
|
11
11
|
}
|
|
12
|
-
export declare const
|
|
12
|
+
export declare const DEFAULT_MAX_NETWORK_CAPTURE_BYTES = 1000000;
|
|
13
|
+
/** Derive the upload budget from the capture limit, allowing one full exchange plus its envelope. */
|
|
14
|
+
export declare function uploadBatchBytes(sessionUrl: string, maxNetworkCaptureBytes?: number): number;
|
|
13
15
|
/** Split between whole events, preserving IDs and payloads for identical retries. */
|
|
14
|
-
export declare function splitBatches(input: CaptureBatch,
|
|
16
|
+
export declare function splitBatches(input: CaptureBatch, maxNetworkCaptureBytes?: number): string[];
|
|
17
|
+
/** Binary or NUL-containing bodies cannot be represented in the text-only capture schema. */
|
|
18
|
+
export declare function decodeCaptureBody(bytes: ArrayBuffer): string | null;
|
package/dist/transport.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
export const
|
|
1
|
+
export const DEFAULT_MAX_NETWORK_CAPTURE_BYTES = 1_000_000;
|
|
2
|
+
/** Derive the upload budget from the capture limit, allowing one full exchange plus its envelope. */
|
|
3
|
+
export function uploadBatchBytes(sessionUrl, maxNetworkCaptureBytes = DEFAULT_MAX_NETWORK_CAPTURE_BYTES) {
|
|
4
|
+
return (maxNetworkCaptureBytes +
|
|
5
|
+
new TextEncoder().encode(JSON.stringify({
|
|
6
|
+
session_url: sessionUrl,
|
|
7
|
+
auxiliary_data: [
|
|
8
|
+
{
|
|
9
|
+
namespace: "network",
|
|
10
|
+
key: "captured-exchanges",
|
|
11
|
+
schema_version: 1,
|
|
12
|
+
payload: { version: 1, exchanges: [] },
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
})).byteLength);
|
|
16
|
+
}
|
|
2
17
|
/** Split between whole events, preserving IDs and payloads for identical retries. */
|
|
3
|
-
export function splitBatches(input,
|
|
18
|
+
export function splitBatches(input, maxNetworkCaptureBytes = DEFAULT_MAX_NETWORK_CAPTURE_BYTES) {
|
|
19
|
+
const maxBytes = uploadBatchBytes(input.session_url, maxNetworkCaptureBytes);
|
|
4
20
|
const encode = (artifacts) => JSON.stringify({
|
|
5
21
|
session_url: input.session_url,
|
|
6
22
|
auxiliary_data: artifacts,
|
|
@@ -63,3 +79,13 @@ export function splitBatches(input, maxBytes = MAX_BATCH_BYTES) {
|
|
|
63
79
|
result.push(encode(pending));
|
|
64
80
|
return result;
|
|
65
81
|
}
|
|
82
|
+
/** Binary or NUL-containing bodies cannot be represented in the text-only capture schema. */
|
|
83
|
+
export function decodeCaptureBody(bytes) {
|
|
84
|
+
try {
|
|
85
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
86
|
+
return text.includes("\0") ? null : text;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@replayio/self-healing-capture",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "FullStory auxiliary capture for Self Healing and compatible session ingesters",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
-
"README.md"
|
|
16
|
+
"README.md",
|
|
17
|
+
"UPGRADING.md"
|
|
17
18
|
],
|
|
18
19
|
"sideEffects": false,
|
|
19
20
|
"license": "UNLICENSED",
|