@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 +92 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +544 -0
- package/dist/transport.d.ts +14 -0
- package/dist/transport.js +65 -0
- package/package.json +35 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|