@maple-dev/browser 0.4.0 → 0.9.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.
@@ -1,13 +1,5 @@
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";
1
+ import { C as warnDropped, E as activeTraceId, M as scrubUrl, O as withStartedTraceId, T as safeEmit, b as postSessionBlob, m as nextChunkSeq, n as getObservedTraceIds, o as startSessionLifecycle, p as markActivity, r as publishSessionSink, u as startEventSink, w as BLOCK_SELECTOR, x as postSessionMeta, y as gzip } from "./sink-DHPiMgU0.mjs";
2
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
3
  //#region ../browser-session/src/replay/capture/console.ts
12
4
  const LEVELS = [
13
5
  "log",
@@ -17,6 +9,8 @@ const LEVELS = [
17
9
  "debug"
18
10
  ];
19
11
  const MAX_MESSAGE = 2e3;
12
+ /** No array logged into a 2,000-character message needs more elements than this. */
13
+ const MAX_ARRAY_ELEMENTS = 100;
20
14
  /**
21
15
  * Capture `console.*` calls as session events. Wraps each method, emits a
22
16
  * distilled record, then forwards to the original so the host app's console
@@ -48,90 +42,28 @@ function formatArgs(args) {
48
42
  if (typeof a === "string") return a;
49
43
  if (a instanceof Error) return `${a.name}: ${a.message}`;
50
44
  try {
51
- return JSON.stringify(a);
45
+ return boundedStringify(a, MAX_MESSAGE) ?? String(a);
52
46
  } catch {
53
47
  return String(a);
54
48
  }
55
49
  }).join(" ");
56
50
  return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
57
51
  }
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
52
  /**
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.
53
+ * `JSON.stringify` that stops descending once roughly `budget` characters have
54
+ * been produced. The message is cut to `MAX_MESSAGE` anyway, and serializing a
55
+ * whole store object on every `console.log` first was a main-thread cost paid
56
+ * on the host app's hot path. A replacer returning `undefined` skips a value
57
+ * without visiting its children, so the walk is bounded by the budget.
101
58
  */
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;
59
+ function boundedStringify(value, budget) {
60
+ let used = 0;
61
+ return JSON.stringify(value, (key, nested) => {
62
+ if (used > budget) return void 0;
63
+ used += key.length + (typeof nested === "string" ? nested.length : 4);
64
+ if (Array.isArray(nested) && nested.length > MAX_ARRAY_ELEMENTS) return nested.slice(0, MAX_ARRAY_ELEMENTS);
65
+ return nested;
66
+ });
135
67
  }
136
68
  //#endregion
137
69
  //#region ../browser-session/src/replay/capture/network.ts
@@ -145,10 +77,13 @@ function installNetworkCapture(emit, ignoreUrl) {
145
77
  if (origFetch) window.fetch = async (input, init) => {
146
78
  const url = requestUrl(input);
147
79
  const method = requestMethod(input, init);
148
- const traceId = activeTraceId();
80
+ const ambientTraceId = activeTraceId();
149
81
  const start = performance.now();
82
+ let traceId = ambientTraceId;
150
83
  try {
151
- const res = await origFetch(input, init);
84
+ const call = withStartedTraceId(() => origFetch(input, init));
85
+ traceId = call.traceId ?? ambientTraceId;
86
+ const res = await call.result;
152
87
  record(url, method, res.status, start, traceId);
153
88
  return res;
154
89
  } catch (error) {
@@ -167,7 +102,7 @@ function installNetworkCapture(emit, ignoreUrl) {
167
102
  durationMs: Math.round(performance.now() - start)
168
103
  },
169
104
  traceId,
170
- ...error ? { attrs: { error } } : {}
105
+ ...error ? { attrs: { error } } : void 0
171
106
  });
172
107
  };
173
108
  const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
@@ -186,11 +121,13 @@ function installNetworkCapture(emit, ignoreUrl) {
186
121
  XHR.prototype.send = function(...args) {
187
122
  const meta = this;
188
123
  const start = performance.now();
189
- const traceId = activeTraceId();
124
+ let traceId = activeTraceId();
190
125
  this.addEventListener("loadend", () => {
191
126
  record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
192
127
  });
193
- return origSend.apply(this, args);
128
+ const call = withStartedTraceId(() => origSend.apply(this, args));
129
+ traceId = call.traceId ?? traceId;
130
+ return call.result;
194
131
  };
195
132
  }
196
133
  return () => {
@@ -210,22 +147,18 @@ function requestMethod(input, init) {
210
147
  //#endregion
211
148
  //#region ../browser-session/src/replay/events.ts
212
149
  /**
213
- * Install the capture modules that turn browser activity (console, network,
214
- * errors, interactions) into distilled session events.
150
+ * Install the capture modules that turn console output and network requests
151
+ * into distilled session events.
215
152
  *
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.
153
+ * Navigation, errors and interactions are deliberately absent: the sink
154
+ * installs those itself (see `startBaselineCapture`), because page views, error
155
+ * counts and click counts have to be counted even when replay is unsampled —
156
+ * and a second listener here would double-count every one of them.
219
157
  */
220
158
  function startEventCapture(config, sessionId) {
221
159
  const sink = startEventSink(config, sessionId);
222
160
  const emit = sink.emit;
223
- const uninstall = [
224
- installInteractionCapture(emit, config.maskAllText),
225
- installConsoleCapture(emit),
226
- installNetworkCapture(emit, sink.ignoreUrl),
227
- installErrorCapture(emit)
228
- ];
161
+ const uninstall = [installConsoleCapture(emit), installNetworkCapture(emit, sink.ignoreUrl)];
229
162
  return {
230
163
  stop: () => {
231
164
  for (const off of uninstall) off();
@@ -236,6 +169,7 @@ function startEventCapture(config, sessionId) {
236
169
  //#endregion
237
170
  //#region ../browser-session/src/replay/record.ts
238
171
  const FULL_SNAPSHOT = 2;
172
+ const META = 4;
239
173
  const INCREMENTAL = 3;
240
174
  const SOURCE_MOUSE_INTERACTION = 2;
241
175
  const MOUSE_CLICK = 2;
@@ -243,6 +177,9 @@ const FLUSH_INTERVAL_MS = 5e3;
243
177
  const FLUSH_BYTES = 102400;
244
178
  const CHECKOUT_EVERY_MS = 3e5;
245
179
  const MAX_BUFFER_BYTES = 4194304;
180
+ function warnExhausted(sessionId) {
181
+ console.warn(`[maple] session replay ${sessionId} reached its maximum recorded size; recording stopped for this session (metadata and events continue)`);
182
+ }
246
183
  let lastDropWarnAt = 0;
247
184
  function warnBufferDropped(bytes) {
248
185
  const now = Date.now();
@@ -258,6 +195,8 @@ function startRecording(config, sessionId) {
258
195
  let lastTimestamp = 0;
259
196
  let droppedChunk = false;
260
197
  let clickCount = 0;
198
+ let stopped = false;
199
+ let exhausted = false;
261
200
  const resetBuffer = () => {
262
201
  parts = [];
263
202
  bufferBytes = 0;
@@ -266,27 +205,39 @@ function startRecording(config, sessionId) {
266
205
  lastTimestamp = 0;
267
206
  };
268
207
  const flush = async (keepalive = false) => {
269
- if (parts.length === 0) return;
208
+ if (stopped || exhausted || parts.length === 0) return;
270
209
  const body = `[${parts.join(",")}]`;
271
210
  const isCheckpoint = bufferHasCheckpoint;
272
211
  const eventCount = parts.length;
273
212
  const durationMs = Math.max(0, lastTimestamp - firstTimestamp);
274
213
  const seq = nextChunkSeq();
275
214
  resetBuffer();
276
- const gzipped = await gzip(new TextEncoder().encode(body));
277
- await postSessionBlob(config, {
215
+ let gzipped;
216
+ try {
217
+ gzipped = await gzip(new TextEncoder().encode(body));
218
+ } catch (error) {
219
+ warnDropped("chunk compression", error);
220
+ return;
221
+ }
222
+ if (await postSessionBlob(config, {
278
223
  sessionId,
279
224
  chunkSeq: seq,
280
225
  isCheckpoint,
281
226
  eventCount,
282
227
  durationMs
283
- }, gzipped, keepalive);
228
+ }, gzipped, keepalive) === "exhausted" && !exhausted) {
229
+ exhausted = true;
230
+ warnExhausted(sessionId);
231
+ haltCapture();
232
+ }
284
233
  };
234
+ let haltCapture = () => {};
285
235
  const stop = record({
286
236
  emit: (event, isCheckpoint) => {
287
237
  const active = markActivity();
288
238
  if (active && active.id !== sessionId) return;
289
239
  const e = event;
240
+ if (e.type === META && e.data && typeof e.data.href === "string") e.data.href = scrubUrl(e.data.href);
290
241
  const isFullSnapshot = isCheckpoint === true || e.type === FULL_SNAPSHOT;
291
242
  if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
292
243
  let json;
@@ -311,25 +262,42 @@ function startRecording(config, sessionId) {
311
262
  if (bufferBytes >= FLUSH_BYTES) flush();
312
263
  },
313
264
  maskAllInputs: config.maskAllInputs,
314
- ...config.maskAllText ? { maskTextSelector: "*" } : {},
265
+ blockSelector: BLOCK_SELECTOR,
266
+ ...config.maskAllText ? { maskTextSelector: "*" } : void 0,
315
267
  checkoutEveryNms: CHECKOUT_EVERY_MS
316
268
  });
269
+ let idleHandle;
317
270
  const scheduleFlush = () => {
318
- if (typeof requestIdleCallback === "function") requestIdleCallback(() => void flush(), { timeout: 2e3 });
271
+ if (typeof requestIdleCallback === "function") idleHandle = requestIdleCallback(() => {
272
+ idleHandle = void 0;
273
+ flush();
274
+ }, { timeout: 2e3 });
319
275
  else flush();
320
276
  };
321
277
  const flushTimer = setInterval(scheduleFlush, FLUSH_INTERVAL_MS);
278
+ let halted = false;
279
+ haltCapture = () => {
280
+ if (halted) return;
281
+ halted = true;
282
+ clearInterval(flushTimer);
283
+ if (idleHandle !== void 0 && typeof cancelIdleCallback === "function") {
284
+ cancelIdleCallback(idleHandle);
285
+ idleHandle = void 0;
286
+ }
287
+ resetBuffer();
288
+ stop?.();
289
+ };
322
290
  return {
323
291
  stop: () => {
324
- clearInterval(flushTimer);
325
- stop?.();
292
+ stopped = true;
293
+ haltCapture();
326
294
  },
327
295
  flush,
328
296
  getClickCount: () => clickCount
329
297
  };
330
298
  }
331
299
  //#endregion
332
- //#region ../browser-session/src/replay-session.ts
300
+ //#region ../browser-session/src/session/replay-session.ts
333
301
  /**
334
302
  * Start recording the current browser session. Publishes the session sink,
335
303
  * posts an `active` metadata row, and installs visibility handlers:
@@ -345,8 +313,10 @@ function startReplaySession(options) {
345
313
  const engineConfig = {
346
314
  endpoint: options.endpoint.replace(/\/$/, ""),
347
315
  ingestKey: options.ingestKey,
316
+ sdk: options.sdk,
348
317
  maskAllInputs: options.maskAllInputs,
349
- maskAllText: options.maskAllText
318
+ maskAllText: options.maskAllText,
319
+ getIdentity: options.getIdentity
350
320
  };
351
321
  let recorder;
352
322
  let events;