@maple-dev/browser 0.11.1 → 0.13.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 +23 -9
- package/dist/console-iNhlufZV.mjs +183 -0
- package/dist/{deferred-B8pFPgZ_.mjs → deferred-Dr2AmxSU.mjs} +32 -5
- package/dist/index.d.mts +12 -5
- package/dist/index.mjs +1 -1
- package/dist/react.d.mts +7 -4
- package/dist/react.mjs +43 -5
- package/dist/replay-session-BuUOwHnI.mjs +906 -0
- package/dist/{sink-DbEuphC-.mjs → sink-Ji0J42ul.mjs} +848 -188
- package/dist/{src-CCfykEMo.mjs → src-JUynR8Jc.mjs} +535 -169
- package/package.json +9 -6
- package/dist/console-Bkd3hBGp.mjs +0 -73
- package/dist/replay-session-D0zjvZIV.mjs +0 -596
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
import { B as withStartedTraceId, F as BLOCK_SELECTOR, I as safeEmit, L as truncateText, N as warnDropped, O as postSessionBlob, R as activeTraceId, T as gzipStored, U as consentRevokedAt, X as scrubUrl, Y as isJsonObject, Z as mayUseStorage, _ as markReplayTriggered, b as patchMethod, d as startEventSink, g as markReplayHalted, h as markActivity, j as reserveKeepalive, k as postSessionMeta, m as isReplayHalted, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, v as nextChunkSeq, w as gzip } from "./sink-Ji0J42ul.mjs";
|
|
2
|
+
import { b as clearPendingChunk, y as PENDING_CHUNK_KEY } from "./src-JUynR8Jc.mjs";
|
|
3
|
+
import { t as installConsoleCapture } from "./console-iNhlufZV.mjs";
|
|
4
|
+
import { record } from "rrweb";
|
|
5
|
+
//#region ../browser-session/src/replay/capture/network.ts
|
|
6
|
+
/** Only text is worth keeping; a body that is an image or a stream is not read. */
|
|
7
|
+
const TEXT_CONTENT = /^(text\/|application\/(json|xml|x-www-form-urlencoded|[\w.+-]+\+(json|xml)))/i;
|
|
8
|
+
/** Text, but never finished: reading a clone would hold the connection open after the app cancels. */
|
|
9
|
+
const STREAMING_CONTENT = /^text\/event-stream/i;
|
|
10
|
+
/** A slow body is given up on rather than kept in memory for as long as it trickles. */
|
|
11
|
+
const BODY_READ_TIMEOUT_MS = 5e3;
|
|
12
|
+
const isReadableText = (contentType) => TEXT_CONTENT.test(contentType) && !STREAMING_CONTENT.test(contentType);
|
|
13
|
+
/** Patterns match the full URL, as documented: a relative `fetch("/api")` is resolved against the page. */
|
|
14
|
+
const absoluteUrl = (url) => {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(url, location.href).href;
|
|
17
|
+
} catch {
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const matchesUrl = (url, patterns) => patterns.some((pattern) => {
|
|
22
|
+
if (typeof pattern === "string") return url.includes(pattern);
|
|
23
|
+
if (!(pattern instanceof RegExp)) return false;
|
|
24
|
+
pattern.lastIndex = 0;
|
|
25
|
+
return pattern.test(url);
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* JSON keys and form fields whose values are credentials whatever the app
|
|
29
|
+
* calls them: tokens, secrets, passwords, API keys, cookies, auth headers.
|
|
30
|
+
*/
|
|
31
|
+
const CREDENTIAL_KEY = /token|secret|pass(word|wd|phrase|code)|(^|[-_])(pwd|pass)([-_]|$)|authori[sz]ation|(^|[-_])auth([-_]|$)|api[-_]?key|cookie|credential|private[-_]?key|access[-_]?key|(^|[-_])(otp|cvv|cvc|ssn)([-_]|$)|card[-_]?number/i;
|
|
32
|
+
/** A JSON member: a string key and its string (possibly cut off) or bare value. */
|
|
33
|
+
const JSON_MEMBER = /"((?:[^"\\]|\\.)*)"(\s*:\s*)("(?:[^"\\]|\\.)*"?|[^\s,}\]"{[]+)/g;
|
|
34
|
+
/** A form field: `key=value`, at the start or after `&`. */
|
|
35
|
+
const FORM_FIELD = /(^|&)([^=&]*)=([^&]*)/g;
|
|
36
|
+
const REDACTED = "[REDACTED]";
|
|
37
|
+
/**
|
|
38
|
+
* Blank the values of credential-looking keys in a JSON or form body. Works on
|
|
39
|
+
* a prefix too: a body cut mid-value still loses the value.
|
|
40
|
+
*/
|
|
41
|
+
function redactCredentials(body) {
|
|
42
|
+
return redactJson(body) ?? redactText(body);
|
|
43
|
+
}
|
|
44
|
+
/** The text passes: JSON members and form fields, on a whole body or one string value. */
|
|
45
|
+
function redactText(body) {
|
|
46
|
+
return body.replace(JSON_MEMBER, (member, key, colon) => CREDENTIAL_KEY.test(key) ? `"${key}"${colon}"${REDACTED}"` : member).replace(FORM_FIELD, (field, lead, key) => CREDENTIAL_KEY.test(safeDecode(key)) ? `${lead}${key}=${REDACTED}` : field);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A whole JSON body, walked: a credential key loses its value even when that
|
|
50
|
+
* value is an object or array, which the member regex cannot see into.
|
|
51
|
+
*/
|
|
52
|
+
function redactJson(body) {
|
|
53
|
+
if (body.length > 1048576) return void 0;
|
|
54
|
+
const start = body.trimStart()[0];
|
|
55
|
+
if (start !== "{" && start !== "[") return void 0;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.stringify(JSON.parse(body, (key, value) => key !== "" && CREDENTIAL_KEY.test(key) ? REDACTED : typeof value === "string" ? redactText(value) : value));
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function safeDecode(text) {
|
|
63
|
+
try {
|
|
64
|
+
return decodeURIComponent(text.replace(/\+/g, " "));
|
|
65
|
+
} catch {
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const cut = (text, maxLength) => truncateText(redactCredentials(text), maxLength, "…");
|
|
70
|
+
/**
|
|
71
|
+
* Capture fetch + XHR requests as session events, tagged with the active trace
|
|
72
|
+
* id so each request links to its backend trace. `ignoreUrl` skips Maple's own
|
|
73
|
+
* ingest endpoints (otherwise capturing the session-events POST would loop).
|
|
74
|
+
* `bodies`, when set, keeps text request/response bodies of the URLs it lists,
|
|
75
|
+
* with credential-looking values blanked.
|
|
76
|
+
*
|
|
77
|
+
* Teardown leaves a wrapper something else installed on top of in place, inert
|
|
78
|
+
* (see `patchMethod`), so an app's own interceptor keeps working.
|
|
79
|
+
*/
|
|
80
|
+
function installNetworkCapture(emit, ignoreUrl, bodies) {
|
|
81
|
+
const hooks = {
|
|
82
|
+
wantsBody: (url) => bodies !== void 0 && matchesUrl(absoluteUrl(url), bodies.urls),
|
|
83
|
+
bodyAttrs: (request, response) => ({
|
|
84
|
+
...request && bodies?.requestBodies !== false ? { "request.body": cut(request, bodies?.maxLength ?? 0) } : void 0,
|
|
85
|
+
...response && bodies ? { "response.body": cut(response, bodies.maxLength) } : void 0
|
|
86
|
+
}),
|
|
87
|
+
maxLength: bodies?.maxLength ?? 0,
|
|
88
|
+
record: (url, method, status, start, traceId, error, extra, end = performance.now()) => {
|
|
89
|
+
if (ignoreUrl(url)) return;
|
|
90
|
+
const attrs = {
|
|
91
|
+
...extra,
|
|
92
|
+
...error ? { error } : void 0
|
|
93
|
+
};
|
|
94
|
+
safeEmit(emit, {
|
|
95
|
+
type: "network",
|
|
96
|
+
net: {
|
|
97
|
+
method,
|
|
98
|
+
url,
|
|
99
|
+
status,
|
|
100
|
+
durationMs: Math.round(end - start)
|
|
101
|
+
},
|
|
102
|
+
traceId,
|
|
103
|
+
...Object.keys(attrs).length > 0 ? { attrs } : void 0
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
if (typeof window === "undefined") return () => {};
|
|
108
|
+
const uninstall = [];
|
|
109
|
+
if (typeof window.fetch === "function") uninstall.push(patchMethod(window, "fetch", hooks, wrapFetch));
|
|
110
|
+
const XHR = window.XMLHttpRequest;
|
|
111
|
+
if (XHR && typeof XHR.prototype.open === "function" && typeof XHR.prototype.send === "function") {
|
|
112
|
+
uninstall.push(patchMethod(XHR.prototype, "open", hooks, wrapXhrOpen));
|
|
113
|
+
uninstall.push(patchMethod(XHR.prototype, "send", hooks, wrapXhrSend));
|
|
114
|
+
}
|
|
115
|
+
return () => {
|
|
116
|
+
for (const off of uninstall) off();
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function wrapFetch(original, hooks) {
|
|
120
|
+
return async (input, init) => {
|
|
121
|
+
const live = hooks();
|
|
122
|
+
if (!live) return original(input, init);
|
|
123
|
+
const url = requestUrl(input);
|
|
124
|
+
const method = requestMethod(input, init);
|
|
125
|
+
const ambientTraceId = activeTraceId();
|
|
126
|
+
const start = performance.now();
|
|
127
|
+
let traceId = ambientTraceId;
|
|
128
|
+
try {
|
|
129
|
+
const call = withStartedTraceId(() => original(input, init));
|
|
130
|
+
traceId = call.traceId ?? ambientTraceId;
|
|
131
|
+
const res = await call.result;
|
|
132
|
+
if (!live.wantsBody(url)) {
|
|
133
|
+
live.record(url, method, res.status, start, traceId);
|
|
134
|
+
return res;
|
|
135
|
+
}
|
|
136
|
+
const requestBody = typeof init?.body === "string" ? init.body : void 0;
|
|
137
|
+
const done = performance.now();
|
|
138
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
139
|
+
(isReadableText(contentType) ? readPrefix(res.clone(), live.maxLength) : Promise.resolve(void 0)).catch(() => void 0).then((responseBody) => live.record(url, method, res.status, start, traceId, void 0, live.bodyAttrs(requestBody, responseBody), done));
|
|
140
|
+
return res;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
live.record(url, method, 0, start, traceId, String(error));
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function wrapXhrOpen(original, hooks) {
|
|
148
|
+
return function(method, url, ...rest) {
|
|
149
|
+
const call = () => original.apply(this, [
|
|
150
|
+
method,
|
|
151
|
+
url,
|
|
152
|
+
...rest
|
|
153
|
+
]);
|
|
154
|
+
if (!hooks()) return call();
|
|
155
|
+
const meta = xhrMeta(this);
|
|
156
|
+
meta.__mapleMethod = String(method).toUpperCase();
|
|
157
|
+
meta.__mapleUrl = String(url);
|
|
158
|
+
const started = withStartedTraceId(call);
|
|
159
|
+
meta.__mapleTraceId = started.traceId;
|
|
160
|
+
return started.result;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function wrapXhrSend(original, hooks) {
|
|
164
|
+
return function(...args) {
|
|
165
|
+
const live = hooks();
|
|
166
|
+
if (!live) return original.apply(this, args);
|
|
167
|
+
const meta = xhrMeta(this);
|
|
168
|
+
const start = performance.now();
|
|
169
|
+
let traceId = meta.__mapleTraceId ?? activeTraceId();
|
|
170
|
+
const url = meta.__mapleUrl ?? "";
|
|
171
|
+
const requestBody = typeof args[0] === "string" ? args[0] : void 0;
|
|
172
|
+
this.addEventListener("loadend", () => {
|
|
173
|
+
const extra = live.wantsBody(url) ? live.bodyAttrs(requestBody, xhrResponseText(this)) : void 0;
|
|
174
|
+
live.record(url, meta.__mapleMethod ?? "GET", this.status, start, traceId, void 0, extra);
|
|
175
|
+
});
|
|
176
|
+
const call = withStartedTraceId(() => original.apply(this, args));
|
|
177
|
+
traceId = call.traceId ?? traceId;
|
|
178
|
+
return call.result;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Up to `maxLength` characters of a response body (one more, so `cut` marks it
|
|
183
|
+
* cut), then the stream is cancelled: a large payload is never read in full.
|
|
184
|
+
*/
|
|
185
|
+
async function readPrefix(response, maxLength) {
|
|
186
|
+
const reader = response.body?.getReader();
|
|
187
|
+
if (!reader) return void 0;
|
|
188
|
+
const decoder = new TextDecoder();
|
|
189
|
+
let text = "";
|
|
190
|
+
const deadline = Date.now() + BODY_READ_TIMEOUT_MS;
|
|
191
|
+
while (text.length <= maxLength) {
|
|
192
|
+
const remaining = deadline - Date.now();
|
|
193
|
+
const chunk = remaining > 0 ? await readWithin(reader, remaining) : void 0;
|
|
194
|
+
if (!chunk) {
|
|
195
|
+
reader.cancel().catch(() => {});
|
|
196
|
+
return text || void 0;
|
|
197
|
+
}
|
|
198
|
+
if (chunk.done) return text + decoder.decode();
|
|
199
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
200
|
+
}
|
|
201
|
+
reader.cancel().catch(() => {});
|
|
202
|
+
return text;
|
|
203
|
+
}
|
|
204
|
+
/** One read, or `undefined` if nothing arrives within `ms`. */
|
|
205
|
+
function readWithin(reader, ms) {
|
|
206
|
+
let timer;
|
|
207
|
+
const timeout = new Promise((resolve) => {
|
|
208
|
+
timer = setTimeout(() => resolve(void 0), ms);
|
|
209
|
+
});
|
|
210
|
+
return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer));
|
|
211
|
+
}
|
|
212
|
+
/** A text or JSON XHR response, as text; the browser already holds it, so this only slices. */
|
|
213
|
+
function xhrResponseText(xhr) {
|
|
214
|
+
if (!isReadableText(xhr.getResponseHeader("content-type") ?? "")) return void 0;
|
|
215
|
+
if (xhr.responseType === "" || xhr.responseType === "text") return xhr.responseText;
|
|
216
|
+
if (xhr.responseType === "json") try {
|
|
217
|
+
return JSON.stringify(xhr.response);
|
|
218
|
+
} catch {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** The request with the fields capture keeps on it; all optional, so any request is one. */
|
|
223
|
+
const xhrMeta = (xhr) => xhr;
|
|
224
|
+
function requestUrl(input) {
|
|
225
|
+
if (typeof input === "string") return input;
|
|
226
|
+
if (input instanceof URL) return input.href;
|
|
227
|
+
return input.url;
|
|
228
|
+
}
|
|
229
|
+
function requestMethod(input, init) {
|
|
230
|
+
return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region ../browser-session/src/replay/events.ts
|
|
234
|
+
/**
|
|
235
|
+
* Install the capture modules that turn console output and network requests
|
|
236
|
+
* into distilled session events.
|
|
237
|
+
*
|
|
238
|
+
* Navigation, errors and interactions are deliberately absent: the sink
|
|
239
|
+
* installs those itself (see `startBaselineCapture`), because page views, error
|
|
240
|
+
* counts and click counts have to be counted even when replay is unsampled —
|
|
241
|
+
* and a second listener here would double-count every one of them.
|
|
242
|
+
*/
|
|
243
|
+
function startEventCapture(config, sessionId) {
|
|
244
|
+
const sink = startEventSink(config, sessionId);
|
|
245
|
+
const emit = sink.emit;
|
|
246
|
+
const uninstall = [installConsoleCapture(emit, { maskText: config.maskAllText }), installNetworkCapture(emit, sink.ignoreUrl, config.maskAllText || !config.networkBodies ? void 0 : {
|
|
247
|
+
...config.networkBodies,
|
|
248
|
+
requestBodies: !config.maskAllInputs
|
|
249
|
+
})];
|
|
250
|
+
return {
|
|
251
|
+
stop: () => {
|
|
252
|
+
for (const off of uninstall) off();
|
|
253
|
+
},
|
|
254
|
+
flush: sink.flush
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region ../browser-session/src/replay/record.ts
|
|
259
|
+
function isRrwebEvent(value) {
|
|
260
|
+
return isJsonObject(value) && typeof value.type === "number" && typeof value.timestamp === "number";
|
|
261
|
+
}
|
|
262
|
+
const FULL_SNAPSHOT = 2;
|
|
263
|
+
const META = 4;
|
|
264
|
+
const INCREMENTAL = 3;
|
|
265
|
+
const SOURCE_MOUSE_INTERACTION = 2;
|
|
266
|
+
const MOUSE_CLICK = 2;
|
|
267
|
+
/**
|
|
268
|
+
* IncrementalSource values only a person produces: MouseMove, MouseInteraction,
|
|
269
|
+
* Scroll, Input, TouchMove, Drag. Mutations, console and network are not
|
|
270
|
+
* activity: a polling dashboard nobody looks at must still go idle.
|
|
271
|
+
*/
|
|
272
|
+
const USER_INPUT_SOURCES = /* @__PURE__ */ new Set([
|
|
273
|
+
1,
|
|
274
|
+
2,
|
|
275
|
+
3,
|
|
276
|
+
5,
|
|
277
|
+
6,
|
|
278
|
+
12
|
|
279
|
+
]);
|
|
280
|
+
const FLUSH_INTERVAL_MS = 5e3;
|
|
281
|
+
const FLUSH_BYTES = 102400;
|
|
282
|
+
const CHECKOUT_EVERY_MS = 3e5;
|
|
283
|
+
const MAX_BUFFER_BYTES = 4194304;
|
|
284
|
+
/** Ceiling on chunk bodies still uploading; a slow network otherwise holds every flush in memory. */
|
|
285
|
+
const MAX_INFLIGHT_BYTES = 2097152;
|
|
286
|
+
/** A lost chunk is repaired with a fresh snapshot, at most this often: each one stalls the main thread. */
|
|
287
|
+
const MIN_RESNAPSHOT_MS = 1e4;
|
|
288
|
+
/** Pause after ingest asks for a retry (429, 5xx), doubling per answer in a row up to the cap. */
|
|
289
|
+
const RETRY_BACKOFF_MS = 5e3;
|
|
290
|
+
const MAX_RETRY_BACKOFF_MS = 6e4;
|
|
291
|
+
/** Snapshots over the buffer cap before recording gives up on the session's DOM. */
|
|
292
|
+
const MAX_OVERSIZED_SNAPSHOTS = 3;
|
|
293
|
+
function warnExhausted(sessionId) {
|
|
294
|
+
console.warn(`[maple] session replay ${sessionId} reached its maximum recorded size; recording stopped for this session (metadata and events continue)`);
|
|
295
|
+
}
|
|
296
|
+
function warnOversized(sessionId) {
|
|
297
|
+
console.warn(`[maple] session replay ${sessionId} stopped: the page's DOM snapshot is larger than ${MAX_BUFFER_BYTES} bytes (metadata and events continue)`);
|
|
298
|
+
}
|
|
299
|
+
let lastDropWarnAt = 0;
|
|
300
|
+
function warnBufferDropped(bytes, limit) {
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
if (now - lastDropWarnAt < 3e4) return;
|
|
303
|
+
lastDropWarnAt = now;
|
|
304
|
+
console.warn(`[maple] session replay buffer exceeded ${limit} bytes (dropping ${bytes} buffered bytes; recording continues from the next full snapshot)`);
|
|
305
|
+
}
|
|
306
|
+
/** Whether a person produced `event`: only that keeps a session from going idle. */
|
|
307
|
+
function isUserInput(event) {
|
|
308
|
+
if (event.type !== INCREMENTAL || !isJsonObject(event.data)) return false;
|
|
309
|
+
const source = event.data.source;
|
|
310
|
+
return typeof source === "number" && USER_INPUT_SOURCES.has(source);
|
|
311
|
+
}
|
|
312
|
+
function isClick(event) {
|
|
313
|
+
return event.type === INCREMENTAL && isJsonObject(event.data) && event.data.source === SOURCE_MOUSE_INTERACTION && event.data.type === MOUSE_CLICK;
|
|
314
|
+
}
|
|
315
|
+
/** The meta event carries the page URL the player shows in its address bar. */
|
|
316
|
+
function scrubMetaHref(event) {
|
|
317
|
+
if (event.type === META && isJsonObject(event.data) && typeof event.data.href === "string") event.data.href = scrubUrl(event.data.href);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Whether `event` belongs to `sessionId`. User input resolves idle rotation
|
|
321
|
+
* (the rotation listener stops this recorder and starts the next one, so the
|
|
322
|
+
* event that discovered it is left out); anything else is kept only while the
|
|
323
|
+
* session is live, so it neither revives nor rotates an idle one. The answer
|
|
324
|
+
* for passive events is reused for a second: mutations arrive in bursts.
|
|
325
|
+
*/
|
|
326
|
+
function sessionGate(sessionId) {
|
|
327
|
+
let checkedAt = Number.NEGATIVE_INFINITY;
|
|
328
|
+
let live = true;
|
|
329
|
+
return (event) => {
|
|
330
|
+
if (isUserInput(event)) {
|
|
331
|
+
const active = markActivity("input");
|
|
332
|
+
checkedAt = Number.NEGATIVE_INFINITY;
|
|
333
|
+
return !active || active.id === sessionId;
|
|
334
|
+
}
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
if (now - checkedAt >= 1e3) {
|
|
337
|
+
checkedAt = now;
|
|
338
|
+
const stored = markActivity("passive");
|
|
339
|
+
live = stored === void 0 || stored?.id === sessionId;
|
|
340
|
+
}
|
|
341
|
+
return live;
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/** Input types rrweb consults `maskInputFn` for. `select` only when masking all: it also hides the selection. */
|
|
345
|
+
const MASKABLE_INPUTS = [
|
|
346
|
+
"color",
|
|
347
|
+
"date",
|
|
348
|
+
"datetime-local",
|
|
349
|
+
"email",
|
|
350
|
+
"month",
|
|
351
|
+
"number",
|
|
352
|
+
"range",
|
|
353
|
+
"search",
|
|
354
|
+
"tel",
|
|
355
|
+
"text",
|
|
356
|
+
"time",
|
|
357
|
+
"url",
|
|
358
|
+
"week",
|
|
359
|
+
"textarea",
|
|
360
|
+
"password",
|
|
361
|
+
"hidden"
|
|
362
|
+
];
|
|
363
|
+
/** Autocomplete tokens of fields masked whatever `maskAllInputs` says. */
|
|
364
|
+
const SECRET_AUTOCOMPLETE = /^(cc-.+|one-time-code|current-password|new-password)$/;
|
|
365
|
+
/**
|
|
366
|
+
* Whether an input's value is a secret on any page: passwords, hidden inputs
|
|
367
|
+
* (CSRF tokens, OAuth state) and card or one-time-code fields. rrweb masks
|
|
368
|
+
* only `type=password` on its own, and not even hidden inputs under `maskAllInputs`.
|
|
369
|
+
*/
|
|
370
|
+
function isSecretInput(element) {
|
|
371
|
+
const type = ("type" in element && typeof element.type === "string" ? element.type : "").toLowerCase();
|
|
372
|
+
if (type === "password" || type === "hidden") return true;
|
|
373
|
+
if (element.hasAttribute("data-rr-is-password")) return true;
|
|
374
|
+
return (element.getAttribute("autocomplete") ?? "").toLowerCase().split(/\s+/).some((token) => SECRET_AUTOCOMPLETE.test(token));
|
|
375
|
+
}
|
|
376
|
+
/** rrweb input masking: everything under `maskAllInputs`, otherwise only secrets. */
|
|
377
|
+
function inputMaskOptions(config) {
|
|
378
|
+
const maskInputOptions = {};
|
|
379
|
+
for (const type of MASKABLE_INPUTS) maskInputOptions[type] = true;
|
|
380
|
+
if (config.maskAllInputs) maskInputOptions.select = true;
|
|
381
|
+
return {
|
|
382
|
+
maskInputOptions,
|
|
383
|
+
maskInputFn: (text, element) => config.maskAllInputs || isSecretInput(element) ? "*".repeat(text.length) : text
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/** rrweb options shared by both recorders. */
|
|
387
|
+
function recordOptions(config) {
|
|
388
|
+
return {
|
|
389
|
+
...inputMaskOptions(config),
|
|
390
|
+
blockSelector: BLOCK_SELECTOR,
|
|
391
|
+
...config.maskAllText ? { maskTextSelector: "*" } : void 0,
|
|
392
|
+
errorHandler: (error) => {
|
|
393
|
+
warnDropped("replay recording", error);
|
|
394
|
+
return true;
|
|
395
|
+
},
|
|
396
|
+
...canvasOptions(config)
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
const MAX_PENDING_AGE_MS = 6e5;
|
|
400
|
+
const PAGE_STARTED_AT = Date.now();
|
|
401
|
+
/** How long a stored chunk that found the keepalive budget full waits before its one re-attempt. */
|
|
402
|
+
const KEEPALIVE_RETRY_MS = 1e3;
|
|
403
|
+
function isPendingChunk(value) {
|
|
404
|
+
return isJsonObject(value) && typeof value.sessionId === "string" && typeof value.chunkSeq === "number" && typeof value.isCheckpoint === "boolean" && typeof value.eventCount === "number" && typeof value.durationMs === "number" && typeof value.body === "string" && typeof value.createdAt === "number" && typeof value.target === "string";
|
|
405
|
+
}
|
|
406
|
+
/** Endpoint plus an FNV-1a hash of the key, so the key itself is not stored. */
|
|
407
|
+
function targetOf(config) {
|
|
408
|
+
let hash = 2166136261;
|
|
409
|
+
for (const char of config.ingestKey ?? "") hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
|
410
|
+
return `${config.endpoint}|${hash >>> 0}`;
|
|
411
|
+
}
|
|
412
|
+
function readPending() {
|
|
413
|
+
if (!mayUseStorage()) return void 0;
|
|
414
|
+
try {
|
|
415
|
+
const parsed = JSON.parse(window.sessionStorage.getItem("maple.replay.pending") ?? "null");
|
|
416
|
+
return isPendingChunk(parsed) ? parsed : void 0;
|
|
417
|
+
} catch {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Keep `chunk` unless one is already waiting. False when it was not kept
|
|
423
|
+
* (storage blocked or full): it is then only as durable as its upload.
|
|
424
|
+
*/
|
|
425
|
+
function storePending(chunk) {
|
|
426
|
+
if (!mayUseStorage()) return false;
|
|
427
|
+
try {
|
|
428
|
+
if (readPending()) return false;
|
|
429
|
+
window.sessionStorage.setItem(PENDING_CHUNK_KEY, JSON.stringify(chunk));
|
|
430
|
+
return true;
|
|
431
|
+
} catch {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Remove the stored chunk if it is `meta`'s, reporting whether it was. Whoever
|
|
437
|
+
* removes it sends it, so a seq is posted once: ingest appends an index row per POST.
|
|
438
|
+
*/
|
|
439
|
+
function takePending(meta) {
|
|
440
|
+
const chunk = readPending();
|
|
441
|
+
return chunk?.sessionId === meta.sessionId && chunk.chunkSeq === meta.chunkSeq && clearPendingChunk();
|
|
442
|
+
}
|
|
443
|
+
/** Send the chunk an earlier flush left behind, if it is still this recorder's to send; otherwise drop it. */
|
|
444
|
+
function sendPendingChunk(config, sessionId) {
|
|
445
|
+
const chunk = readPending();
|
|
446
|
+
if (!chunk) return;
|
|
447
|
+
if (chunk.target === targetOf(config) && chunk.createdAt > consentRevokedAt() && (chunk.createdAt >= PAGE_STARTED_AT || chunk.sessionId === sessionId && Date.now() - chunk.createdAt <= MAX_PENDING_AGE_MS)) compressAndPost(config, chunk, chunk.body, false, true, chunk.createdAt);
|
|
448
|
+
else clearPendingChunk();
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Claim a seq and upload one chunk. The seq is monotonic across reloads
|
|
452
|
+
* (persisted on the session record), so a refresh continues the sequence
|
|
453
|
+
* instead of overwriting the previous load's blobs.
|
|
454
|
+
*/
|
|
455
|
+
function uploadChunk(config, sessionId, body, chunk, keepalive) {
|
|
456
|
+
const chunkSeq = nextChunkSeq(sessionId);
|
|
457
|
+
if (chunkSeq === void 0) return Promise.resolve("dropped");
|
|
458
|
+
const meta = {
|
|
459
|
+
sessionId,
|
|
460
|
+
chunkSeq,
|
|
461
|
+
...chunk
|
|
462
|
+
};
|
|
463
|
+
const sentNow = keepalive ? postBeforeUnload(config, meta, body) : void 0;
|
|
464
|
+
if (sentNow) return sentNow;
|
|
465
|
+
const cutAt = Date.now();
|
|
466
|
+
return compressAndPost(config, meta, body, keepalive, keepalive && storePending({
|
|
467
|
+
...meta,
|
|
468
|
+
body,
|
|
469
|
+
createdAt: cutAt,
|
|
470
|
+
target: targetOf(config)
|
|
471
|
+
}), cutAt);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The most of the shared keepalive budget a chunk takes on the way out, so the
|
|
475
|
+
* session row, events and spans issued alongside it keep room.
|
|
476
|
+
*/
|
|
477
|
+
const UNLOAD_CHUNK_MAX_BYTES = 24576;
|
|
478
|
+
/**
|
|
479
|
+
* Post a chunk from a page that may be going away, uncompressed, before anything
|
|
480
|
+
* awaits: `CompressionStream` never resolves on a document being torn down, and
|
|
481
|
+
* a stored copy only reaches ingest if this tab shows the site again. Without
|
|
482
|
+
* this a visit shorter than one flush interval uploaded no recording at all.
|
|
483
|
+
* Undefined when the chunk is too large, which leaves it to the stored path.
|
|
484
|
+
*/
|
|
485
|
+
function postBeforeUnload(config, meta, body) {
|
|
486
|
+
if (body.length > UNLOAD_CHUNK_MAX_BYTES) return void 0;
|
|
487
|
+
const gzipped = gzipStored(new TextEncoder().encode(body));
|
|
488
|
+
if (gzipped.byteLength > UNLOAD_CHUNK_MAX_BYTES) return void 0;
|
|
489
|
+
const release = reserveKeepalive(true, gzipped.byteLength);
|
|
490
|
+
if (!release) return void 0;
|
|
491
|
+
release();
|
|
492
|
+
return postSessionBlob(config, meta, gzipped, true);
|
|
493
|
+
}
|
|
494
|
+
async function compressAndPost(config, meta, body, keepalive, stored, cutAt) {
|
|
495
|
+
let gzipped;
|
|
496
|
+
try {
|
|
497
|
+
gzipped = await gzip(new TextEncoder().encode(body));
|
|
498
|
+
} catch (error) {
|
|
499
|
+
warnDropped("chunk compression", error);
|
|
500
|
+
return "dropped";
|
|
501
|
+
}
|
|
502
|
+
if (consentRevokedAt() >= cutAt) return "handed-off";
|
|
503
|
+
if (stored) {
|
|
504
|
+
if (keepalive) {
|
|
505
|
+
const release = reserveKeepalive(true, gzipped.byteLength);
|
|
506
|
+
if (release) release();
|
|
507
|
+
else await new Promise((resolve) => setTimeout(resolve, KEEPALIVE_RETRY_MS));
|
|
508
|
+
}
|
|
509
|
+
if (!takePending(meta)) return "handed-off";
|
|
510
|
+
}
|
|
511
|
+
return postSessionBlob(config, meta, gzipped, keepalive);
|
|
512
|
+
}
|
|
513
|
+
const emptyBuffer = () => ({
|
|
514
|
+
parts: [],
|
|
515
|
+
types: [],
|
|
516
|
+
timestamps: [],
|
|
517
|
+
bytes: 0
|
|
518
|
+
});
|
|
519
|
+
function sliceBuffer(buffer, start, end = buffer.parts.length) {
|
|
520
|
+
const parts = buffer.parts.slice(start, end);
|
|
521
|
+
return {
|
|
522
|
+
parts,
|
|
523
|
+
types: buffer.types.slice(start, end),
|
|
524
|
+
timestamps: buffer.timestamps.slice(start, end),
|
|
525
|
+
bytes: parts.reduce((sum, part) => sum + part.length, 0)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const NOOP_RECORDER = {
|
|
529
|
+
stop: () => {},
|
|
530
|
+
flush: async () => {},
|
|
531
|
+
getClickCount: () => 0
|
|
532
|
+
};
|
|
533
|
+
function startRecording(config, sessionId) {
|
|
534
|
+
sendPendingChunk(config, sessionId);
|
|
535
|
+
if (isReplayHalted(sessionId)) return NOOP_RECORDER;
|
|
536
|
+
let buffer = emptyBuffer();
|
|
537
|
+
/** After a lost batch the stream has a gap: nothing is kept until the next snapshot's Meta. */
|
|
538
|
+
let awaitingSnapshot = false;
|
|
539
|
+
let inflightBytes = 0;
|
|
540
|
+
let oversizedSnapshots = 0;
|
|
541
|
+
let clickCount = 0;
|
|
542
|
+
let stopped = false;
|
|
543
|
+
let halted = false;
|
|
544
|
+
let resnapshotTimer;
|
|
545
|
+
let lastResnapshotAt = Number.NEGATIVE_INFINITY;
|
|
546
|
+
/** Uploads wait until then after ingest asked for a retry; the buffer keeps filling meanwhile. */
|
|
547
|
+
let retryAt = 0;
|
|
548
|
+
let retriesInARow = 0;
|
|
549
|
+
const isHidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
550
|
+
const takeResnapshot = () => {
|
|
551
|
+
resnapshotTimer = void 0;
|
|
552
|
+
if (halted || !awaitingSnapshot) return;
|
|
553
|
+
if (inflightBytes > MAX_INFLIGHT_BYTES / 2 || isHidden() || Date.now() < retryAt) {
|
|
554
|
+
resnapshotTimer = setTimeout(takeResnapshot, Math.max(MIN_RESNAPSHOT_MS, retryAt - Date.now()));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
lastResnapshotAt = Date.now();
|
|
558
|
+
try {
|
|
559
|
+
record.takeFullSnapshot(true);
|
|
560
|
+
} catch {}
|
|
561
|
+
};
|
|
562
|
+
/** Drop everything buffered and reopen the stream at a fresh snapshot, rate-limited. */
|
|
563
|
+
const dropAndResnapshot = () => {
|
|
564
|
+
buffer = emptyBuffer();
|
|
565
|
+
awaitingSnapshot = true;
|
|
566
|
+
if (resnapshotTimer !== void 0) return;
|
|
567
|
+
const wait = Math.max(0, lastResnapshotAt + MIN_RESNAPSHOT_MS - Date.now());
|
|
568
|
+
resnapshotTimer = setTimeout(takeResnapshot, wait);
|
|
569
|
+
};
|
|
570
|
+
/**
|
|
571
|
+
* A chunk did not reach ingest, so later incremental events would apply to
|
|
572
|
+
* nodes the player never built. Keep the buffer from its latest snapshot if
|
|
573
|
+
* it has one; otherwise drop it and take a fresh snapshot.
|
|
574
|
+
*/
|
|
575
|
+
const onChunkLost = () => {
|
|
576
|
+
if (halted || stopped) return;
|
|
577
|
+
const metaAt = buffer.types.lastIndexOf(META);
|
|
578
|
+
if (metaAt >= 0) buffer = sliceBuffer(buffer, metaAt);
|
|
579
|
+
else dropAndResnapshot();
|
|
580
|
+
};
|
|
581
|
+
const halt = (warn) => {
|
|
582
|
+
if (halted) return;
|
|
583
|
+
warn(sessionId);
|
|
584
|
+
markReplayHalted(sessionId);
|
|
585
|
+
haltCapture();
|
|
586
|
+
};
|
|
587
|
+
const flush = async (keepalive = false) => {
|
|
588
|
+
if (stopped || halted || !keepalive && Date.now() < retryAt) return;
|
|
589
|
+
const cut = buffer.types.at(-1) === META ? buffer.parts.length - 1 : buffer.parts.length;
|
|
590
|
+
if (cut === 0) return;
|
|
591
|
+
const sending = sliceBuffer(buffer, 0, cut);
|
|
592
|
+
buffer = sliceBuffer(buffer, cut);
|
|
593
|
+
const body = `[${sending.parts.join(",")}]`;
|
|
594
|
+
if (!keepalive && inflightBytes + body.length > MAX_INFLIGHT_BYTES) {
|
|
595
|
+
warnBufferDropped(body.length, MAX_INFLIGHT_BYTES);
|
|
596
|
+
dropAndResnapshot();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const first = sending.timestamps[0] ?? 0;
|
|
600
|
+
const last = sending.timestamps.at(-1) ?? first;
|
|
601
|
+
inflightBytes += body.length;
|
|
602
|
+
let outcome;
|
|
603
|
+
try {
|
|
604
|
+
outcome = await uploadChunk(config, sessionId, body, {
|
|
605
|
+
isCheckpoint: sending.types.includes(FULL_SNAPSHOT),
|
|
606
|
+
eventCount: sending.parts.length,
|
|
607
|
+
durationMs: Math.max(0, last - first)
|
|
608
|
+
}, keepalive);
|
|
609
|
+
} finally {
|
|
610
|
+
inflightBytes -= body.length;
|
|
611
|
+
}
|
|
612
|
+
if (outcome === "accepted") retriesInARow = 0;
|
|
613
|
+
else if (outcome !== "handed-off" && outcome !== "refused" && outcome !== "exhausted") {
|
|
614
|
+
retriesInARow++;
|
|
615
|
+
retryAt = Date.now() + Math.min(MAX_RETRY_BACKOFF_MS, RETRY_BACKOFF_MS * 2 ** (retriesInARow - 1));
|
|
616
|
+
}
|
|
617
|
+
if (outcome === "refused") haltCapture();
|
|
618
|
+
else if (outcome === "exhausted") halt(warnExhausted);
|
|
619
|
+
else if (outcome !== "accepted" && outcome !== "handed-off") onChunkLost();
|
|
620
|
+
};
|
|
621
|
+
let haltCapture = () => {};
|
|
622
|
+
const belongs = sessionGate(sessionId);
|
|
623
|
+
const stop = record({
|
|
624
|
+
emit: (event) => {
|
|
625
|
+
if (!isRrwebEvent(event) || !belongs(event)) return;
|
|
626
|
+
scrubMetaHref(event);
|
|
627
|
+
if (isClick(event)) clickCount++;
|
|
628
|
+
let json;
|
|
629
|
+
try {
|
|
630
|
+
json = JSON.stringify(event);
|
|
631
|
+
} catch {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (awaitingSnapshot && event.type !== META) return;
|
|
635
|
+
awaitingSnapshot = false;
|
|
636
|
+
if (buffer.bytes + json.length > MAX_BUFFER_BYTES) {
|
|
637
|
+
warnBufferDropped(buffer.bytes + json.length, MAX_BUFFER_BYTES);
|
|
638
|
+
if (event.type !== FULL_SNAPSHOT) {
|
|
639
|
+
dropAndResnapshot();
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
buffer = emptyBuffer();
|
|
643
|
+
awaitingSnapshot = true;
|
|
644
|
+
oversizedSnapshots++;
|
|
645
|
+
if (oversizedSnapshots >= MAX_OVERSIZED_SNAPSHOTS) halt(warnOversized);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
buffer.parts.push(json);
|
|
649
|
+
buffer.types.push(event.type);
|
|
650
|
+
buffer.timestamps.push(event.timestamp);
|
|
651
|
+
buffer.bytes += json.length;
|
|
652
|
+
if (buffer.bytes >= FLUSH_BYTES && event.type !== META) flush();
|
|
653
|
+
},
|
|
654
|
+
...recordOptions(config),
|
|
655
|
+
checkoutEveryNms: CHECKOUT_EVERY_MS
|
|
656
|
+
});
|
|
657
|
+
let idleHandle;
|
|
658
|
+
const scheduleFlush = () => {
|
|
659
|
+
if (isHidden()) return;
|
|
660
|
+
if (typeof requestIdleCallback === "function") idleHandle = requestIdleCallback(() => {
|
|
661
|
+
idleHandle = void 0;
|
|
662
|
+
flush();
|
|
663
|
+
}, { timeout: 2e3 });
|
|
664
|
+
else flush();
|
|
665
|
+
};
|
|
666
|
+
const flushTimer = setInterval(scheduleFlush, FLUSH_INTERVAL_MS);
|
|
667
|
+
haltCapture = () => {
|
|
668
|
+
if (halted) return;
|
|
669
|
+
halted = true;
|
|
670
|
+
clearInterval(flushTimer);
|
|
671
|
+
if (resnapshotTimer !== void 0) clearTimeout(resnapshotTimer);
|
|
672
|
+
resnapshotTimer = void 0;
|
|
673
|
+
if (idleHandle !== void 0 && typeof cancelIdleCallback === "function") {
|
|
674
|
+
cancelIdleCallback(idleHandle);
|
|
675
|
+
idleHandle = void 0;
|
|
676
|
+
}
|
|
677
|
+
buffer = emptyBuffer();
|
|
678
|
+
stop?.();
|
|
679
|
+
};
|
|
680
|
+
return {
|
|
681
|
+
stop: () => {
|
|
682
|
+
stopped = true;
|
|
683
|
+
haltCapture();
|
|
684
|
+
},
|
|
685
|
+
flush,
|
|
686
|
+
getClickCount: () => clickCount
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/** rrweb options for `<canvas>` capture: sampled frames as WebP, off unless asked for. */
|
|
690
|
+
function canvasOptions(config) {
|
|
691
|
+
if (config.maskAllText || !config.canvasFps || config.canvasFps <= 0) return void 0;
|
|
692
|
+
return {
|
|
693
|
+
recordCanvas: true,
|
|
694
|
+
sampling: { canvas: config.canvasFps },
|
|
695
|
+
dataURLOptions: {
|
|
696
|
+
type: "image/webp",
|
|
697
|
+
quality: .6
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Buffer mode checks out often, so the retained window stays near a minute. A
|
|
703
|
+
* checkout is a full DOM snapshot, so it is skipped while hidden or when nothing changed.
|
|
704
|
+
*/
|
|
705
|
+
const BUFFER_CHECKOUT_MS = 3e4;
|
|
706
|
+
const NOOP_BUFFERED = {
|
|
707
|
+
drain: async () => {},
|
|
708
|
+
stop: () => {},
|
|
709
|
+
getClickCount: () => 0
|
|
710
|
+
};
|
|
711
|
+
/**
|
|
712
|
+
* Record into memory only: the segments since the second-to-last checkout, so
|
|
713
|
+
* 30-60s of replay, and nothing is uploaded until `drain()`. For sessions that
|
|
714
|
+
* keep a replay only when an error happens.
|
|
715
|
+
*/
|
|
716
|
+
function startBufferedRecording(config, sessionId) {
|
|
717
|
+
if (isReplayHalted(sessionId)) return NOOP_BUFFERED;
|
|
718
|
+
let segments = [];
|
|
719
|
+
let bytes = 0;
|
|
720
|
+
let clickCount = 0;
|
|
721
|
+
let stopped = false;
|
|
722
|
+
let oversizedSnapshots = 0;
|
|
723
|
+
/** Incremental events since the last snapshot: an idle page needs no new one. */
|
|
724
|
+
let changedSinceSnapshot = 0;
|
|
725
|
+
const belongs = sessionGate(sessionId);
|
|
726
|
+
let stopRecord;
|
|
727
|
+
const stop = () => {
|
|
728
|
+
stopped = true;
|
|
729
|
+
clearInterval(checkoutTimer);
|
|
730
|
+
segments = [];
|
|
731
|
+
bytes = 0;
|
|
732
|
+
stopRecord?.();
|
|
733
|
+
};
|
|
734
|
+
stopRecord = record({
|
|
735
|
+
emit: (event) => {
|
|
736
|
+
if (stopped || !isRrwebEvent(event) || !belongs(event)) return;
|
|
737
|
+
scrubMetaHref(event);
|
|
738
|
+
if (isClick(event)) clickCount++;
|
|
739
|
+
let json;
|
|
740
|
+
try {
|
|
741
|
+
json = JSON.stringify(event);
|
|
742
|
+
} catch {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (event.type === META) {
|
|
746
|
+
segments.push({
|
|
747
|
+
parts: [],
|
|
748
|
+
bytes: 0,
|
|
749
|
+
first: event.timestamp,
|
|
750
|
+
last: event.timestamp,
|
|
751
|
+
playable: false
|
|
752
|
+
});
|
|
753
|
+
while (segments.length > 2) bytes -= segments.shift()?.bytes ?? 0;
|
|
754
|
+
changedSinceSnapshot = 0;
|
|
755
|
+
} else if (event.type === INCREMENTAL) changedSinceSnapshot++;
|
|
756
|
+
const segment = segments.at(-1);
|
|
757
|
+
if (!segment) return;
|
|
758
|
+
if (event.type === FULL_SNAPSHOT) {
|
|
759
|
+
segment.playable = segment.parts.length === 1;
|
|
760
|
+
if (json.length > MAX_BUFFER_BYTES && ++oversizedSnapshots >= MAX_OVERSIZED_SNAPSHOTS) {
|
|
761
|
+
warnOversized(sessionId);
|
|
762
|
+
markReplayHalted(sessionId);
|
|
763
|
+
stop();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
segment.parts.push(json);
|
|
768
|
+
segment.bytes += json.length;
|
|
769
|
+
segment.last = event.timestamp;
|
|
770
|
+
bytes += json.length;
|
|
771
|
+
while (bytes > MAX_BUFFER_BYTES && segments.length > 0) bytes -= segments.shift()?.bytes ?? 0;
|
|
772
|
+
},
|
|
773
|
+
...recordOptions(config)
|
|
774
|
+
});
|
|
775
|
+
const checkoutTimer = setInterval(() => {
|
|
776
|
+
if (stopped || changedSinceSnapshot === 0) return;
|
|
777
|
+
const current = segments.at(-1);
|
|
778
|
+
if (!(current === void 0 || current.bytes > MAX_BUFFER_BYTES / 2) && typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
779
|
+
try {
|
|
780
|
+
record.takeFullSnapshot(true);
|
|
781
|
+
} catch {}
|
|
782
|
+
}, BUFFER_CHECKOUT_MS);
|
|
783
|
+
return {
|
|
784
|
+
drain: async (keepalive = false) => {
|
|
785
|
+
const pending = segments.filter((segment) => segment.playable);
|
|
786
|
+
segments = [];
|
|
787
|
+
bytes = 0;
|
|
788
|
+
if (stopped) return;
|
|
789
|
+
if ((await Promise.all(pending.map((segment) => uploadChunk(config, sessionId, `[${segment.parts.join(",")}]`, {
|
|
790
|
+
isCheckpoint: true,
|
|
791
|
+
eventCount: segment.parts.length,
|
|
792
|
+
durationMs: Math.max(0, segment.last - segment.first)
|
|
793
|
+
}, keepalive)))).includes("exhausted")) {
|
|
794
|
+
warnExhausted(sessionId);
|
|
795
|
+
markReplayHalted(sessionId);
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
stop,
|
|
799
|
+
getClickCount: () => clickCount
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
//#endregion
|
|
803
|
+
//#region ../browser-session/src/session/replay-session.ts
|
|
804
|
+
/**
|
|
805
|
+
* Start recording the current browser session. Publishes the session sink,
|
|
806
|
+
* posts an `active` metadata row, and installs visibility handlers:
|
|
807
|
+
* hidden → flush + `ended` row (with observed trace ids), capture kept running;
|
|
808
|
+
* visible → re-resolve the session (rotating if idle-expired, which restarts
|
|
809
|
+
* capture under the new id) and post a fresh `active` row. `pagehide`, consent
|
|
810
|
+
* revoke and a tab hidden past the idle timeout stop capture. Metadata versions
|
|
811
|
+
* are monotonic per session, so the latest row always wins on the backend.
|
|
812
|
+
*
|
|
813
|
+
* Returns undefined outside a browser. Sampling is the caller's decision.
|
|
814
|
+
*/
|
|
815
|
+
function startReplaySession(options) {
|
|
816
|
+
if (typeof window === "undefined") return void 0;
|
|
817
|
+
const engineConfig = {
|
|
818
|
+
endpoint: options.endpoint.replace(/\/$/, ""),
|
|
819
|
+
ingestKey: options.ingestKey,
|
|
820
|
+
sdk: options.sdk,
|
|
821
|
+
maskAllInputs: options.maskAllInputs,
|
|
822
|
+
maskAllText: options.maskAllText,
|
|
823
|
+
getIdentity: options.getIdentity,
|
|
824
|
+
canvasFps: options.canvasFps,
|
|
825
|
+
networkBodies: options.networkBodies
|
|
826
|
+
};
|
|
827
|
+
let recorder;
|
|
828
|
+
let buffered;
|
|
829
|
+
let events;
|
|
830
|
+
let publishedSessionId;
|
|
831
|
+
const buffering = options.mode === "buffer";
|
|
832
|
+
let triggered = !buffering;
|
|
833
|
+
/** Clicks the buffered recorder saw before a trigger replaced it. */
|
|
834
|
+
let clicksBeforeTrigger = 0;
|
|
835
|
+
const publish = (sessionId) => {
|
|
836
|
+
if (publishedSessionId === sessionId) return;
|
|
837
|
+
publishedSessionId = sessionId;
|
|
838
|
+
publishSessionSink(sessionId);
|
|
839
|
+
};
|
|
840
|
+
const startStreaming = (sessionId) => {
|
|
841
|
+
recorder = startRecording(engineConfig, sessionId);
|
|
842
|
+
events = startEventCapture(engineConfig, sessionId);
|
|
843
|
+
};
|
|
844
|
+
const lifecycle = startSessionLifecycle({
|
|
845
|
+
...options,
|
|
846
|
+
getTraceIds: getObservedTraceIds
|
|
847
|
+
}, {
|
|
848
|
+
recorded: () => triggered,
|
|
849
|
+
buffered: () => buffering,
|
|
850
|
+
post: (row, keepalive) => {
|
|
851
|
+
postSessionMeta(engineConfig, row, keepalive);
|
|
852
|
+
},
|
|
853
|
+
clicksSinceStart: () => clicksBeforeTrigger + (recorder?.getClickCount() ?? buffered?.getClickCount() ?? 0),
|
|
854
|
+
onStart: (record) => {
|
|
855
|
+
publish(record.id);
|
|
856
|
+
clicksBeforeTrigger = 0;
|
|
857
|
+
if (triggered) startStreaming(record.id);
|
|
858
|
+
else buffered = startBufferedRecording(engineConfig, record.id);
|
|
859
|
+
},
|
|
860
|
+
onSuspend: ({ flush, keepalive }) => {
|
|
861
|
+
const stoppingRecorder = recorder;
|
|
862
|
+
const stoppingEvents = events;
|
|
863
|
+
recorder = void 0;
|
|
864
|
+
events = void 0;
|
|
865
|
+
buffered?.stop();
|
|
866
|
+
buffered = void 0;
|
|
867
|
+
const flushed = flush ? Promise.all([stoppingRecorder?.flush(keepalive), stoppingEvents?.flush(keepalive)]).then(() => {}) : void 0;
|
|
868
|
+
stoppingRecorder?.stop();
|
|
869
|
+
stoppingEvents?.stop();
|
|
870
|
+
return flushed;
|
|
871
|
+
},
|
|
872
|
+
onPause: async ({ keepalive }) => {
|
|
873
|
+
await Promise.all([recorder?.flush(keepalive), events?.flush(keepalive)]);
|
|
874
|
+
},
|
|
875
|
+
onSessionChange: (sessionId) => {
|
|
876
|
+
triggered = !buffering;
|
|
877
|
+
publish(sessionId);
|
|
878
|
+
options.onSessionChange?.(sessionId);
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
if (!lifecycle) return void 0;
|
|
882
|
+
return {
|
|
883
|
+
get sessionId() {
|
|
884
|
+
return lifecycle.sessionId;
|
|
885
|
+
},
|
|
886
|
+
announce: lifecycle.announce,
|
|
887
|
+
shutdown: lifecycle.shutdown,
|
|
888
|
+
trigger: async () => {
|
|
889
|
+
if (triggered) return;
|
|
890
|
+
triggered = true;
|
|
891
|
+
const sessionId = lifecycle.sessionId;
|
|
892
|
+
markReplayTriggered(sessionId);
|
|
893
|
+
const pending = buffered;
|
|
894
|
+
buffered = void 0;
|
|
895
|
+
if (!pending) return;
|
|
896
|
+
clicksBeforeTrigger = pending.getClickCount();
|
|
897
|
+
const drained = pending.drain();
|
|
898
|
+
pending.stop();
|
|
899
|
+
startStreaming(sessionId);
|
|
900
|
+
lifecycle.announce();
|
|
901
|
+
await drained;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
//#endregion
|
|
906
|
+
export { startReplaySession };
|