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