@maple-dev/browser 0.3.0 → 0.4.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/LICENSE +21 -0
- package/README.md +36 -1
- package/dist/index.d.mts +92 -8
- package/dist/index.mjs +437 -727
- package/dist/replay-session-B_ACRogX.mjs +390 -0
- package/dist/sink-DHTfvFgl.mjs +1149 -0
- package/package.json +3 -3
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { _ as gzip, f as startEventSink, h as nextChunkSeq, m as markActivity, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, s as activeTraceId, v as postSessionBlob, y as postSessionMeta } from "./sink-DHTfvFgl.mjs";
|
|
2
|
+
import { record } from "rrweb";
|
|
3
|
+
//#region ../browser-session/src/replay/capture/shared.ts
|
|
4
|
+
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
5
|
+
function safeEmit(emit, ev) {
|
|
6
|
+
try {
|
|
7
|
+
emit(ev);
|
|
8
|
+
} catch {}
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region ../browser-session/src/replay/capture/console.ts
|
|
12
|
+
const LEVELS = [
|
|
13
|
+
"log",
|
|
14
|
+
"info",
|
|
15
|
+
"warn",
|
|
16
|
+
"error",
|
|
17
|
+
"debug"
|
|
18
|
+
];
|
|
19
|
+
const MAX_MESSAGE = 2e3;
|
|
20
|
+
/**
|
|
21
|
+
* Capture `console.*` calls as session events. Wraps each method, emits a
|
|
22
|
+
* distilled record, then forwards to the original so the host app's console
|
|
23
|
+
* behaves normally. Never throws into the call site.
|
|
24
|
+
*/
|
|
25
|
+
function installConsoleCapture(emit) {
|
|
26
|
+
const original = {};
|
|
27
|
+
for (const level of LEVELS) {
|
|
28
|
+
const orig = console[level];
|
|
29
|
+
original[level] = orig;
|
|
30
|
+
console[level] = (...args) => {
|
|
31
|
+
safeEmit(emit, {
|
|
32
|
+
type: "console",
|
|
33
|
+
level,
|
|
34
|
+
message: formatArgs(args)
|
|
35
|
+
});
|
|
36
|
+
orig.apply(console, args);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return () => {
|
|
40
|
+
for (const level of LEVELS) {
|
|
41
|
+
const orig = original[level];
|
|
42
|
+
if (orig) console[level] = orig;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function formatArgs(args) {
|
|
47
|
+
const text = args.map((a) => {
|
|
48
|
+
if (typeof a === "string") return a;
|
|
49
|
+
if (a instanceof Error) return `${a.name}: ${a.message}`;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.stringify(a);
|
|
52
|
+
} catch {
|
|
53
|
+
return String(a);
|
|
54
|
+
}
|
|
55
|
+
}).join(" ");
|
|
56
|
+
return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region ../browser-session/src/replay/capture/errors.ts
|
|
60
|
+
const MAX_STACK = 4e3;
|
|
61
|
+
/** Capture uncaught errors + unhandled promise rejections as session events. */
|
|
62
|
+
function installErrorCapture(emit) {
|
|
63
|
+
const onError = (event) => {
|
|
64
|
+
safeEmit(emit, {
|
|
65
|
+
type: "error",
|
|
66
|
+
level: "error",
|
|
67
|
+
message: event.message || String(event.error ?? "Error"),
|
|
68
|
+
errorStack: truncate(event.error?.stack),
|
|
69
|
+
traceId: activeTraceId()
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
const onRejection = (event) => {
|
|
73
|
+
const reason = event.reason;
|
|
74
|
+
safeEmit(emit, {
|
|
75
|
+
type: "error",
|
|
76
|
+
level: "error",
|
|
77
|
+
message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
|
|
78
|
+
errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
|
|
79
|
+
traceId: activeTraceId()
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener("error", onError);
|
|
83
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
84
|
+
return () => {
|
|
85
|
+
window.removeEventListener("error", onError);
|
|
86
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function truncate(stack) {
|
|
90
|
+
if (!stack) return void 0;
|
|
91
|
+
return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region ../browser-session/src/replay/capture/interactions.ts
|
|
95
|
+
const MAX_TEXT = 120;
|
|
96
|
+
/**
|
|
97
|
+
* Capture clicks and input events as session events. Listens in the capture
|
|
98
|
+
* phase so it sees interactions even when the host app calls
|
|
99
|
+
* `stopPropagation()`. Input *values* are never recorded; only the target
|
|
100
|
+
* element. Click target text is omitted when `maskAllText` is set.
|
|
101
|
+
*/
|
|
102
|
+
function installInteractionCapture(emit, maskAllText) {
|
|
103
|
+
const onClick = (event) => {
|
|
104
|
+
const target = event.target;
|
|
105
|
+
if (!(target instanceof Element)) return;
|
|
106
|
+
safeEmit(emit, {
|
|
107
|
+
type: "click",
|
|
108
|
+
targetSelector: selectorOf(target),
|
|
109
|
+
targetText: maskAllText ? void 0 : textOf(target)
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
const onInput = (event) => {
|
|
113
|
+
const target = event.target;
|
|
114
|
+
if (!(target instanceof Element)) return;
|
|
115
|
+
safeEmit(emit, {
|
|
116
|
+
type: "input",
|
|
117
|
+
targetSelector: selectorOf(target)
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
document.addEventListener("click", onClick, true);
|
|
121
|
+
document.addEventListener("input", onInput, true);
|
|
122
|
+
return () => {
|
|
123
|
+
document.removeEventListener("click", onClick, true);
|
|
124
|
+
document.removeEventListener("input", onInput, true);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** A short, human-readable selector: tag + #id + .first-class. */
|
|
128
|
+
function selectorOf(el) {
|
|
129
|
+
return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
|
|
130
|
+
}
|
|
131
|
+
function textOf(el) {
|
|
132
|
+
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
133
|
+
if (!text) return void 0;
|
|
134
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region ../browser-session/src/replay/capture/network.ts
|
|
138
|
+
/**
|
|
139
|
+
* Capture fetch + XHR requests as session events, tagged with the active trace
|
|
140
|
+
* id so each request links to its backend trace. `ignoreUrl` skips Maple's own
|
|
141
|
+
* ingest endpoints (otherwise capturing the session-events POST would loop).
|
|
142
|
+
*/
|
|
143
|
+
function installNetworkCapture(emit, ignoreUrl) {
|
|
144
|
+
const origFetch = typeof window !== "undefined" ? window.fetch : void 0;
|
|
145
|
+
if (origFetch) window.fetch = async (input, init) => {
|
|
146
|
+
const url = requestUrl(input);
|
|
147
|
+
const method = requestMethod(input, init);
|
|
148
|
+
const traceId = activeTraceId();
|
|
149
|
+
const start = performance.now();
|
|
150
|
+
try {
|
|
151
|
+
const res = await origFetch(input, init);
|
|
152
|
+
record(url, method, res.status, start, traceId);
|
|
153
|
+
return res;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
record(url, method, 0, start, traceId, String(error));
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const record = (url, method, status, start, traceId, error) => {
|
|
160
|
+
if (ignoreUrl(url)) return;
|
|
161
|
+
safeEmit(emit, {
|
|
162
|
+
type: "network",
|
|
163
|
+
net: {
|
|
164
|
+
method,
|
|
165
|
+
url,
|
|
166
|
+
status,
|
|
167
|
+
durationMs: Math.round(performance.now() - start)
|
|
168
|
+
},
|
|
169
|
+
traceId,
|
|
170
|
+
...error ? { attrs: { error } } : {}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
|
|
174
|
+
const origOpen = XHR?.prototype.open;
|
|
175
|
+
const origSend = XHR?.prototype.send;
|
|
176
|
+
if (XHR && origOpen && origSend) {
|
|
177
|
+
XHR.prototype.open = function(method, url, ...rest) {
|
|
178
|
+
this.__mapleMethod = String(method).toUpperCase();
|
|
179
|
+
this.__mapleUrl = typeof url === "string" ? url : url.href;
|
|
180
|
+
return origOpen.apply(this, [
|
|
181
|
+
method,
|
|
182
|
+
url,
|
|
183
|
+
...rest
|
|
184
|
+
]);
|
|
185
|
+
};
|
|
186
|
+
XHR.prototype.send = function(...args) {
|
|
187
|
+
const meta = this;
|
|
188
|
+
const start = performance.now();
|
|
189
|
+
const traceId = activeTraceId();
|
|
190
|
+
this.addEventListener("loadend", () => {
|
|
191
|
+
record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
|
|
192
|
+
});
|
|
193
|
+
return origSend.apply(this, args);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return () => {
|
|
197
|
+
if (origFetch) window.fetch = origFetch;
|
|
198
|
+
if (XHR && origOpen) XHR.prototype.open = origOpen;
|
|
199
|
+
if (XHR && origSend) XHR.prototype.send = origSend;
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function requestUrl(input) {
|
|
203
|
+
if (typeof input === "string") return input;
|
|
204
|
+
if (input instanceof URL) return input.href;
|
|
205
|
+
return input.url;
|
|
206
|
+
}
|
|
207
|
+
function requestMethod(input, init) {
|
|
208
|
+
return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region ../browser-session/src/replay/events.ts
|
|
212
|
+
/**
|
|
213
|
+
* Install the capture modules that turn browser activity (console, network,
|
|
214
|
+
* errors, interactions) into distilled session events.
|
|
215
|
+
*
|
|
216
|
+
* Navigation is deliberately absent: the sink observes it, because page views
|
|
217
|
+
* have to be counted even when replay is unsampled, and a second history patch
|
|
218
|
+
* here would double-count every SPA transition.
|
|
219
|
+
*/
|
|
220
|
+
function startEventCapture(config, sessionId) {
|
|
221
|
+
const sink = startEventSink(config, sessionId);
|
|
222
|
+
const emit = sink.emit;
|
|
223
|
+
const uninstall = [
|
|
224
|
+
installInteractionCapture(emit, config.maskAllText),
|
|
225
|
+
installConsoleCapture(emit),
|
|
226
|
+
installNetworkCapture(emit, sink.ignoreUrl),
|
|
227
|
+
installErrorCapture(emit)
|
|
228
|
+
];
|
|
229
|
+
return {
|
|
230
|
+
stop: () => {
|
|
231
|
+
for (const off of uninstall) off();
|
|
232
|
+
},
|
|
233
|
+
flush: sink.flush
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region ../browser-session/src/replay/record.ts
|
|
238
|
+
const FULL_SNAPSHOT = 2;
|
|
239
|
+
const INCREMENTAL = 3;
|
|
240
|
+
const SOURCE_MOUSE_INTERACTION = 2;
|
|
241
|
+
const MOUSE_CLICK = 2;
|
|
242
|
+
const FLUSH_INTERVAL_MS = 5e3;
|
|
243
|
+
const FLUSH_BYTES = 102400;
|
|
244
|
+
const CHECKOUT_EVERY_MS = 3e5;
|
|
245
|
+
const MAX_BUFFER_BYTES = 4194304;
|
|
246
|
+
let lastDropWarnAt = 0;
|
|
247
|
+
function warnBufferDropped(bytes) {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
if (now - lastDropWarnAt < 3e4) return;
|
|
250
|
+
lastDropWarnAt = now;
|
|
251
|
+
console.warn(`[maple] session replay buffer exceeded ${MAX_BUFFER_BYTES} bytes (dropping ${bytes} buffered bytes; recording continues from the next full snapshot)`);
|
|
252
|
+
}
|
|
253
|
+
function startRecording(config, sessionId) {
|
|
254
|
+
let parts = [];
|
|
255
|
+
let bufferBytes = 0;
|
|
256
|
+
let bufferHasCheckpoint = false;
|
|
257
|
+
let firstTimestamp = 0;
|
|
258
|
+
let lastTimestamp = 0;
|
|
259
|
+
let droppedChunk = false;
|
|
260
|
+
let clickCount = 0;
|
|
261
|
+
const resetBuffer = () => {
|
|
262
|
+
parts = [];
|
|
263
|
+
bufferBytes = 0;
|
|
264
|
+
bufferHasCheckpoint = false;
|
|
265
|
+
firstTimestamp = 0;
|
|
266
|
+
lastTimestamp = 0;
|
|
267
|
+
};
|
|
268
|
+
const flush = async (keepalive = false) => {
|
|
269
|
+
if (parts.length === 0) return;
|
|
270
|
+
const body = `[${parts.join(",")}]`;
|
|
271
|
+
const isCheckpoint = bufferHasCheckpoint;
|
|
272
|
+
const eventCount = parts.length;
|
|
273
|
+
const durationMs = Math.max(0, lastTimestamp - firstTimestamp);
|
|
274
|
+
const seq = nextChunkSeq();
|
|
275
|
+
resetBuffer();
|
|
276
|
+
const gzipped = await gzip(new TextEncoder().encode(body));
|
|
277
|
+
await postSessionBlob(config, {
|
|
278
|
+
sessionId,
|
|
279
|
+
chunkSeq: seq,
|
|
280
|
+
isCheckpoint,
|
|
281
|
+
eventCount,
|
|
282
|
+
durationMs
|
|
283
|
+
}, gzipped, keepalive);
|
|
284
|
+
};
|
|
285
|
+
const stop = record({
|
|
286
|
+
emit: (event, isCheckpoint) => {
|
|
287
|
+
const active = markActivity();
|
|
288
|
+
if (active && active.id !== sessionId) return;
|
|
289
|
+
const e = event;
|
|
290
|
+
const isFullSnapshot = isCheckpoint === true || e.type === FULL_SNAPSHOT;
|
|
291
|
+
if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
|
|
292
|
+
let json;
|
|
293
|
+
try {
|
|
294
|
+
json = JSON.stringify(e);
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (droppedChunk && !isFullSnapshot) return;
|
|
299
|
+
droppedChunk = false;
|
|
300
|
+
if (bufferBytes + json.length > MAX_BUFFER_BYTES) {
|
|
301
|
+
warnBufferDropped(bufferBytes + json.length);
|
|
302
|
+
resetBuffer();
|
|
303
|
+
droppedChunk = true;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (isFullSnapshot) bufferHasCheckpoint = true;
|
|
307
|
+
if (parts.length === 0) firstTimestamp = e.timestamp;
|
|
308
|
+
lastTimestamp = e.timestamp;
|
|
309
|
+
parts.push(json);
|
|
310
|
+
bufferBytes += json.length;
|
|
311
|
+
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
312
|
+
},
|
|
313
|
+
maskAllInputs: config.maskAllInputs,
|
|
314
|
+
...config.maskAllText ? { maskTextSelector: "*" } : {},
|
|
315
|
+
checkoutEveryNms: CHECKOUT_EVERY_MS
|
|
316
|
+
});
|
|
317
|
+
const scheduleFlush = () => {
|
|
318
|
+
if (typeof requestIdleCallback === "function") requestIdleCallback(() => void flush(), { timeout: 2e3 });
|
|
319
|
+
else flush();
|
|
320
|
+
};
|
|
321
|
+
const flushTimer = setInterval(scheduleFlush, FLUSH_INTERVAL_MS);
|
|
322
|
+
return {
|
|
323
|
+
stop: () => {
|
|
324
|
+
clearInterval(flushTimer);
|
|
325
|
+
stop?.();
|
|
326
|
+
},
|
|
327
|
+
flush,
|
|
328
|
+
getClickCount: () => clickCount
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region ../browser-session/src/replay-session.ts
|
|
333
|
+
/**
|
|
334
|
+
* Start recording the current browser session. Publishes the session sink,
|
|
335
|
+
* posts an `active` metadata row, and installs visibility handlers:
|
|
336
|
+
* hidden → flush + `ended` row (with observed trace ids) + stop capture;
|
|
337
|
+
* visible → re-resolve the session (rotating if idle-expired), republish the
|
|
338
|
+
* sink, restart capture, post a fresh `active` row. Metadata versions are
|
|
339
|
+
* monotonic per session, so the latest row always wins on the backend.
|
|
340
|
+
*
|
|
341
|
+
* Returns undefined outside a browser. Sampling is the caller's decision.
|
|
342
|
+
*/
|
|
343
|
+
function startReplaySession(options) {
|
|
344
|
+
if (typeof window === "undefined") return void 0;
|
|
345
|
+
const engineConfig = {
|
|
346
|
+
endpoint: options.endpoint.replace(/\/$/, ""),
|
|
347
|
+
ingestKey: options.ingestKey,
|
|
348
|
+
maskAllInputs: options.maskAllInputs,
|
|
349
|
+
maskAllText: options.maskAllText
|
|
350
|
+
};
|
|
351
|
+
let recorder;
|
|
352
|
+
let events;
|
|
353
|
+
let publishedSessionId;
|
|
354
|
+
const publish = (sessionId) => {
|
|
355
|
+
if (publishedSessionId === sessionId) return;
|
|
356
|
+
publishedSessionId = sessionId;
|
|
357
|
+
publishSessionSink(sessionId);
|
|
358
|
+
};
|
|
359
|
+
return startSessionLifecycle({
|
|
360
|
+
...options,
|
|
361
|
+
getTraceIds: getObservedTraceIds
|
|
362
|
+
}, {
|
|
363
|
+
recorded: true,
|
|
364
|
+
post: (row, keepalive) => {
|
|
365
|
+
postSessionMeta(engineConfig, row, keepalive);
|
|
366
|
+
},
|
|
367
|
+
clicksSinceStart: () => recorder?.getClickCount() ?? 0,
|
|
368
|
+
onStart: (record) => {
|
|
369
|
+
publish(record.id);
|
|
370
|
+
recorder = startRecording(engineConfig, record.id);
|
|
371
|
+
events = startEventCapture(engineConfig, record.id);
|
|
372
|
+
},
|
|
373
|
+
onSuspend: ({ flush, keepalive }) => {
|
|
374
|
+
const stoppingRecorder = recorder;
|
|
375
|
+
const stoppingEvents = events;
|
|
376
|
+
recorder = void 0;
|
|
377
|
+
events = void 0;
|
|
378
|
+
const flushed = flush ? Promise.all([stoppingRecorder?.flush(keepalive), stoppingEvents?.flush(keepalive)]).then(() => {}) : void 0;
|
|
379
|
+
stoppingRecorder?.stop();
|
|
380
|
+
stoppingEvents?.stop();
|
|
381
|
+
return flushed;
|
|
382
|
+
},
|
|
383
|
+
onSessionChange: (sessionId) => {
|
|
384
|
+
publish(sessionId);
|
|
385
|
+
options.onSessionChange?.(sessionId);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
//#endregion
|
|
390
|
+
export { startReplaySession };
|