@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.
@@ -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 };