@maple-dev/browser 0.3.0 → 0.8.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,327 @@
1
+ import { S as activeTraceId, _ as gzip, f as markActivity, n as getObservedTraceIds, o as startSessionLifecycle, p as nextChunkSeq, r as publishSessionSink, u as startEventSink, v as postSessionBlob, x as safeEmit, y as postSessionMeta } from "./sink-D9w1kg0Q.mjs";
2
+ import { record } from "rrweb";
3
+ //#region ../browser-session/src/replay/capture/console.ts
4
+ const LEVELS = [
5
+ "log",
6
+ "info",
7
+ "warn",
8
+ "error",
9
+ "debug"
10
+ ];
11
+ const MAX_MESSAGE = 2e3;
12
+ /**
13
+ * Capture `console.*` calls as session events. Wraps each method, emits a
14
+ * distilled record, then forwards to the original so the host app's console
15
+ * behaves normally. Never throws into the call site.
16
+ */
17
+ function installConsoleCapture(emit) {
18
+ const original = {};
19
+ for (const level of LEVELS) {
20
+ const orig = console[level];
21
+ original[level] = orig;
22
+ console[level] = (...args) => {
23
+ safeEmit(emit, {
24
+ type: "console",
25
+ level,
26
+ message: formatArgs(args)
27
+ });
28
+ orig.apply(console, args);
29
+ };
30
+ }
31
+ return () => {
32
+ for (const level of LEVELS) {
33
+ const orig = original[level];
34
+ if (orig) console[level] = orig;
35
+ }
36
+ };
37
+ }
38
+ function formatArgs(args) {
39
+ const text = args.map((a) => {
40
+ if (typeof a === "string") return a;
41
+ if (a instanceof Error) return `${a.name}: ${a.message}`;
42
+ try {
43
+ return JSON.stringify(a);
44
+ } catch {
45
+ return String(a);
46
+ }
47
+ }).join(" ");
48
+ return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
49
+ }
50
+ //#endregion
51
+ //#region ../browser-session/src/replay/capture/network.ts
52
+ /**
53
+ * Capture fetch + XHR requests as session events, tagged with the active trace
54
+ * id so each request links to its backend trace. `ignoreUrl` skips Maple's own
55
+ * ingest endpoints (otherwise capturing the session-events POST would loop).
56
+ */
57
+ function installNetworkCapture(emit, ignoreUrl) {
58
+ const origFetch = typeof window !== "undefined" ? window.fetch : void 0;
59
+ if (origFetch) window.fetch = async (input, init) => {
60
+ const url = requestUrl(input);
61
+ const method = requestMethod(input, init);
62
+ const traceId = activeTraceId();
63
+ const start = performance.now();
64
+ try {
65
+ const res = await origFetch(input, init);
66
+ record(url, method, res.status, start, traceId);
67
+ return res;
68
+ } catch (error) {
69
+ record(url, method, 0, start, traceId, String(error));
70
+ throw error;
71
+ }
72
+ };
73
+ const record = (url, method, status, start, traceId, error) => {
74
+ if (ignoreUrl(url)) return;
75
+ safeEmit(emit, {
76
+ type: "network",
77
+ net: {
78
+ method,
79
+ url,
80
+ status,
81
+ durationMs: Math.round(performance.now() - start)
82
+ },
83
+ traceId,
84
+ ...error ? { attrs: { error } } : void 0
85
+ });
86
+ };
87
+ const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
88
+ const origOpen = XHR?.prototype.open;
89
+ const origSend = XHR?.prototype.send;
90
+ if (XHR && origOpen && origSend) {
91
+ XHR.prototype.open = function(method, url, ...rest) {
92
+ this.__mapleMethod = String(method).toUpperCase();
93
+ this.__mapleUrl = typeof url === "string" ? url : url.href;
94
+ return origOpen.apply(this, [
95
+ method,
96
+ url,
97
+ ...rest
98
+ ]);
99
+ };
100
+ XHR.prototype.send = function(...args) {
101
+ const meta = this;
102
+ const start = performance.now();
103
+ const traceId = activeTraceId();
104
+ this.addEventListener("loadend", () => {
105
+ record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
106
+ });
107
+ return origSend.apply(this, args);
108
+ };
109
+ }
110
+ return () => {
111
+ if (origFetch) window.fetch = origFetch;
112
+ if (XHR && origOpen) XHR.prototype.open = origOpen;
113
+ if (XHR && origSend) XHR.prototype.send = origSend;
114
+ };
115
+ }
116
+ function requestUrl(input) {
117
+ if (typeof input === "string") return input;
118
+ if (input instanceof URL) return input.href;
119
+ return input.url;
120
+ }
121
+ function requestMethod(input, init) {
122
+ return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
123
+ }
124
+ //#endregion
125
+ //#region ../browser-session/src/replay/events.ts
126
+ /**
127
+ * Install the capture modules that turn console output and network requests
128
+ * into distilled session events.
129
+ *
130
+ * Navigation, errors and interactions are deliberately absent: the sink
131
+ * installs those itself (see `startBaselineCapture`), because page views, error
132
+ * counts and click counts have to be counted even when replay is unsampled —
133
+ * and a second listener here would double-count every one of them.
134
+ */
135
+ function startEventCapture(config, sessionId) {
136
+ const sink = startEventSink(config, sessionId);
137
+ const emit = sink.emit;
138
+ const uninstall = [installConsoleCapture(emit), installNetworkCapture(emit, sink.ignoreUrl)];
139
+ return {
140
+ stop: () => {
141
+ for (const off of uninstall) off();
142
+ },
143
+ flush: sink.flush
144
+ };
145
+ }
146
+ //#endregion
147
+ //#region ../browser-session/src/replay/record.ts
148
+ const FULL_SNAPSHOT = 2;
149
+ const INCREMENTAL = 3;
150
+ const SOURCE_MOUSE_INTERACTION = 2;
151
+ const MOUSE_CLICK = 2;
152
+ const FLUSH_INTERVAL_MS = 5e3;
153
+ const FLUSH_BYTES = 102400;
154
+ const CHECKOUT_EVERY_MS = 3e5;
155
+ const MAX_BUFFER_BYTES = 4194304;
156
+ function warnExhausted(sessionId) {
157
+ console.warn(`[maple] session replay ${sessionId} reached its maximum recorded size; recording stopped for this session (metadata and events continue)`);
158
+ }
159
+ let lastDropWarnAt = 0;
160
+ function warnBufferDropped(bytes) {
161
+ const now = Date.now();
162
+ if (now - lastDropWarnAt < 3e4) return;
163
+ lastDropWarnAt = now;
164
+ console.warn(`[maple] session replay buffer exceeded ${MAX_BUFFER_BYTES} bytes (dropping ${bytes} buffered bytes; recording continues from the next full snapshot)`);
165
+ }
166
+ function startRecording(config, sessionId) {
167
+ let parts = [];
168
+ let bufferBytes = 0;
169
+ let bufferHasCheckpoint = false;
170
+ let firstTimestamp = 0;
171
+ let lastTimestamp = 0;
172
+ let droppedChunk = false;
173
+ let clickCount = 0;
174
+ let stopped = false;
175
+ let exhausted = false;
176
+ const resetBuffer = () => {
177
+ parts = [];
178
+ bufferBytes = 0;
179
+ bufferHasCheckpoint = false;
180
+ firstTimestamp = 0;
181
+ lastTimestamp = 0;
182
+ };
183
+ const flush = async (keepalive = false) => {
184
+ if (stopped || exhausted || parts.length === 0) return;
185
+ const body = `[${parts.join(",")}]`;
186
+ const isCheckpoint = bufferHasCheckpoint;
187
+ const eventCount = parts.length;
188
+ const durationMs = Math.max(0, lastTimestamp - firstTimestamp);
189
+ const seq = nextChunkSeq();
190
+ resetBuffer();
191
+ const gzipped = await gzip(new TextEncoder().encode(body));
192
+ if (await postSessionBlob(config, {
193
+ sessionId,
194
+ chunkSeq: seq,
195
+ isCheckpoint,
196
+ eventCount,
197
+ durationMs
198
+ }, gzipped, keepalive) === "exhausted" && !exhausted) {
199
+ exhausted = true;
200
+ warnExhausted(sessionId);
201
+ haltCapture();
202
+ }
203
+ };
204
+ let haltCapture = () => {};
205
+ const stop = record({
206
+ emit: (event, isCheckpoint) => {
207
+ const active = markActivity();
208
+ if (active && active.id !== sessionId) return;
209
+ const e = event;
210
+ const isFullSnapshot = isCheckpoint === true || e.type === FULL_SNAPSHOT;
211
+ if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
212
+ let json;
213
+ try {
214
+ json = JSON.stringify(e);
215
+ } catch {
216
+ return;
217
+ }
218
+ if (droppedChunk && !isFullSnapshot) return;
219
+ droppedChunk = false;
220
+ if (bufferBytes + json.length > MAX_BUFFER_BYTES) {
221
+ warnBufferDropped(bufferBytes + json.length);
222
+ resetBuffer();
223
+ droppedChunk = true;
224
+ return;
225
+ }
226
+ if (isFullSnapshot) bufferHasCheckpoint = true;
227
+ if (parts.length === 0) firstTimestamp = e.timestamp;
228
+ lastTimestamp = e.timestamp;
229
+ parts.push(json);
230
+ bufferBytes += json.length;
231
+ if (bufferBytes >= FLUSH_BYTES) flush();
232
+ },
233
+ maskAllInputs: config.maskAllInputs,
234
+ ...config.maskAllText ? { maskTextSelector: "*" } : void 0,
235
+ checkoutEveryNms: CHECKOUT_EVERY_MS
236
+ });
237
+ let idleHandle;
238
+ const scheduleFlush = () => {
239
+ if (typeof requestIdleCallback === "function") idleHandle = requestIdleCallback(() => {
240
+ idleHandle = void 0;
241
+ flush();
242
+ }, { timeout: 2e3 });
243
+ else flush();
244
+ };
245
+ const flushTimer = setInterval(scheduleFlush, FLUSH_INTERVAL_MS);
246
+ let halted = false;
247
+ haltCapture = () => {
248
+ if (halted) return;
249
+ halted = true;
250
+ clearInterval(flushTimer);
251
+ if (idleHandle !== void 0 && typeof cancelIdleCallback === "function") {
252
+ cancelIdleCallback(idleHandle);
253
+ idleHandle = void 0;
254
+ }
255
+ resetBuffer();
256
+ stop?.();
257
+ };
258
+ return {
259
+ stop: () => {
260
+ stopped = true;
261
+ haltCapture();
262
+ },
263
+ flush,
264
+ getClickCount: () => clickCount
265
+ };
266
+ }
267
+ //#endregion
268
+ //#region ../browser-session/src/session/replay-session.ts
269
+ /**
270
+ * Start recording the current browser session. Publishes the session sink,
271
+ * posts an `active` metadata row, and installs visibility handlers:
272
+ * hidden → flush + `ended` row (with observed trace ids) + stop capture;
273
+ * visible → re-resolve the session (rotating if idle-expired), republish the
274
+ * sink, restart capture, post a fresh `active` row. Metadata versions are
275
+ * monotonic per session, so the latest row always wins on the backend.
276
+ *
277
+ * Returns undefined outside a browser. Sampling is the caller's decision.
278
+ */
279
+ function startReplaySession(options) {
280
+ if (typeof window === "undefined") return void 0;
281
+ const engineConfig = {
282
+ endpoint: options.endpoint.replace(/\/$/, ""),
283
+ ingestKey: options.ingestKey,
284
+ sdk: options.sdk,
285
+ maskAllInputs: options.maskAllInputs,
286
+ maskAllText: options.maskAllText
287
+ };
288
+ let recorder;
289
+ let events;
290
+ let publishedSessionId;
291
+ const publish = (sessionId) => {
292
+ if (publishedSessionId === sessionId) return;
293
+ publishedSessionId = sessionId;
294
+ publishSessionSink(sessionId);
295
+ };
296
+ return startSessionLifecycle({
297
+ ...options,
298
+ getTraceIds: getObservedTraceIds
299
+ }, {
300
+ recorded: true,
301
+ post: (row, keepalive) => {
302
+ postSessionMeta(engineConfig, row, keepalive);
303
+ },
304
+ clicksSinceStart: () => recorder?.getClickCount() ?? 0,
305
+ onStart: (record) => {
306
+ publish(record.id);
307
+ recorder = startRecording(engineConfig, record.id);
308
+ events = startEventCapture(engineConfig, record.id);
309
+ },
310
+ onSuspend: ({ flush, keepalive }) => {
311
+ const stoppingRecorder = recorder;
312
+ const stoppingEvents = events;
313
+ recorder = void 0;
314
+ events = void 0;
315
+ const flushed = flush ? Promise.all([stoppingRecorder?.flush(keepalive), stoppingEvents?.flush(keepalive)]).then(() => {}) : void 0;
316
+ stoppingRecorder?.stop();
317
+ stoppingEvents?.stop();
318
+ return flushed;
319
+ },
320
+ onSessionChange: (sessionId) => {
321
+ publish(sessionId);
322
+ options.onSessionChange?.(sessionId);
323
+ }
324
+ });
325
+ }
326
+ //#endregion
327
+ export { startReplaySession };