@maple-dev/browser 0.4.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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Maple Tech Labs
3
+ Copyright (c) 2026 Makisuo, Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -33,10 +33,28 @@ That single call:
33
33
  gzipping them with the native `CompressionStream`, and uploading to
34
34
  `POST /v1/sessionReplays/blob`. rrweb ships in a lazy code-split chunk loaded
35
35
  only once a session is sampled in, so a `sampleRate` below 1 costs the
36
- unsampled visitors nothing beyond the ~8 kB gzipped base SDK;
36
+ unsampled visitors nothing beyond the base SDK (see [Bundle size](#bundle-size));
37
37
  - writes session metadata at start (`active`) and on page hide (`ended`),
38
38
  including the trace ids observed during the session.
39
39
 
40
+ ## Bundle size
41
+
42
+ Bundled, minified and gzipped, as your bundler would ship it:
43
+
44
+ | | gzipped | what it is |
45
+ | ---------------- | ------- | --------------------------------------------------------- |
46
+ | **eager** | ~32 kB | every page load, before any sampling decision |
47
+ | ↳ our code alone | ~3.5 kB | the marginal cost if your app already ships OpenTelemetry |
48
+ | **lazy** | ~61 kB | rrweb — downloaded only by sessions sampled into replay |
49
+
50
+ The eager figure is ~90% OpenTelemetry. If your app already uses the OTel web
51
+ SDK, your bundler should dedupe it and you pay closer to the second row; if it
52
+ doesn't dedupe, you will ship two copies, so pin matching versions.
53
+
54
+ Run `bun run size` in this package for the current numbers. It fails past a
55
+ budget, so a regression has to be argued for in review rather than discovered
56
+ in production.
57
+
40
58
  ## Identifying users
41
59
 
42
60
  Pass `userId` to `init()` when you already know the signed-in user, or call
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region ../browser-session/src/identity.d.ts
1
+ //#region ../browser-session/src/identity/identity.d.ts
2
2
  /**
3
3
  * Who the current visitor is.
4
4
  *
@@ -26,7 +26,7 @@ interface MapleIdentity {
26
26
  */
27
27
  type IdentifyInput = string | null | undefined | MapleIdentity;
28
28
  //#endregion
29
- //#region ../browser-session/src/track.d.ts
29
+ //#region ../browser-session/src/events/track.d.ts
30
30
  /** Properties a host app may attach to a custom event. */
31
31
  type TrackProps = Readonly<Record<string, unknown>>;
32
32
  //#endregion
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { S as setVisitorTracking, a as recordTraceId, b as postSessionMetaRow, c as clearPendingEvents, d as setActiveTraceIdProvider, f as startEventSink, g as rotateSession, i as readSessionSink, l as getActiveSink, n as getObservedTraceIds, o as startSessionLifecycle, p as getSession, r as publishSessionSink, t as clearSessionSink, u as queuePending, x as configureVisitorCookie } from "./sink-DHTfvFgl.mjs";
1
+ import { C as setActiveTraceIdProvider, T as setVisitorTracking, a as recordTraceId, b as sdkHint, c as getActiveSink, d as getSession, g as SDK_HINT_HEADER, h as postSessionMetaRow, i as readSessionSink, l as queuePending, m as rotateSession, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as startEventSink, w as configureVisitorCookie } from "./sink-D9w1kg0Q.mjs";
2
2
  import { trace } from "@opentelemetry/api";
3
3
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
4
4
  import { registerInstrumentations } from "@opentelemetry/instrumentation";
@@ -7,7 +7,7 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
7
7
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
8
8
  import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
9
9
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
10
- //#region ../browser-session/src/consent.ts
10
+ //#region ../browser-session/src/identity/consent.ts
11
11
  /**
12
12
  * Consent gating for session capture.
13
13
  *
@@ -27,7 +27,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic
27
27
  */
28
28
  /**
29
29
  * Consent lives on `globalThis`, for the same reason the event sink does
30
- * (`events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
30
+ * (`events/events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
31
31
  * Effect SDK gets **two copies** of this module, and per-copy state would mean
32
32
  * `setConsent(true)` on one never releases the other copy's exporters — or, the
33
33
  * other way round, one copy capturing while the other believes consent is
@@ -120,7 +120,7 @@ function mayPersistIdentifier() {
120
120
  return true;
121
121
  }
122
122
  //#endregion
123
- //#region ../browser-session/src/identity.ts
123
+ //#region ../browser-session/src/identity/identity.ts
124
124
  /**
125
125
  * Trait caps. The warehouse column is `Map(String, String)` — plain keys
126
126
  * precisely because they are arbitrary — so the cost of a unique key per user
@@ -189,7 +189,7 @@ function normalizeIdentity(input) {
189
189
  return resolved.id || resolved.email || resolved.username || resolved.groupId || resolved.groupName || Object.keys(resolved.traits).length > 0 ? resolved : void 0;
190
190
  }
191
191
  //#endregion
192
- //#region ../browser-session/src/metadata-session.ts
192
+ //#region ../browser-session/src/session/metadata-session.ts
193
193
  /**
194
194
  * Metadata-only session lifecycle used whenever rrweb is disabled or unsampled.
195
195
  * It is the shared lifecycle driver with no capture of its own: the distilled
@@ -200,7 +200,7 @@ function startMetadataSession(options) {
200
200
  return startSessionLifecycle(options, {
201
201
  recorded: false,
202
202
  post: (row, keepalive) => {
203
- postSessionMetaRow(options.endpoint, options.ingestKey, row, keepalive);
203
+ postSessionMetaRow(options, row, keepalive);
204
204
  },
205
205
  onSuspend: ({ flush, keepalive }) => {
206
206
  if (flush && keepalive) getActiveSink()?.flush(true);
@@ -209,7 +209,7 @@ function startMetadataSession(options) {
209
209
  });
210
210
  }
211
211
  //#endregion
212
- //#region ../browser-session/src/track.ts
212
+ //#region ../browser-session/src/events/track.ts
213
213
  /**
214
214
  * Caps mirroring what the ingest gateway enforces. Applying them here too means
215
215
  * an over-sized event is trimmed before it costs bandwidth, and the developer
@@ -324,6 +324,11 @@ function resolveConfig(config) {
324
324
  };
325
325
  }
326
326
  //#endregion
327
+ //#region src/version.ts
328
+ const SDK_VERSION = "0.8.0";
329
+ /** The `x-maple-sdk` value this build sends. */
330
+ const SDK_NAME = "maple-browser";
331
+ //#endregion
327
332
  //#region src/tracing.ts
328
333
  /**
329
334
  * Captures every span's trace id into the session sink. Lightweight — runs
@@ -381,6 +386,15 @@ var ConsentSpanExporter = class {
381
386
  }
382
387
  };
383
388
  /**
389
+ * How long a span may sit in the batch queue before export.
390
+ *
391
+ * Shorter than OTel's 5s default: in a browser the queue is only as durable as
392
+ * the tab, and the unload flush below is a best-effort catch rather than a
393
+ * guarantee (a crashed or killed tab fires neither event). 2s trades a few more
394
+ * requests for a materially smaller loss window.
395
+ */
396
+ const EXPORT_INTERVAL_MS = 2e3;
397
+ /**
384
398
  * Set up browser OTel tracing exporting to Maple's ingest. When
385
399
  * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
386
400
  * their trace ids feed the session. Disable it when an external tracer (e.g.
@@ -410,15 +424,33 @@ function setupTracing(config) {
410
424
  }
411
425
  const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
412
426
  url: `${config.endpoint}/v1/traces`,
413
- headers: { Authorization: `Bearer ${config.ingestKey}` }
427
+ headers: {
428
+ Authorization: `Bearer ${config.ingestKey}`,
429
+ [SDK_HINT_HEADER]: sdkHint(SDK_NAME, SDK_VERSION)
430
+ }
414
431
  }));
415
432
  const provider = new WebTracerProvider({
416
433
  resource: resourceFromAttributes(attributes),
417
- spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter)]
434
+ spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
418
435
  });
419
436
  provider.register();
437
+ const onExit = () => {
438
+ provider.forceFlush().catch(() => {});
439
+ };
440
+ const onVisibilityChange = () => {
441
+ if (document.visibilityState === "hidden") onExit();
442
+ };
443
+ const canListen = typeof document !== "undefined" && typeof document.addEventListener === "function";
444
+ if (canListen) {
445
+ document.addEventListener("visibilitychange", onVisibilityChange);
446
+ window.addEventListener("pagehide", onExit);
447
+ }
420
448
  const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
421
449
  return async () => {
450
+ if (canListen) {
451
+ document.removeEventListener("visibilitychange", onVisibilityChange);
452
+ window.removeEventListener("pagehide", onExit);
453
+ }
422
454
  unregisterInstrumentations?.();
423
455
  await provider.shutdown();
424
456
  };
@@ -428,6 +460,8 @@ function escapeRegExp(value) {
428
460
  }
429
461
  //#endregion
430
462
  //#region src/init.ts
463
+ /** `x-maple-sdk` value for every request this build makes to ingest. */
464
+ const SDK_HINT = sdkHint(SDK_NAME, SDK_VERSION);
431
465
  let active;
432
466
  let activeConfig;
433
467
  /**
@@ -461,6 +495,7 @@ function init(rawConfig) {
461
495
  const sink = startEventSink({
462
496
  endpoint: config.endpoint,
463
497
  ingestKey: config.ingestKey,
498
+ sdk: SDK_HINT,
464
499
  maskAllInputs: config.maskAllInputs,
465
500
  maskAllText: config.maskAllText
466
501
  }, session.id);
@@ -468,6 +503,7 @@ function init(rawConfig) {
468
503
  const shared = {
469
504
  endpoint: config.endpoint,
470
505
  ingestKey: config.ingestKey,
506
+ sdk: SDK_HINT,
471
507
  serviceName: config.serviceName,
472
508
  environment: config.environment,
473
509
  serviceVersion: config.serviceVersion,
@@ -494,7 +530,7 @@ function init(rawConfig) {
494
530
  runtime = next;
495
531
  const ownGeneration = ++generation;
496
532
  const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
497
- next.replayPending = import("./replay-session-B_ACRogX.mjs").then(({ startReplaySession }) => {
533
+ next.replayPending = import("./replay-session-BYZfi3iZ.mjs").then(({ startReplaySession }) => {
498
534
  if (stale()) return;
499
535
  next.replay = startReplaySession({
500
536
  ...shared,
@@ -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 { 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
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",
@@ -56,84 +48,6 @@ function formatArgs(args) {
56
48
  return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
57
49
  }
58
50
  //#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
51
  //#region ../browser-session/src/replay/capture/network.ts
138
52
  /**
139
53
  * Capture fetch + XHR requests as session events, tagged with the active trace
@@ -167,7 +81,7 @@ function installNetworkCapture(emit, ignoreUrl) {
167
81
  durationMs: Math.round(performance.now() - start)
168
82
  },
169
83
  traceId,
170
- ...error ? { attrs: { error } } : {}
84
+ ...error ? { attrs: { error } } : void 0
171
85
  });
172
86
  };
173
87
  const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
@@ -210,22 +124,18 @@ function requestMethod(input, init) {
210
124
  //#endregion
211
125
  //#region ../browser-session/src/replay/events.ts
212
126
  /**
213
- * Install the capture modules that turn browser activity (console, network,
214
- * errors, interactions) into distilled session events.
127
+ * Install the capture modules that turn console output and network requests
128
+ * into distilled session events.
215
129
  *
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.
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.
219
134
  */
220
135
  function startEventCapture(config, sessionId) {
221
136
  const sink = startEventSink(config, sessionId);
222
137
  const emit = sink.emit;
223
- const uninstall = [
224
- installInteractionCapture(emit, config.maskAllText),
225
- installConsoleCapture(emit),
226
- installNetworkCapture(emit, sink.ignoreUrl),
227
- installErrorCapture(emit)
228
- ];
138
+ const uninstall = [installConsoleCapture(emit), installNetworkCapture(emit, sink.ignoreUrl)];
229
139
  return {
230
140
  stop: () => {
231
141
  for (const off of uninstall) off();
@@ -243,6 +153,9 @@ const FLUSH_INTERVAL_MS = 5e3;
243
153
  const FLUSH_BYTES = 102400;
244
154
  const CHECKOUT_EVERY_MS = 3e5;
245
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
+ }
246
159
  let lastDropWarnAt = 0;
247
160
  function warnBufferDropped(bytes) {
248
161
  const now = Date.now();
@@ -258,6 +171,8 @@ function startRecording(config, sessionId) {
258
171
  let lastTimestamp = 0;
259
172
  let droppedChunk = false;
260
173
  let clickCount = 0;
174
+ let stopped = false;
175
+ let exhausted = false;
261
176
  const resetBuffer = () => {
262
177
  parts = [];
263
178
  bufferBytes = 0;
@@ -266,7 +181,7 @@ function startRecording(config, sessionId) {
266
181
  lastTimestamp = 0;
267
182
  };
268
183
  const flush = async (keepalive = false) => {
269
- if (parts.length === 0) return;
184
+ if (stopped || exhausted || parts.length === 0) return;
270
185
  const body = `[${parts.join(",")}]`;
271
186
  const isCheckpoint = bufferHasCheckpoint;
272
187
  const eventCount = parts.length;
@@ -274,14 +189,19 @@ function startRecording(config, sessionId) {
274
189
  const seq = nextChunkSeq();
275
190
  resetBuffer();
276
191
  const gzipped = await gzip(new TextEncoder().encode(body));
277
- await postSessionBlob(config, {
192
+ if (await postSessionBlob(config, {
278
193
  sessionId,
279
194
  chunkSeq: seq,
280
195
  isCheckpoint,
281
196
  eventCount,
282
197
  durationMs
283
- }, gzipped, keepalive);
198
+ }, gzipped, keepalive) === "exhausted" && !exhausted) {
199
+ exhausted = true;
200
+ warnExhausted(sessionId);
201
+ haltCapture();
202
+ }
284
203
  };
204
+ let haltCapture = () => {};
285
205
  const stop = record({
286
206
  emit: (event, isCheckpoint) => {
287
207
  const active = markActivity();
@@ -311,25 +231,41 @@ function startRecording(config, sessionId) {
311
231
  if (bufferBytes >= FLUSH_BYTES) flush();
312
232
  },
313
233
  maskAllInputs: config.maskAllInputs,
314
- ...config.maskAllText ? { maskTextSelector: "*" } : {},
234
+ ...config.maskAllText ? { maskTextSelector: "*" } : void 0,
315
235
  checkoutEveryNms: CHECKOUT_EVERY_MS
316
236
  });
237
+ let idleHandle;
317
238
  const scheduleFlush = () => {
318
- if (typeof requestIdleCallback === "function") requestIdleCallback(() => void flush(), { timeout: 2e3 });
239
+ if (typeof requestIdleCallback === "function") idleHandle = requestIdleCallback(() => {
240
+ idleHandle = void 0;
241
+ flush();
242
+ }, { timeout: 2e3 });
319
243
  else flush();
320
244
  };
321
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
+ };
322
258
  return {
323
259
  stop: () => {
324
- clearInterval(flushTimer);
325
- stop?.();
260
+ stopped = true;
261
+ haltCapture();
326
262
  },
327
263
  flush,
328
264
  getClickCount: () => clickCount
329
265
  };
330
266
  }
331
267
  //#endregion
332
- //#region ../browser-session/src/replay-session.ts
268
+ //#region ../browser-session/src/session/replay-session.ts
333
269
  /**
334
270
  * Start recording the current browser session. Publishes the session sink,
335
271
  * posts an `active` metadata row, and installs visibility handlers:
@@ -345,6 +281,7 @@ function startReplaySession(options) {
345
281
  const engineConfig = {
346
282
  endpoint: options.endpoint.replace(/\/$/, ""),
347
283
  ingestKey: options.ingestKey,
284
+ sdk: options.sdk,
348
285
  maskAllInputs: options.maskAllInputs,
349
286
  maskAllText: options.maskAllText
350
287
  };
@@ -1,4 +1,48 @@
1
- //#region ../browser-session/src/visitor.ts
1
+ //#region ../browser-session/src/platform/json.ts
2
+ /**
3
+ * Minimal structural guards for records read back out of browser storage.
4
+ *
5
+ * These replace what was an `effect/Schema` decoder. This package is *bundled*
6
+ * into `@maple-dev/browser`, whose eager chunk every visitor downloads before
7
+ * any sampling decision runs — and pulling Schema in to validate two flat
8
+ * records cost ~30 kB gzipped, most of that SDK's entire page-load budget, for
9
+ * shapes the callers already declare in TypeScript. Schema earns its size where
10
+ * the input is genuinely unknown and the errors need to be legible; here the
11
+ * only question is "did we write this, and is it still the shape we write",
12
+ * and the only answer anyone acts on is yes/no.
13
+ *
14
+ * The semantics deliberately match what the Schema decoders did, because the
15
+ * records in the wild were written by them:
16
+ *
17
+ * - unknown keys are dropped rather than rejected (callers rebuild an explicit
18
+ * object from the keys they know),
19
+ * - a required key that is absent or wrongly typed rejects the whole record,
20
+ * - an optional key must be either absent or correctly typed — `null` is not
21
+ * an accepted stand-in for absent, matching `Schema.optionalKey`.
22
+ */
23
+ /** A non-null, non-array object — the only JSON shape these records take. */
24
+ function isJsonObject(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ /**
28
+ * Parse a storage string into a plain object, or `undefined` if it is not
29
+ * valid JSON or not an object. Never throws: every caller here treats a
30
+ * corrupt record as a cache miss, not as an error worth surfacing.
31
+ */
32
+ function parseJsonObject(raw) {
33
+ try {
34
+ const parsed = JSON.parse(raw);
35
+ return isJsonObject(parsed) ? parsed : void 0;
36
+ } catch {
37
+ return;
38
+ }
39
+ }
40
+ /** A `Record<string, string>` — every value a string, as `Schema.Record` required. */
41
+ function isStringRecord(value) {
42
+ return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
43
+ }
44
+ //#endregion
45
+ //#region ../browser-session/src/identity/visitor.ts
2
46
  /**
3
47
  * A persistent per-browser visitor id.
4
48
  *
@@ -147,14 +191,15 @@ function cookieDomain() {
147
191
  }
148
192
  function parseRecord(raw) {
149
193
  if (!raw) return void 0;
150
- try {
151
- const parsed = JSON.parse(raw);
152
- if (typeof parsed.id !== "string" || typeof parsed.mintedAt !== "number") return void 0;
153
- if (Date.now() - parsed.mintedAt > MAX_AGE_MS) return void 0;
154
- return parsed;
155
- } catch {
156
- return;
157
- }
194
+ const value = parseJsonObject(raw);
195
+ if (!value) return void 0;
196
+ const { id, mintedAt } = value;
197
+ if (typeof id !== "string" || typeof mintedAt !== "number") return void 0;
198
+ if (Date.now() - mintedAt > MAX_AGE_MS) return void 0;
199
+ return {
200
+ id,
201
+ mintedAt
202
+ };
158
203
  }
159
204
  function readFromStorage() {
160
205
  try {
@@ -259,7 +304,245 @@ function setVisitorTracking(nextEnabled) {
259
304
  if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
260
305
  }
261
306
  //#endregion
262
- //#region ../browser-session/src/user-agent.ts
307
+ //#region ../browser-session/src/events/trace-id.ts
308
+ const ZERO_TRACE_ID = "00000000000000000000000000000000";
309
+ let traceIdProvider = () => void 0;
310
+ /** Wire the host SDK's active-trace-id lookup into event capture. */
311
+ function setActiveTraceIdProvider(provider) {
312
+ traceIdProvider = provider;
313
+ }
314
+ /** The trace id of the active span, or undefined when none is active. */
315
+ function activeTraceId() {
316
+ const id = traceIdProvider();
317
+ return id && id !== ZERO_TRACE_ID ? id : void 0;
318
+ }
319
+ //#endregion
320
+ //#region ../browser-session/src/capture/shared.ts
321
+ /** Emit best-effort: capture must never throw into the host app's call site. */
322
+ function safeEmit(emit, ev) {
323
+ try {
324
+ emit(ev);
325
+ } catch {}
326
+ }
327
+ //#endregion
328
+ //#region ../browser-session/src/capture/errors.ts
329
+ const MAX_STACK = 4e3;
330
+ /** Capture uncaught errors + unhandled promise rejections as session events. */
331
+ function installErrorCapture(emit) {
332
+ const onError = (event) => {
333
+ safeEmit(emit, {
334
+ type: "error",
335
+ level: "error",
336
+ message: event.message || String(event.error ?? "Error"),
337
+ errorStack: truncate(event.error?.stack),
338
+ traceId: activeTraceId()
339
+ });
340
+ };
341
+ const onRejection = (event) => {
342
+ const reason = event.reason;
343
+ safeEmit(emit, {
344
+ type: "error",
345
+ level: "error",
346
+ message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
347
+ errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
348
+ traceId: activeTraceId()
349
+ });
350
+ };
351
+ window.addEventListener("error", onError);
352
+ window.addEventListener("unhandledrejection", onRejection);
353
+ return () => {
354
+ window.removeEventListener("error", onError);
355
+ window.removeEventListener("unhandledrejection", onRejection);
356
+ };
357
+ }
358
+ function truncate(stack) {
359
+ if (!stack) return void 0;
360
+ return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
361
+ }
362
+ //#endregion
363
+ //#region ../browser-session/src/capture/interactions.ts
364
+ const MAX_TEXT = 120;
365
+ /**
366
+ * Capture clicks and input events as session events. Listens in the capture
367
+ * phase so it sees interactions even when the host app calls
368
+ * `stopPropagation()`. Input *values* are never recorded; only the target
369
+ * element. Click target text is omitted when `maskAllText` is set.
370
+ */
371
+ function installInteractionCapture(emit, maskAllText) {
372
+ const onClick = (event) => {
373
+ const target = event.target;
374
+ if (!(target instanceof Element)) return;
375
+ safeEmit(emit, {
376
+ type: "click",
377
+ targetSelector: selectorOf(target),
378
+ targetText: maskAllText ? void 0 : textOf(target)
379
+ });
380
+ };
381
+ const onInput = (event) => {
382
+ const target = event.target;
383
+ if (!(target instanceof Element)) return;
384
+ safeEmit(emit, {
385
+ type: "input",
386
+ targetSelector: selectorOf(target)
387
+ });
388
+ };
389
+ document.addEventListener("click", onClick, true);
390
+ document.addEventListener("input", onInput, true);
391
+ return () => {
392
+ document.removeEventListener("click", onClick, true);
393
+ document.removeEventListener("input", onInput, true);
394
+ };
395
+ }
396
+ /** A short, human-readable selector: tag + #id + .first-class. */
397
+ function selectorOf(el) {
398
+ return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
399
+ }
400
+ function textOf(el) {
401
+ const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
402
+ if (!text) return void 0;
403
+ return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
404
+ }
405
+ //#endregion
406
+ //#region ../browser-session/src/capture/baseline.ts
407
+ /**
408
+ * The capture that runs on **every** page load, sampled for replay or not.
409
+ *
410
+ * Errors and clicks are the analytics substrate, for the same reason navigation
411
+ * is: `error_count` drives the Sessions UI "has errors" filter and `click_count`
412
+ * separates a real visit from a bounce, so gating them behind replay sampling
413
+ * makes both a sample rather than a count — and a session row that reports zero
414
+ * errors because nothing was listening is worse than one that reports none,
415
+ * because it looks complete.
416
+ *
417
+ * Console and network capture stay on the sampled replay path: they patch
418
+ * `console.*`, `window.fetch` and `XMLHttpRequest`, which is a cost (and a
419
+ * surface) worth paying only for sessions that get a recording to attach it to.
420
+ *
421
+ * Owned by the sink rather than by the replay lifecycle, which is also what
422
+ * keeps the listeners installed exactly once — the recorder starting and
423
+ * stopping across visibility changes must not re-register them.
424
+ */
425
+ function startBaselineCapture(emit, maskAllText) {
426
+ if (typeof window === "undefined" || typeof document === "undefined") return () => {};
427
+ const uninstall = [installErrorCapture(emit), installInteractionCapture(emit, maskAllText)];
428
+ return () => {
429
+ for (const off of uninstall) off();
430
+ };
431
+ }
432
+ //#endregion
433
+ //#region ../browser-session/src/platform/browser-globals.ts
434
+ const browserNavigator = () => globalThis.navigator;
435
+ const browserLocation = () => globalThis.window?.location;
436
+ const browserDocument = () => globalThis.document;
437
+ //#endregion
438
+ //#region ../browser-session/src/platform/transport.ts
439
+ /**
440
+ * Header stamped on every request to ingest: `<sdk-name>/<version>`, e.g.
441
+ * `maple-browser/0.3.0`. Browsers refuse to let a page set `user-agent`, so
442
+ * without this a rejected request carried nothing that said which SDK build
443
+ * produced it. Ingest records it as `maple.sdk` on the request span. Every
444
+ * gateway that receives browser traffic must allow it in CORS (`apps/ingest`
445
+ * and the CLI's local listener do) — a header the SDK always sends and
446
+ * preflight refuses blocks the whole SDK, not just the header.
447
+ */
448
+ const SDK_HINT_HEADER = "x-maple-sdk";
449
+ /** `<name>/<version>` — the one shape ingest expects in `x-maple-sdk`. */
450
+ const sdkHint = (name, version) => `${name}/${version}`;
451
+ /** Auth + identity headers shared by every ingest request. */
452
+ function ingestHeaders(config) {
453
+ return {
454
+ Authorization: `Bearer ${config.ingestKey}`,
455
+ [SDK_HINT_HEADER]: config.sdk
456
+ };
457
+ }
458
+ const lastWarnAt = /* @__PURE__ */ new Map();
459
+ function warnDropped(what, error) {
460
+ const now = Date.now();
461
+ if (now - (lastWarnAt.get(what) ?? 0) < 3e4) return;
462
+ lastWarnAt.set(what, now);
463
+ console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
464
+ }
465
+ /**
466
+ * Largest body we will hand to a `keepalive` fetch.
467
+ *
468
+ * The Fetch spec caps the *combined* inflight keepalive body at 64 KiB, and on
469
+ * the way out we issue up to three of these at once (metadata row, final events
470
+ * batch, last replay chunk). Over the budget the browser rejects the request
471
+ * outright, so a normal request — which the page may or may not survive long
472
+ * enough to finish — is strictly the better bet than a guaranteed rejection.
473
+ */
474
+ const MAX_KEEPALIVE_BYTES = 49152;
475
+ /** Whether a body of `bytes` may still ride the keepalive budget. */
476
+ function keepaliveFor(requested, bytes) {
477
+ return requested && bytes <= MAX_KEEPALIVE_BYTES;
478
+ }
479
+ /** gzip a byte buffer using the native CompressionStream (no library). */
480
+ async function gzip(bytes) {
481
+ const stream = new CompressionStream("gzip");
482
+ const writer = stream.writable.getWriter();
483
+ writer.write(bytes);
484
+ writer.close();
485
+ const buffer = await new Response(stream.readable).arrayBuffer();
486
+ return new Uint8Array(buffer);
487
+ }
488
+ /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
489
+ async function postSessionMeta(config, row, keepalive = false) {
490
+ const body = `${JSON.stringify(row)}\n`;
491
+ await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
492
+ method: "POST",
493
+ headers: {
494
+ ...ingestHeaders(config),
495
+ "content-type": "application/x-ndjson"
496
+ },
497
+ body,
498
+ keepalive: keepaliveFor(keepalive, body.length)
499
+ }).catch((error) => {
500
+ warnDropped("metadata POST", error);
501
+ });
502
+ }
503
+ /** POST distilled session events (NDJSON, one row per event). Best-effort. */
504
+ async function postSessionEvents(config, rows, keepalive = false) {
505
+ if (rows.length === 0) return;
506
+ const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
507
+ await fetch(`${config.endpoint}/v1/sessionEvents`, {
508
+ method: "POST",
509
+ headers: {
510
+ ...ingestHeaders(config),
511
+ "content-type": "application/x-ndjson"
512
+ },
513
+ body,
514
+ keepalive: keepaliveFor(keepalive, body.length)
515
+ }).catch((error) => {
516
+ warnDropped("events POST", error);
517
+ });
518
+ }
519
+ const SESSION_EXHAUSTED_STATUS = 413;
520
+ /** POST a gzipped rrweb event chunk. */
521
+ async function postSessionBlob(config, meta, gzipped, keepalive = false) {
522
+ try {
523
+ const response = await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
524
+ method: "POST",
525
+ headers: {
526
+ ...ingestHeaders(config),
527
+ "content-type": "application/octet-stream",
528
+ "x-maple-session-id": meta.sessionId,
529
+ "x-maple-chunk-seq": String(meta.chunkSeq),
530
+ "x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
531
+ "x-maple-event-count": String(meta.eventCount),
532
+ "x-maple-duration-ms": String(meta.durationMs)
533
+ },
534
+ body: gzipped,
535
+ keepalive: keepaliveFor(keepalive, gzipped.byteLength)
536
+ });
537
+ if (response.ok) return "accepted";
538
+ return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
539
+ } catch (error) {
540
+ warnDropped("blob PUT", error);
541
+ return "failed";
542
+ }
543
+ }
544
+ //#endregion
545
+ //#region ../browser-session/src/platform/user-agent.ts
263
546
  let memo;
264
547
  /** Best-effort UA parse — enough to populate filterable session facets. */
265
548
  function parseUserAgent(ua) {
@@ -279,7 +562,7 @@ function parse(ua) {
279
562
  };
280
563
  }
281
564
  //#endregion
282
- //#region ../browser-session/src/meta-row.ts
565
+ //#region ../browser-session/src/events/meta-row.ts
283
566
  /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
284
567
  function formatCHDateTime(date) {
285
568
  const pad = (n, width = 2) => String(n).padStart(width, "0");
@@ -292,11 +575,10 @@ function formatCHDateTime(date) {
292
575
  * exotic embedders) they fall back to empty strings.
293
576
  */
294
577
  function buildSessionMetaRow(input) {
295
- const g = globalThis;
296
- const userAgent = g["navigator"]?.userAgent ?? "";
578
+ const userAgent = browserNavigator()?.userAgent ?? "";
297
579
  const ua = parseUserAgent(userAgent);
298
580
  const now = /* @__PURE__ */ new Date();
299
- const location = g["window"]?.location;
581
+ const location = browserLocation();
300
582
  const identity = input.identity;
301
583
  const entryUrl = input.entry?.entryUrl ?? location?.href ?? "";
302
584
  const referrer = input.entry?.referrer ?? "";
@@ -315,12 +597,13 @@ function buildSessionMetaRow(input) {
315
597
  service_name: input.serviceName,
316
598
  resource_attributes: {
317
599
  "maple.session.recorded": input.recorded ? "true" : "false",
318
- ...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : {},
600
+ "maple.session.replay_format": "rrweb",
601
+ ...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : void 0,
319
602
  ...input.environment ? {
320
603
  "deployment.environment": input.environment,
321
604
  "deployment.environment.name": input.environment
322
- } : {},
323
- ...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : {}
605
+ } : void 0,
606
+ ...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : void 0
324
607
  },
325
608
  visitor_id: input.visitorId ?? "",
326
609
  visitor_is_new: input.visitorIsNew ? 1 : 0,
@@ -338,7 +621,7 @@ function buildSessionMetaRow(input) {
338
621
  host: location?.host ?? "",
339
622
  entry_path: pathOf(entryUrl),
340
623
  exit_path: pathOf(input.lastUrl ?? location?.href ?? ""),
341
- language: g["navigator"]?.language ?? "",
624
+ language: browserNavigator()?.language ?? "",
342
625
  last_activity_at: formatCHDateTime(now),
343
626
  click_count: input.clickCount ?? 0,
344
627
  page_views: input.pageViews ?? 0,
@@ -367,87 +650,20 @@ function pathOf(url) {
367
650
  }
368
651
  }
369
652
  /** POST one session metadata row (NDJSON). Best-effort — never throws. */
370
- async function postSessionMetaRow(endpoint, ingestKey, row, keepalive = false) {
371
- await fetch(`${endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
372
- method: "POST",
373
- headers: {
374
- Authorization: `Bearer ${ingestKey}`,
375
- "content-type": "application/x-ndjson"
376
- },
377
- body: `${JSON.stringify(row)}\n`,
378
- keepalive
379
- }).catch(() => {});
380
- }
381
- //#endregion
382
- //#region ../browser-session/src/replay/transport.ts
383
- let lastWarnAt = 0;
384
- function warnDropped(what, error) {
385
- const now = Date.now();
386
- if (now - lastWarnAt < 3e4) return;
387
- lastWarnAt = now;
388
- console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
389
- }
390
- /** gzip a byte buffer using the native CompressionStream (no library). */
391
- async function gzip(bytes) {
392
- const stream = new CompressionStream("gzip");
393
- const writer = stream.writable.getWriter();
394
- writer.write(bytes);
395
- writer.close();
396
- const buffer = await new Response(stream.readable).arrayBuffer();
397
- return new Uint8Array(buffer);
398
- }
399
- /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
400
- async function postSessionMeta(config, row, keepalive = false) {
653
+ async function postSessionMetaRow(target, row, keepalive = false) {
401
654
  const body = `${JSON.stringify(row)}\n`;
402
- await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
403
- method: "POST",
404
- headers: {
405
- Authorization: `Bearer ${config.ingestKey}`,
406
- "content-type": "application/x-ndjson"
407
- },
408
- body,
409
- keepalive
410
- }).catch((error) => {
411
- warnDropped("metadata POST", error);
412
- });
413
- }
414
- /** POST distilled session events (NDJSON, one row per event). Best-effort. */
415
- async function postSessionEvents(config, rows, keepalive = false) {
416
- if (rows.length === 0) return;
417
- const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
418
- await fetch(`${config.endpoint}/v1/sessionEvents`, {
655
+ await fetch(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
419
656
  method: "POST",
420
657
  headers: {
421
- Authorization: `Bearer ${config.ingestKey}`,
658
+ ...ingestHeaders(target),
422
659
  "content-type": "application/x-ndjson"
423
660
  },
424
661
  body,
425
- keepalive
426
- }).catch((error) => {
427
- warnDropped("events POST", error);
428
- });
429
- }
430
- /** PUT a gzipped rrweb event chunk. */
431
- async function postSessionBlob(config, meta, gzipped, keepalive = false) {
432
- await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
433
- method: "POST",
434
- headers: {
435
- Authorization: `Bearer ${config.ingestKey}`,
436
- "content-type": "application/octet-stream",
437
- "x-maple-session-id": meta.sessionId,
438
- "x-maple-chunk-seq": String(meta.chunkSeq),
439
- "x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
440
- "x-maple-event-count": String(meta.eventCount),
441
- "x-maple-duration-ms": String(meta.durationMs)
442
- },
443
- body: gzipped,
444
- keepalive
445
- }).catch((error) => {
446
- warnDropped("blob PUT", error);
447
- });
662
+ keepalive: keepaliveFor(keepalive, body.length)
663
+ }).catch(() => {});
448
664
  }
449
665
  //#endregion
450
- //#region ../browser-session/src/replay/util.ts
666
+ //#region ../browser-session/src/platform/approximate-size.ts
451
667
  /** Approximate byte size of an event for flush-threshold accounting. Falls back
452
668
  * to a fixed estimate for values that can't be serialized (e.g. cycles). */
453
669
  function approximateSize(value) {
@@ -458,7 +674,7 @@ function approximateSize(value) {
458
674
  }
459
675
  }
460
676
  //#endregion
461
- //#region ../browser-session/src/session.ts
677
+ //#region ../browser-session/src/session/session.ts
462
678
  const STORAGE_KEY = "maple.session";
463
679
  /** Rotate the session after this much inactivity (PostHog's default). */
464
680
  const IDLE_TIMEOUT_MS = 18e5;
@@ -471,6 +687,60 @@ const MAX_SESSION_MS = 864e5;
471
687
  * — rotation correctness only needs sub-idle-timeout granularity.
472
688
  */
473
689
  const ACTIVITY_TOUCH_THROTTLE_MS = 5e3;
690
+ /** Optional keys carrying a plain number. Absent is fine; wrongly typed is not. */
691
+ const OPTIONAL_NUMBERS = [
692
+ "metaVersion",
693
+ "pageViews",
694
+ "clickCount",
695
+ "errorCount"
696
+ ];
697
+ /** Optional keys carrying a plain string. */
698
+ const OPTIONAL_STRINGS = [
699
+ "entryUrl",
700
+ "entryReferrer",
701
+ "lastUrl"
702
+ ];
703
+ /**
704
+ * Validate a persisted session record. Deliberately still accepts records
705
+ * written by older SDKs, which have none of the optional fields — see the
706
+ * `SessionRecord` field comments.
707
+ *
708
+ * Returns `undefined` rather than throwing: the sole caller treats a corrupt
709
+ * record exactly as it treats unreadable storage.
710
+ */
711
+ function parseSessionRecord(raw) {
712
+ const value = parseJsonObject(raw);
713
+ if (!value) return void 0;
714
+ const { id, startedAt, lastActivityAt, chunkSeq } = value;
715
+ if (typeof id !== "string" || typeof startedAt !== "number" || typeof lastActivityAt !== "number" || typeof chunkSeq !== "number") return;
716
+ const record = {
717
+ id,
718
+ startedAt,
719
+ lastActivityAt,
720
+ chunkSeq
721
+ };
722
+ for (const key of OPTIONAL_NUMBERS) {
723
+ const entry = value[key];
724
+ if (entry === void 0) continue;
725
+ if (typeof entry !== "number") return void 0;
726
+ record[key] = entry;
727
+ }
728
+ for (const key of OPTIONAL_STRINGS) {
729
+ const entry = value[key];
730
+ if (entry === void 0) continue;
731
+ if (typeof entry !== "string") return void 0;
732
+ record[key] = entry;
733
+ }
734
+ if (value.visitorIsNew !== void 0) {
735
+ if (typeof value.visitorIsNew !== "boolean") return void 0;
736
+ record.visitorIsNew = value.visitorIsNew;
737
+ }
738
+ if (value.utm !== void 0) {
739
+ if (!isStringRecord(value.utm)) return void 0;
740
+ record.utm = value.utm;
741
+ }
742
+ return record;
743
+ }
474
744
  const UTM_KEYS = [
475
745
  "utm_source",
476
746
  "utm_medium",
@@ -523,9 +793,7 @@ function readRecord() {
523
793
  try {
524
794
  const raw = window.sessionStorage.getItem(STORAGE_KEY);
525
795
  if (!raw) return void 0;
526
- const parsed = JSON.parse(raw);
527
- if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
528
- return;
796
+ return parseSessionRecord(raw) ?? ephemeral;
529
797
  } catch {
530
798
  return ephemeral;
531
799
  }
@@ -699,20 +967,9 @@ function nextMetaVersion() {
699
967
  return version;
700
968
  }
701
969
  //#endregion
702
- //#region ../browser-session/src/events-sink.ts
970
+ //#region ../browser-session/src/events/events-sink.ts
703
971
  const FLUSH_INTERVAL_MS = 5e3;
704
972
  const FLUSH_BYTES = 65536;
705
- const ZERO_TRACE_ID = "00000000000000000000000000000000";
706
- let traceIdProvider = () => void 0;
707
- /** Wire the host SDK's active-trace-id lookup into event capture. */
708
- function setActiveTraceIdProvider(provider) {
709
- traceIdProvider = provider;
710
- }
711
- /** The trace id of the active span, or undefined when none is active. */
712
- function activeTraceId() {
713
- const id = traceIdProvider();
714
- return id && id !== ZERO_TRACE_ID ? id : void 0;
715
- }
716
973
  /**
717
974
  * The sink is a per-session singleton, published on `globalThis` rather than a
718
975
  * module-level variable.
@@ -784,6 +1041,7 @@ function startEventSink(config, sessionId) {
784
1041
  url
785
1042
  });
786
1043
  });
1044
+ const stopBaselineCapture = startBaselineCapture(emit, config.maskAllText);
787
1045
  const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
788
1046
  const sink = {
789
1047
  sessionId,
@@ -792,6 +1050,7 @@ function startEventSink(config, sessionId) {
792
1050
  stop: () => {
793
1051
  clearInterval(flushTimer);
794
1052
  stopNavigation();
1053
+ stopBaselineCapture();
795
1054
  if (holder()[SINK_KEY]?.sink === sink) holder()[SINK_KEY] = void 0;
796
1055
  },
797
1056
  getPageViews: () => pageViews,
@@ -913,7 +1172,7 @@ function toRow(sessionId, ev, seq) {
913
1172
  };
914
1173
  }
915
1174
  //#endregion
916
- //#region ../browser-session/src/session-lifecycle.ts
1175
+ //#region ../browser-session/src/session/lifecycle.ts
917
1176
  /**
918
1177
  * How often a visible tab re-posts its `active` row.
919
1178
  *
@@ -1056,7 +1315,7 @@ function startSessionLifecycle(options, hooks) {
1056
1315
  keepalive: true
1057
1316
  });
1058
1317
  const onVisibilityChange = () => {
1059
- const doc = globalThis["document"];
1318
+ const doc = browserDocument();
1060
1319
  if (!doc) return;
1061
1320
  if (doc.visibilityState === "hidden") {
1062
1321
  endRun({
@@ -1095,9 +1354,24 @@ function startSessionLifecycle(options, hooks) {
1095
1354
  };
1096
1355
  }
1097
1356
  //#endregion
1098
- //#region ../browser-session/src/sink.ts
1357
+ //#region ../browser-session/src/session/sink.ts
1099
1358
  const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
1100
1359
  const observedTraceIdsBySession = /* @__PURE__ */ new Map();
1360
+ /**
1361
+ * Ceiling on trace ids retained per session.
1362
+ *
1363
+ * A session lives up to 24h and every span feeds this, so it is otherwise
1364
+ * unbounded — and the whole set is serialized into the `ended` metadata row,
1365
+ * which is written with `keepalive` on the way out. The Fetch spec caps the
1366
+ * *combined* keepalive body across in-flight requests at 64 KiB, shared here
1367
+ * with the final events flush and the last replay chunk, so an app emitting a
1368
+ * span a second would silently lose its entire session row after ~30 minutes.
1369
+ *
1370
+ * Keep-first rather than keep-last: the ids are a join key for "show me this
1371
+ * session's traces", the UI paginates them anyway, and dropping the tail of a
1372
+ * long session is a smaller loss than dropping the row.
1373
+ */
1374
+ const MAX_TRACE_IDS_PER_SESSION = 200;
1101
1375
  /** Record a trace id seen during the session. Idempotent per id. */
1102
1376
  function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
1103
1377
  if (!sessionId) return;
@@ -1106,6 +1380,7 @@ function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
1106
1380
  ids = /* @__PURE__ */ new Set();
1107
1381
  observedTraceIdsBySession.set(sessionId, ids);
1108
1382
  }
1383
+ if (ids.size >= MAX_TRACE_IDS_PER_SESSION && !ids.has(traceId)) return;
1109
1384
  ids.add(traceId);
1110
1385
  }
1111
1386
  function getObservedTraceIds(sessionId = readSessionSink()?.sessionId) {
@@ -1146,4 +1421,4 @@ function readSessionSink() {
1146
1421
  return globalThis[SESSION_SINK_KEY];
1147
1422
  }
1148
1423
  //#endregion
1149
- export { setVisitorTracking as S, gzip as _, recordTraceId as a, postSessionMetaRow as b, clearPendingEvents as c, setActiveTraceIdProvider as d, startEventSink as f, rotateSession as g, nextChunkSeq as h, readSessionSink as i, getActiveSink as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, activeTraceId as s, clearSessionSink as t, queuePending as u, postSessionBlob as v, configureVisitorCookie as x, postSessionMeta as y };
1424
+ export { setActiveTraceIdProvider as C, activeTraceId as S, setVisitorTracking as T, gzip as _, recordTraceId as a, sdkHint as b, getActiveSink as c, getSession as d, markActivity as f, SDK_HINT_HEADER as g, postSessionMetaRow as h, readSessionSink as i, queuePending as l, rotateSession as m, getObservedTraceIds as n, startSessionLifecycle as o, nextChunkSeq as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, startEventSink as u, postSessionBlob as v, configureVisitorCookie as w, safeEmit as x, postSessionMeta as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maple-dev/browser",
3
- "version": "0.4.0",
3
+ "version": "0.8.0",
4
4
  "description": "Maple browser SDK — OpenTelemetry tracing and rrweb session replay in one package. Every span and replay event shares a session id for trace↔replay correlation.",
5
5
  "keywords": [
6
6
  "maple",
@@ -10,22 +10,36 @@
10
10
  "rum",
11
11
  "session-replay"
12
12
  ],
13
+ "homepage": "https://github.com/MapleTechLabs/maple/tree/main/packages/browser#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/MapleTechLabs/maple/issues"
16
+ },
13
17
  "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/MapleTechLabs/maple.git",
21
+ "directory": "packages/browser"
22
+ },
14
23
  "files": [
15
24
  "dist",
16
25
  "README.md"
17
26
  ],
18
27
  "type": "module",
28
+ "sideEffects": false,
19
29
  "exports": {
20
30
  ".": {
21
31
  "types": "./dist/index.d.mts",
22
32
  "import": "./dist/index.mjs"
23
33
  }
24
34
  },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
25
38
  "scripts": {
26
39
  "build": "tsdown",
27
40
  "typecheck": "tsc --noEmit",
28
- "test": "vitest run"
41
+ "test": "vitest run",
42
+ "size": "bun scripts/size.ts"
29
43
  },
30
44
  "dependencies": {
31
45
  "@opentelemetry/api": "^1.9.0",
@@ -40,6 +54,7 @@
40
54
  },
41
55
  "devDependencies": {
42
56
  "@maple/browser-session": "0.1.0",
57
+ "jsdom": "^29.1.1",
43
58
  "tsdown": "^0.22.14",
44
59
  "typescript": "^6.0.3",
45
60
  "vitest": "^4.1.9"