@maple-dev/browser 0.12.0 → 0.14.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-BDKAWiKZ.mjs → deferred-BcyLmMyV.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-DFPRE4Ee.mjs +906 -0
- package/dist/{sink-DbEuphC-.mjs → sink-Ji0J42ul.mjs} +848 -188
- package/dist/{src-DfU4Cnb1.mjs → src-B3Giah8c.mjs} +545 -171
- package/package.json +19 -16
- package/dist/console-Bkd3hBGp.mjs +0 -73
- package/dist/replay-session-Cg3U1RS0.mjs +0 -596
|
@@ -1,596 +0,0 @@
|
|
|
1
|
-
import { D as reserveKeepalive, F as withStartedTraceId, G as scrubUrl, K as mayUseStorage, M as safeEmit, N as activeTraceId, R as consentRevokedAt, T as postSessionMeta, W as isJsonObject, d as startEventSink, g as nextChunkSeq, h as markReplayTriggered, j as BLOCK_SELECTOR, k as warnDropped, m as markActivity, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, w as postSessionBlob, x as gzip } from "./sink-DbEuphC-.mjs";
|
|
2
|
-
import { v as PENDING_CHUNK_KEY, y as clearPendingChunk } from "./src-DfU4Cnb1.mjs";
|
|
3
|
-
import { t as installConsoleCapture } from "./console-Bkd3hBGp.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
|
-
pattern.lastIndex = 0;
|
|
24
|
-
return pattern.test(url);
|
|
25
|
-
});
|
|
26
|
-
const cut = (text, maxLength) => text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
|
27
|
-
/**
|
|
28
|
-
* Capture fetch + XHR requests as session events, tagged with the active trace
|
|
29
|
-
* id so each request links to its backend trace. `ignoreUrl` skips Maple's own
|
|
30
|
-
* ingest endpoints (otherwise capturing the session-events POST would loop).
|
|
31
|
-
* `bodies`, when set, keeps text request/response bodies of the URLs it lists.
|
|
32
|
-
*/
|
|
33
|
-
function installNetworkCapture(emit, ignoreUrl, bodies) {
|
|
34
|
-
const wantsBody = (url) => bodies !== void 0 && matchesUrl(absoluteUrl(url), bodies.urls);
|
|
35
|
-
const bodyAttrs = (request, response) => ({
|
|
36
|
-
...request && bodies?.requestBodies !== false ? { "request.body": cut(request, bodies?.maxLength ?? 0) } : void 0,
|
|
37
|
-
...response && bodies ? { "response.body": cut(response, bodies.maxLength) } : void 0
|
|
38
|
-
});
|
|
39
|
-
const origFetch = typeof window !== "undefined" ? window.fetch : void 0;
|
|
40
|
-
if (origFetch) window.fetch = async (input, init) => {
|
|
41
|
-
const url = requestUrl(input);
|
|
42
|
-
const method = requestMethod(input, init);
|
|
43
|
-
const ambientTraceId = activeTraceId();
|
|
44
|
-
const start = performance.now();
|
|
45
|
-
let traceId = ambientTraceId;
|
|
46
|
-
try {
|
|
47
|
-
const call = withStartedTraceId(() => origFetch(input, init));
|
|
48
|
-
traceId = call.traceId ?? ambientTraceId;
|
|
49
|
-
const res = await call.result;
|
|
50
|
-
if (!wantsBody(url)) {
|
|
51
|
-
record(url, method, res.status, start, traceId);
|
|
52
|
-
return res;
|
|
53
|
-
}
|
|
54
|
-
const requestBody = typeof init?.body === "string" ? init.body : void 0;
|
|
55
|
-
const done = performance.now();
|
|
56
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
57
|
-
(isReadableText(contentType) ? readPrefix(res.clone(), bodies?.maxLength ?? 0) : Promise.resolve(void 0)).catch(() => void 0).then((responseBody) => record(url, method, res.status, start, traceId, void 0, bodyAttrs(requestBody, responseBody), done));
|
|
58
|
-
return res;
|
|
59
|
-
} catch (error) {
|
|
60
|
-
record(url, method, 0, start, traceId, String(error));
|
|
61
|
-
throw error;
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
const record = (url, method, status, start, traceId, error, extra, end = performance.now()) => {
|
|
65
|
-
if (ignoreUrl(url)) return;
|
|
66
|
-
const attrs = {
|
|
67
|
-
...extra,
|
|
68
|
-
...error ? { error } : void 0
|
|
69
|
-
};
|
|
70
|
-
safeEmit(emit, {
|
|
71
|
-
type: "network",
|
|
72
|
-
net: {
|
|
73
|
-
method,
|
|
74
|
-
url,
|
|
75
|
-
status,
|
|
76
|
-
durationMs: Math.round(end - start)
|
|
77
|
-
},
|
|
78
|
-
traceId,
|
|
79
|
-
...Object.keys(attrs).length > 0 ? { attrs } : void 0
|
|
80
|
-
});
|
|
81
|
-
};
|
|
82
|
-
const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
|
|
83
|
-
const origOpen = XHR?.prototype.open;
|
|
84
|
-
const origSend = XHR?.prototype.send;
|
|
85
|
-
if (XHR && origOpen && origSend) {
|
|
86
|
-
XHR.prototype.open = function(method, url, ...rest) {
|
|
87
|
-
this.__mapleMethod = String(method).toUpperCase();
|
|
88
|
-
this.__mapleUrl = typeof url === "string" ? url : url.href;
|
|
89
|
-
const call = withStartedTraceId(() => origOpen.apply(this, [
|
|
90
|
-
method,
|
|
91
|
-
url,
|
|
92
|
-
...rest
|
|
93
|
-
]));
|
|
94
|
-
this.__mapleTraceId = call.traceId;
|
|
95
|
-
return call.result;
|
|
96
|
-
};
|
|
97
|
-
XHR.prototype.send = function(...args) {
|
|
98
|
-
const meta = this;
|
|
99
|
-
const start = performance.now();
|
|
100
|
-
let traceId = meta.__mapleTraceId ?? activeTraceId();
|
|
101
|
-
const url = meta.__mapleUrl ?? "";
|
|
102
|
-
const requestBody = typeof args[0] === "string" ? args[0] : void 0;
|
|
103
|
-
this.addEventListener("loadend", () => {
|
|
104
|
-
const extra = wantsBody(url) ? bodyAttrs(requestBody, xhrResponseText(this)) : void 0;
|
|
105
|
-
record(url, meta.__mapleMethod ?? "GET", this.status, start, traceId, void 0, extra);
|
|
106
|
-
});
|
|
107
|
-
const call = withStartedTraceId(() => origSend.apply(this, args));
|
|
108
|
-
traceId = call.traceId ?? traceId;
|
|
109
|
-
return call.result;
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
return () => {
|
|
113
|
-
if (origFetch) window.fetch = origFetch;
|
|
114
|
-
if (XHR && origOpen) XHR.prototype.open = origOpen;
|
|
115
|
-
if (XHR && origSend) XHR.prototype.send = origSend;
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Up to `maxLength` characters of a response body (one more, so `cut` marks it
|
|
120
|
-
* cut), then the stream is cancelled: a large payload is never read in full.
|
|
121
|
-
*/
|
|
122
|
-
async function readPrefix(response, maxLength) {
|
|
123
|
-
const reader = response.body?.getReader();
|
|
124
|
-
if (!reader) return void 0;
|
|
125
|
-
const decoder = new TextDecoder();
|
|
126
|
-
let text = "";
|
|
127
|
-
const deadline = Date.now() + BODY_READ_TIMEOUT_MS;
|
|
128
|
-
while (text.length <= maxLength) {
|
|
129
|
-
const remaining = deadline - Date.now();
|
|
130
|
-
const chunk = remaining > 0 ? await readWithin(reader, remaining) : void 0;
|
|
131
|
-
if (!chunk) {
|
|
132
|
-
reader.cancel().catch(() => {});
|
|
133
|
-
return text || void 0;
|
|
134
|
-
}
|
|
135
|
-
if (chunk.done) return text + decoder.decode();
|
|
136
|
-
text += decoder.decode(chunk.value, { stream: true });
|
|
137
|
-
}
|
|
138
|
-
reader.cancel().catch(() => {});
|
|
139
|
-
return text;
|
|
140
|
-
}
|
|
141
|
-
/** One read, or `undefined` if nothing arrives within `ms`. */
|
|
142
|
-
function readWithin(reader, ms) {
|
|
143
|
-
let timer;
|
|
144
|
-
const timeout = new Promise((resolve) => {
|
|
145
|
-
timer = setTimeout(() => resolve(void 0), ms);
|
|
146
|
-
});
|
|
147
|
-
return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer));
|
|
148
|
-
}
|
|
149
|
-
/** A text or JSON XHR response, as text; the browser already holds it, so this only slices. */
|
|
150
|
-
function xhrResponseText(xhr) {
|
|
151
|
-
if (!isReadableText(xhr.getResponseHeader("content-type") ?? "")) return void 0;
|
|
152
|
-
if (xhr.responseType === "" || xhr.responseType === "text") return xhr.responseText;
|
|
153
|
-
if (xhr.responseType === "json") try {
|
|
154
|
-
return JSON.stringify(xhr.response);
|
|
155
|
-
} catch {
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
function requestUrl(input) {
|
|
160
|
-
if (typeof input === "string") return input;
|
|
161
|
-
if (input instanceof URL) return input.href;
|
|
162
|
-
return input.url;
|
|
163
|
-
}
|
|
164
|
-
function requestMethod(input, init) {
|
|
165
|
-
return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
|
|
166
|
-
}
|
|
167
|
-
//#endregion
|
|
168
|
-
//#region ../browser-session/src/replay/events.ts
|
|
169
|
-
/**
|
|
170
|
-
* Install the capture modules that turn console output and network requests
|
|
171
|
-
* into distilled session events.
|
|
172
|
-
*
|
|
173
|
-
* Navigation, errors and interactions are deliberately absent: the sink
|
|
174
|
-
* installs those itself (see `startBaselineCapture`), because page views, error
|
|
175
|
-
* counts and click counts have to be counted even when replay is unsampled —
|
|
176
|
-
* and a second listener here would double-count every one of them.
|
|
177
|
-
*/
|
|
178
|
-
function startEventCapture(config, sessionId) {
|
|
179
|
-
const sink = startEventSink(config, sessionId);
|
|
180
|
-
const emit = sink.emit;
|
|
181
|
-
const uninstall = [installConsoleCapture(emit), installNetworkCapture(emit, sink.ignoreUrl, config.maskAllText || !config.networkBodies ? void 0 : {
|
|
182
|
-
...config.networkBodies,
|
|
183
|
-
requestBodies: !config.maskAllInputs
|
|
184
|
-
})];
|
|
185
|
-
return {
|
|
186
|
-
stop: () => {
|
|
187
|
-
for (const off of uninstall) off();
|
|
188
|
-
},
|
|
189
|
-
flush: sink.flush
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
//#endregion
|
|
193
|
-
//#region ../browser-session/src/replay/record.ts
|
|
194
|
-
const FULL_SNAPSHOT = 2;
|
|
195
|
-
const META = 4;
|
|
196
|
-
const INCREMENTAL = 3;
|
|
197
|
-
const SOURCE_MOUSE_INTERACTION = 2;
|
|
198
|
-
const MOUSE_CLICK = 2;
|
|
199
|
-
const FLUSH_INTERVAL_MS = 5e3;
|
|
200
|
-
const FLUSH_BYTES = 102400;
|
|
201
|
-
const CHECKOUT_EVERY_MS = 3e5;
|
|
202
|
-
const MAX_BUFFER_BYTES = 4194304;
|
|
203
|
-
function warnExhausted(sessionId) {
|
|
204
|
-
console.warn(`[maple] session replay ${sessionId} reached its maximum recorded size; recording stopped for this session (metadata and events continue)`);
|
|
205
|
-
}
|
|
206
|
-
let lastDropWarnAt = 0;
|
|
207
|
-
function warnBufferDropped(bytes) {
|
|
208
|
-
const now = Date.now();
|
|
209
|
-
if (now - lastDropWarnAt < 3e4) return;
|
|
210
|
-
lastDropWarnAt = now;
|
|
211
|
-
console.warn(`[maple] session replay buffer exceeded ${MAX_BUFFER_BYTES} bytes (dropping ${bytes} buffered bytes; recording continues from the next full snapshot)`);
|
|
212
|
-
}
|
|
213
|
-
const MAX_PENDING_AGE_MS = 6e5;
|
|
214
|
-
const PAGE_STARTED_AT = Date.now();
|
|
215
|
-
/** How long a stored chunk that found the keepalive budget full waits before its one re-attempt. */
|
|
216
|
-
const KEEPALIVE_RETRY_MS = 1e3;
|
|
217
|
-
function isPendingChunk(value) {
|
|
218
|
-
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";
|
|
219
|
-
}
|
|
220
|
-
/** Endpoint plus an FNV-1a hash of the key, so the key itself is not stored. */
|
|
221
|
-
function targetOf(config) {
|
|
222
|
-
let hash = 2166136261;
|
|
223
|
-
for (const char of config.ingestKey ?? "") hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
|
224
|
-
return `${config.endpoint}|${hash >>> 0}`;
|
|
225
|
-
}
|
|
226
|
-
function readPending() {
|
|
227
|
-
if (!mayUseStorage()) return void 0;
|
|
228
|
-
try {
|
|
229
|
-
const parsed = JSON.parse(window.sessionStorage.getItem("maple.replay.pending") ?? "null");
|
|
230
|
-
return isPendingChunk(parsed) ? parsed : void 0;
|
|
231
|
-
} catch {
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Keep `chunk` unless one is already waiting. False when it was not kept
|
|
237
|
-
* (storage blocked or full): it is then only as durable as its upload.
|
|
238
|
-
*/
|
|
239
|
-
function storePending(chunk) {
|
|
240
|
-
if (!mayUseStorage()) return false;
|
|
241
|
-
try {
|
|
242
|
-
if (readPending()) return false;
|
|
243
|
-
window.sessionStorage.setItem(PENDING_CHUNK_KEY, JSON.stringify(chunk));
|
|
244
|
-
return true;
|
|
245
|
-
} catch {
|
|
246
|
-
return false;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* Remove the stored chunk if it is `meta`'s, reporting whether it was. Whoever
|
|
251
|
-
* removes it sends it, so a seq is posted once: ingest appends an index row per POST.
|
|
252
|
-
*/
|
|
253
|
-
function takePending(meta) {
|
|
254
|
-
const chunk = readPending();
|
|
255
|
-
return chunk?.sessionId === meta.sessionId && chunk.chunkSeq === meta.chunkSeq && clearPendingChunk();
|
|
256
|
-
}
|
|
257
|
-
/** Send the chunk an earlier flush left behind, if it is still this recorder's to send; otherwise drop it. */
|
|
258
|
-
function sendPendingChunk(config, sessionId) {
|
|
259
|
-
const chunk = readPending();
|
|
260
|
-
if (!chunk) return;
|
|
261
|
-
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);
|
|
262
|
-
else clearPendingChunk();
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* Claim a seq and upload one chunk. The seq is monotonic across reloads
|
|
266
|
-
* (persisted on the session record), so a refresh continues the sequence
|
|
267
|
-
* instead of overwriting the previous load's blobs.
|
|
268
|
-
*/
|
|
269
|
-
function uploadChunk(config, sessionId, body, chunk, keepalive) {
|
|
270
|
-
const meta = {
|
|
271
|
-
sessionId,
|
|
272
|
-
chunkSeq: nextChunkSeq(),
|
|
273
|
-
...chunk
|
|
274
|
-
};
|
|
275
|
-
return compressAndPost(config, meta, body, keepalive, keepalive && storePending({
|
|
276
|
-
...meta,
|
|
277
|
-
body,
|
|
278
|
-
createdAt: Date.now(),
|
|
279
|
-
target: targetOf(config)
|
|
280
|
-
}));
|
|
281
|
-
}
|
|
282
|
-
async function compressAndPost(config, meta, body, keepalive, stored) {
|
|
283
|
-
let gzipped;
|
|
284
|
-
try {
|
|
285
|
-
gzipped = await gzip(new TextEncoder().encode(body));
|
|
286
|
-
} catch (error) {
|
|
287
|
-
warnDropped("chunk compression", error);
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
if (stored) {
|
|
291
|
-
if (keepalive) {
|
|
292
|
-
const release = reserveKeepalive(true, gzipped.byteLength);
|
|
293
|
-
if (release) release();
|
|
294
|
-
else await new Promise((resolve) => setTimeout(resolve, KEEPALIVE_RETRY_MS));
|
|
295
|
-
}
|
|
296
|
-
if (!takePending(meta)) return void 0;
|
|
297
|
-
}
|
|
298
|
-
return postSessionBlob(config, meta, gzipped, keepalive);
|
|
299
|
-
}
|
|
300
|
-
function startRecording(config, sessionId) {
|
|
301
|
-
sendPendingChunk(config, sessionId);
|
|
302
|
-
let parts = [];
|
|
303
|
-
let bufferBytes = 0;
|
|
304
|
-
let bufferHasCheckpoint = false;
|
|
305
|
-
let firstTimestamp = 0;
|
|
306
|
-
let lastTimestamp = 0;
|
|
307
|
-
let droppedChunk = false;
|
|
308
|
-
let clickCount = 0;
|
|
309
|
-
let stopped = false;
|
|
310
|
-
let exhausted = false;
|
|
311
|
-
const resetBuffer = () => {
|
|
312
|
-
parts = [];
|
|
313
|
-
bufferBytes = 0;
|
|
314
|
-
bufferHasCheckpoint = false;
|
|
315
|
-
firstTimestamp = 0;
|
|
316
|
-
lastTimestamp = 0;
|
|
317
|
-
};
|
|
318
|
-
const flush = async (keepalive = false) => {
|
|
319
|
-
if (stopped || exhausted || parts.length === 0) return;
|
|
320
|
-
const body = `[${parts.join(",")}]`;
|
|
321
|
-
const isCheckpoint = bufferHasCheckpoint;
|
|
322
|
-
const eventCount = parts.length;
|
|
323
|
-
const durationMs = Math.max(0, lastTimestamp - firstTimestamp);
|
|
324
|
-
resetBuffer();
|
|
325
|
-
if (await uploadChunk(config, sessionId, body, {
|
|
326
|
-
isCheckpoint,
|
|
327
|
-
eventCount,
|
|
328
|
-
durationMs
|
|
329
|
-
}, keepalive) === "exhausted" && !exhausted) {
|
|
330
|
-
exhausted = true;
|
|
331
|
-
warnExhausted(sessionId);
|
|
332
|
-
haltCapture();
|
|
333
|
-
}
|
|
334
|
-
};
|
|
335
|
-
let haltCapture = () => {};
|
|
336
|
-
const stop = record({
|
|
337
|
-
emit: (event, isCheckpoint) => {
|
|
338
|
-
const active = markActivity();
|
|
339
|
-
if (active && active.id !== sessionId) return;
|
|
340
|
-
const e = event;
|
|
341
|
-
if (e.type === META && e.data && typeof e.data.href === "string") e.data.href = scrubUrl(e.data.href);
|
|
342
|
-
const isFullSnapshot = isCheckpoint === true || e.type === FULL_SNAPSHOT;
|
|
343
|
-
if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
|
|
344
|
-
let json;
|
|
345
|
-
try {
|
|
346
|
-
json = JSON.stringify(e);
|
|
347
|
-
} catch {
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
if (droppedChunk && !isFullSnapshot) return;
|
|
351
|
-
droppedChunk = false;
|
|
352
|
-
if (bufferBytes + json.length > MAX_BUFFER_BYTES) {
|
|
353
|
-
warnBufferDropped(bufferBytes + json.length);
|
|
354
|
-
resetBuffer();
|
|
355
|
-
droppedChunk = true;
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
if (isFullSnapshot) bufferHasCheckpoint = true;
|
|
359
|
-
if (parts.length === 0) firstTimestamp = e.timestamp;
|
|
360
|
-
lastTimestamp = e.timestamp;
|
|
361
|
-
parts.push(json);
|
|
362
|
-
bufferBytes += json.length;
|
|
363
|
-
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
364
|
-
},
|
|
365
|
-
maskAllInputs: config.maskAllInputs,
|
|
366
|
-
blockSelector: BLOCK_SELECTOR,
|
|
367
|
-
...config.maskAllText ? { maskTextSelector: "*" } : void 0,
|
|
368
|
-
checkoutEveryNms: CHECKOUT_EVERY_MS,
|
|
369
|
-
...canvasOptions(config)
|
|
370
|
-
});
|
|
371
|
-
let idleHandle;
|
|
372
|
-
const scheduleFlush = () => {
|
|
373
|
-
if (typeof requestIdleCallback === "function") idleHandle = requestIdleCallback(() => {
|
|
374
|
-
idleHandle = void 0;
|
|
375
|
-
flush();
|
|
376
|
-
}, { timeout: 2e3 });
|
|
377
|
-
else flush();
|
|
378
|
-
};
|
|
379
|
-
const flushTimer = setInterval(scheduleFlush, FLUSH_INTERVAL_MS);
|
|
380
|
-
let halted = false;
|
|
381
|
-
haltCapture = () => {
|
|
382
|
-
if (halted) return;
|
|
383
|
-
halted = true;
|
|
384
|
-
clearInterval(flushTimer);
|
|
385
|
-
if (idleHandle !== void 0 && typeof cancelIdleCallback === "function") {
|
|
386
|
-
cancelIdleCallback(idleHandle);
|
|
387
|
-
idleHandle = void 0;
|
|
388
|
-
}
|
|
389
|
-
resetBuffer();
|
|
390
|
-
stop?.();
|
|
391
|
-
};
|
|
392
|
-
return {
|
|
393
|
-
stop: () => {
|
|
394
|
-
stopped = true;
|
|
395
|
-
haltCapture();
|
|
396
|
-
},
|
|
397
|
-
flush,
|
|
398
|
-
getClickCount: () => clickCount
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
/** rrweb options for `<canvas>` capture: sampled frames as WebP, off unless asked for. */
|
|
402
|
-
function canvasOptions(config) {
|
|
403
|
-
if (config.maskAllText || !config.canvasFps || config.canvasFps <= 0) return void 0;
|
|
404
|
-
return {
|
|
405
|
-
recordCanvas: true,
|
|
406
|
-
sampling: { canvas: config.canvasFps },
|
|
407
|
-
dataURLOptions: {
|
|
408
|
-
type: "image/webp",
|
|
409
|
-
quality: .6
|
|
410
|
-
}
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
/**
|
|
414
|
-
* Buffer mode checks out often, so the retained window stays near a minute. A
|
|
415
|
-
* checkout is a full DOM snapshot, so it is skipped while hidden or when nothing changed.
|
|
416
|
-
*/
|
|
417
|
-
const BUFFER_CHECKOUT_MS = 3e4;
|
|
418
|
-
/**
|
|
419
|
-
* Record into memory only: the segments since the second-to-last checkout, so
|
|
420
|
-
* 30-60s of replay, and nothing is uploaded until `drain()`. For sessions that
|
|
421
|
-
* keep a replay only when an error happens.
|
|
422
|
-
*/
|
|
423
|
-
function startBufferedRecording(config, sessionId) {
|
|
424
|
-
let segments = [];
|
|
425
|
-
let bytes = 0;
|
|
426
|
-
let clickCount = 0;
|
|
427
|
-
let stopped = false;
|
|
428
|
-
/** Incremental events since the last snapshot: an idle page needs no new one. */
|
|
429
|
-
let changedSinceSnapshot = 0;
|
|
430
|
-
const stopRecord = record({
|
|
431
|
-
emit: (event) => {
|
|
432
|
-
const active = markActivity();
|
|
433
|
-
if (stopped || active && active.id !== sessionId) return;
|
|
434
|
-
const e = event;
|
|
435
|
-
if (e.type === META && e.data && typeof e.data.href === "string") e.data.href = scrubUrl(e.data.href);
|
|
436
|
-
if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
|
|
437
|
-
let json;
|
|
438
|
-
try {
|
|
439
|
-
json = JSON.stringify(e);
|
|
440
|
-
} catch {
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (e.type === META) {
|
|
444
|
-
segments.push({
|
|
445
|
-
parts: [],
|
|
446
|
-
bytes: 0,
|
|
447
|
-
first: e.timestamp,
|
|
448
|
-
last: e.timestamp
|
|
449
|
-
});
|
|
450
|
-
while (segments.length > 2) bytes -= segments.shift()?.bytes ?? 0;
|
|
451
|
-
changedSinceSnapshot = 0;
|
|
452
|
-
} else if (e.type === INCREMENTAL) changedSinceSnapshot++;
|
|
453
|
-
const segment = segments.at(-1);
|
|
454
|
-
if (!segment) return;
|
|
455
|
-
segment.parts.push(json);
|
|
456
|
-
segment.bytes += json.length;
|
|
457
|
-
segment.last = e.timestamp;
|
|
458
|
-
bytes += json.length;
|
|
459
|
-
while (bytes > MAX_BUFFER_BYTES && segments.length > 0) bytes -= segments.shift()?.bytes ?? 0;
|
|
460
|
-
},
|
|
461
|
-
maskAllInputs: config.maskAllInputs,
|
|
462
|
-
blockSelector: BLOCK_SELECTOR,
|
|
463
|
-
...config.maskAllText ? { maskTextSelector: "*" } : void 0,
|
|
464
|
-
...canvasOptions(config)
|
|
465
|
-
});
|
|
466
|
-
const checkoutTimer = setInterval(() => {
|
|
467
|
-
if (stopped || changedSinceSnapshot === 0) return;
|
|
468
|
-
const current = segments.at(-1);
|
|
469
|
-
if (!(current === void 0 || current.bytes > MAX_BUFFER_BYTES / 2) && typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
470
|
-
try {
|
|
471
|
-
record.takeFullSnapshot(true);
|
|
472
|
-
} catch {}
|
|
473
|
-
}, BUFFER_CHECKOUT_MS);
|
|
474
|
-
return {
|
|
475
|
-
drain: async (keepalive = false) => {
|
|
476
|
-
const pending = segments;
|
|
477
|
-
segments = [];
|
|
478
|
-
bytes = 0;
|
|
479
|
-
if (stopped) return;
|
|
480
|
-
await Promise.all(pending.map((segment) => uploadChunk(config, sessionId, `[${segment.parts.join(",")}]`, {
|
|
481
|
-
isCheckpoint: true,
|
|
482
|
-
eventCount: segment.parts.length,
|
|
483
|
-
durationMs: Math.max(0, segment.last - segment.first)
|
|
484
|
-
}, keepalive)));
|
|
485
|
-
},
|
|
486
|
-
stop: () => {
|
|
487
|
-
stopped = true;
|
|
488
|
-
clearInterval(checkoutTimer);
|
|
489
|
-
segments = [];
|
|
490
|
-
bytes = 0;
|
|
491
|
-
stopRecord?.();
|
|
492
|
-
},
|
|
493
|
-
getClickCount: () => clickCount
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
//#endregion
|
|
497
|
-
//#region ../browser-session/src/session/replay-session.ts
|
|
498
|
-
/**
|
|
499
|
-
* Start recording the current browser session. Publishes the session sink,
|
|
500
|
-
* posts an `active` metadata row, and installs visibility handlers:
|
|
501
|
-
* hidden → flush + `ended` row (with observed trace ids) + stop capture;
|
|
502
|
-
* visible → re-resolve the session (rotating if idle-expired), republish the
|
|
503
|
-
* sink, restart capture, post a fresh `active` row. Metadata versions are
|
|
504
|
-
* monotonic per session, so the latest row always wins on the backend.
|
|
505
|
-
*
|
|
506
|
-
* Returns undefined outside a browser. Sampling is the caller's decision.
|
|
507
|
-
*/
|
|
508
|
-
function startReplaySession(options) {
|
|
509
|
-
if (typeof window === "undefined") return void 0;
|
|
510
|
-
const engineConfig = {
|
|
511
|
-
endpoint: options.endpoint.replace(/\/$/, ""),
|
|
512
|
-
ingestKey: options.ingestKey,
|
|
513
|
-
sdk: options.sdk,
|
|
514
|
-
maskAllInputs: options.maskAllInputs,
|
|
515
|
-
maskAllText: options.maskAllText,
|
|
516
|
-
getIdentity: options.getIdentity,
|
|
517
|
-
canvasFps: options.canvasFps,
|
|
518
|
-
networkBodies: options.networkBodies
|
|
519
|
-
};
|
|
520
|
-
let recorder;
|
|
521
|
-
let buffered;
|
|
522
|
-
let events;
|
|
523
|
-
let publishedSessionId;
|
|
524
|
-
const buffering = options.mode === "buffer";
|
|
525
|
-
let triggered = !buffering;
|
|
526
|
-
/** Clicks the buffered recorder saw before a trigger replaced it. */
|
|
527
|
-
let clicksBeforeTrigger = 0;
|
|
528
|
-
const publish = (sessionId) => {
|
|
529
|
-
if (publishedSessionId === sessionId) return;
|
|
530
|
-
publishedSessionId = sessionId;
|
|
531
|
-
publishSessionSink(sessionId);
|
|
532
|
-
};
|
|
533
|
-
const startStreaming = (sessionId) => {
|
|
534
|
-
recorder = startRecording(engineConfig, sessionId);
|
|
535
|
-
events = startEventCapture(engineConfig, sessionId);
|
|
536
|
-
};
|
|
537
|
-
const lifecycle = startSessionLifecycle({
|
|
538
|
-
...options,
|
|
539
|
-
getTraceIds: getObservedTraceIds
|
|
540
|
-
}, {
|
|
541
|
-
recorded: () => triggered,
|
|
542
|
-
buffered: () => buffering,
|
|
543
|
-
post: (row, keepalive) => {
|
|
544
|
-
postSessionMeta(engineConfig, row, keepalive);
|
|
545
|
-
},
|
|
546
|
-
clicksSinceStart: () => clicksBeforeTrigger + (recorder?.getClickCount() ?? buffered?.getClickCount() ?? 0),
|
|
547
|
-
onStart: (record) => {
|
|
548
|
-
publish(record.id);
|
|
549
|
-
clicksBeforeTrigger = 0;
|
|
550
|
-
if (triggered) startStreaming(record.id);
|
|
551
|
-
else buffered = startBufferedRecording(engineConfig, record.id);
|
|
552
|
-
},
|
|
553
|
-
onSuspend: ({ flush, keepalive }) => {
|
|
554
|
-
const stoppingRecorder = recorder;
|
|
555
|
-
const stoppingEvents = events;
|
|
556
|
-
recorder = void 0;
|
|
557
|
-
events = void 0;
|
|
558
|
-
buffered?.stop();
|
|
559
|
-
buffered = void 0;
|
|
560
|
-
const flushed = flush ? Promise.all([stoppingRecorder?.flush(keepalive), stoppingEvents?.flush(keepalive)]).then(() => {}) : void 0;
|
|
561
|
-
stoppingRecorder?.stop();
|
|
562
|
-
stoppingEvents?.stop();
|
|
563
|
-
return flushed;
|
|
564
|
-
},
|
|
565
|
-
onSessionChange: (sessionId) => {
|
|
566
|
-
triggered = !buffering;
|
|
567
|
-
publish(sessionId);
|
|
568
|
-
options.onSessionChange?.(sessionId);
|
|
569
|
-
}
|
|
570
|
-
});
|
|
571
|
-
if (!lifecycle) return void 0;
|
|
572
|
-
return {
|
|
573
|
-
get sessionId() {
|
|
574
|
-
return lifecycle.sessionId;
|
|
575
|
-
},
|
|
576
|
-
announce: lifecycle.announce,
|
|
577
|
-
shutdown: lifecycle.shutdown,
|
|
578
|
-
trigger: async () => {
|
|
579
|
-
if (triggered) return;
|
|
580
|
-
triggered = true;
|
|
581
|
-
const sessionId = lifecycle.sessionId;
|
|
582
|
-
markReplayTriggered(sessionId);
|
|
583
|
-
const pending = buffered;
|
|
584
|
-
buffered = void 0;
|
|
585
|
-
if (!pending) return;
|
|
586
|
-
clicksBeforeTrigger = pending.getClickCount();
|
|
587
|
-
const drained = pending.drain();
|
|
588
|
-
pending.stop();
|
|
589
|
-
startStreaming(sessionId);
|
|
590
|
-
lifecycle.announce();
|
|
591
|
-
await drained;
|
|
592
|
-
}
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
//#endregion
|
|
596
|
-
export { startReplaySession };
|