@maple-dev/browser 0.8.0 → 0.10.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,3 +1,185 @@
1
+ //#region ../browser-session/src/platform/url-privacy.ts
2
+ const REDACTED = "REDACTED";
3
+ const SENSITIVE_PARAM = /^(access_token|id_token|refresh_token|token|token_hash|auth|authorization|code|oobcode|state|password|passwd|pwd|pass|secret|client_secret|api_key|apikey|key|signature|sig|otp|jwt|session|sessionid|session_id|ticket|__clerk_ticket|reset_token|magic|nonce|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential)$/i;
4
+ /** Sanitizers live on `globalThis`, like consent: one page, one policy, however many SDK copies. */
5
+ const SANITIZERS_KEY = "__MAPLE_URL_SANITIZERS__";
6
+ function sanitizers() {
7
+ const owner = globalThis;
8
+ const existing = owner[SANITIZERS_KEY];
9
+ if (existing instanceof Set) return existing;
10
+ const fresh = /* @__PURE__ */ new Set();
11
+ owner[SANITIZERS_KEY] = fresh;
12
+ return fresh;
13
+ }
14
+ /**
15
+ * Register a host-app sanitizer. Additive, like the consent gates: an SDK
16
+ * initialized without one must not remove the one another SDK was given.
17
+ */
18
+ function addUrlSanitizer(sanitizer) {
19
+ sanitizers().add(sanitizer);
20
+ }
21
+ function redactParams(params) {
22
+ let changed = false;
23
+ for (const name of new Set(params.keys())) {
24
+ if (!SENSITIVE_PARAM.test(name)) continue;
25
+ params.set(name, REDACTED);
26
+ changed = true;
27
+ }
28
+ return changed;
29
+ }
30
+ /**
31
+ * Implicit-flow tokens ride the fragment as `#access_token=…&…`; a hash-routed
32
+ * app carries them in the route's own query, `#/reset?token=…`. The route path
33
+ * is kept either way.
34
+ */
35
+ function redactFragment(fragment) {
36
+ if (fragment.startsWith("/") || fragment.startsWith("!/")) {
37
+ const queryAt = fragment.indexOf("?");
38
+ if (queryAt === -1) return fragment;
39
+ const params = new URLSearchParams(fragment.slice(queryAt + 1));
40
+ return redactParams(params) ? `${fragment.slice(0, queryAt)}?${params.toString()}` : fragment;
41
+ }
42
+ if (!fragment.includes("=")) return fragment;
43
+ const params = new URLSearchParams(fragment);
44
+ return redactParams(params) ? params.toString() : fragment;
45
+ }
46
+ const ABSOLUTE = /^[a-z][a-z0-9+.-]*:/i;
47
+ const RELATIVE_BASE = "http://relative.invalid";
48
+ /** Redact credential-shaped query and fragment parameters, keeping the URL's form. */
49
+ function redactUrl(url) {
50
+ if (!url || !url.includes("?") && !url.includes("#")) return url;
51
+ const absolute = ABSOLUTE.test(url);
52
+ let parsed;
53
+ try {
54
+ parsed = new URL(url, absolute ? void 0 : RELATIVE_BASE);
55
+ } catch {
56
+ return url;
57
+ }
58
+ let changed = redactParams(parsed.searchParams);
59
+ const fragment = parsed.hash.slice(1);
60
+ const redactedFragment = redactFragment(fragment);
61
+ if (redactedFragment !== fragment) {
62
+ parsed.hash = redactedFragment;
63
+ changed = true;
64
+ }
65
+ if (!changed) return url;
66
+ if (absolute) return parsed.href;
67
+ if (url.startsWith("//")) return parsed.href.slice(parsed.protocol.length);
68
+ const tail = `${parsed.pathname}${parsed.search}${parsed.hash}`;
69
+ return url.startsWith("/") ? tail : tail.replace(/^\//, "");
70
+ }
71
+ /**
72
+ * The URL as it may leave the page: default redaction, then every registered
73
+ * host sanitizer. A sanitizer that throws or returns a non-string yields the
74
+ * default-redacted URL rather than the raw one.
75
+ */
76
+ function scrubUrl(url) {
77
+ let out = redactUrl(url);
78
+ for (const sanitizer of sanitizers()) try {
79
+ const next = sanitizer(out);
80
+ if (typeof next === "string") out = next;
81
+ } catch {}
82
+ return out;
83
+ }
84
+ //#endregion
85
+ //#region ../browser-session/src/platform/cookie.ts
86
+ /** Scope cookies to the registered domain so subdomains share them. */
87
+ let crossSubdomainCookie = true;
88
+ /** Explicit `Domain=` override; `""` forces a host-only cookie. */
89
+ let cookieDomainOverride;
90
+ /** Memoized probe result. `undefined` = not resolved yet. */
91
+ let probedCookieDomain;
92
+ /**
93
+ * Apply the host app's cookie configuration. Called from `configurePrivacy`, so
94
+ * both SDKs get it from the single call they already make.
95
+ *
96
+ * Like the consent gates, this only ever *tightens*: an app that initializes two
97
+ * SDKs, only one of which passes a `privacy` block, must not have the other's
98
+ * absent option widen the cookie back out to every subdomain.
99
+ *
100
+ * "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
101
+ * first-write-wins: `""` (host-only) is the tightest value there is, and a
102
+ * second SDK asking for it has to win over an earlier `"example.com"`. Between
103
+ * two non-empty domains the shorter one is the broader (`example.com` covers
104
+ * `app.example.com`, not the reverse), so the longer string wins.
105
+ */
106
+ function configureCookieScope(options) {
107
+ if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
108
+ if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
109
+ probedCookieDomain = void 0;
110
+ }
111
+ /** The narrower of two `Domain=` values, treating `undefined` as "unset". */
112
+ function tighterCookieDomain(current, next) {
113
+ if (current === void 0) return next;
114
+ if (current === "" || next === "") return "";
115
+ return next.length > current.length ? next : current;
116
+ }
117
+ function readRawCookie(name) {
118
+ if (typeof document === "undefined") return void 0;
119
+ try {
120
+ for (const part of document.cookie.split(";")) {
121
+ const raw = part.trim();
122
+ if (!raw.startsWith(`${name}=`)) continue;
123
+ return decodeURIComponent(raw.slice(name.length + 1));
124
+ }
125
+ } catch {}
126
+ }
127
+ function setRawCookie(name, value, domain, maxAgeSeconds) {
128
+ if (typeof document === "undefined") return false;
129
+ const attributes = [
130
+ `${name}=${encodeURIComponent(value)}`,
131
+ "path=/",
132
+ `max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
133
+ "SameSite=Lax"
134
+ ];
135
+ if (domain) attributes.push(`domain=.${domain}`);
136
+ if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
137
+ try {
138
+ document.cookie = attributes.join("; ");
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+ /** Expire `name` on the shared domain and host-only: it may predate a narrowed scope. */
145
+ function deleteRawCookie(name) {
146
+ const domain = cookieDomain();
147
+ setRawCookie(name, "", domain, 0);
148
+ if (domain) setRawCookie(name, "", "", 0);
149
+ }
150
+ /**
151
+ * The broadest domain this browser will actually accept a cookie for, found by
152
+ * probing rather than by carrying a public-suffix list, the same trick
153
+ * posthog-js uses. Candidates start at the broadest (the last two labels) and
154
+ * narrow a label at a time, with the first that sticks winning, so
155
+ * `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
156
+ *
157
+ * Returns `""` (host-only cookie) for single-label hosts like `localhost` and
158
+ * for bare IPs, neither of which can carry a `Domain=` attribute.
159
+ */
160
+ function probeCookieDomain() {
161
+ if (typeof document === "undefined" || typeof location === "undefined") return "";
162
+ const hostname = location.hostname;
163
+ if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
164
+ const parts = hostname.split(".");
165
+ if (parts.length < 2) return "";
166
+ for (let i = parts.length - 2; i >= 0; i--) {
167
+ const candidate = parts.slice(i).join(".");
168
+ const probe = "__maple_probe";
169
+ if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
170
+ setRawCookie(probe, "", candidate, 0);
171
+ return candidate;
172
+ }
173
+ }
174
+ return "";
175
+ }
176
+ function cookieDomain() {
177
+ if (cookieDomainOverride !== void 0) return cookieDomainOverride;
178
+ if (!crossSubdomainCookie) return "";
179
+ if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
180
+ return probedCookieDomain;
181
+ }
182
+ //#endregion
1
183
  //#region ../browser-session/src/platform/json.ts
2
184
  /**
3
185
  * Minimal structural guards for records read back out of browser storage.
@@ -42,6 +224,88 @@ function isStringRecord(value) {
42
224
  return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
43
225
  }
44
226
  //#endregion
227
+ //#region ../browser-session/src/session/idle.ts
228
+ /**
229
+ * Rotate the session after this much inactivity (PostHog's default). The visit
230
+ * claim uses the same window: longer and a rotated session goes unbilled,
231
+ * shorter and one continuous visit is billed twice.
232
+ */
233
+ const IDLE_TIMEOUT_MS = 18e5;
234
+ //#endregion
235
+ //#region ../browser-session/src/identity/visit.ts
236
+ /**
237
+ * The billable unit: one visit per visitor per idle window, across every tab and
238
+ * subdomain. `SessionId` cannot be that unit: sessionStorage scopes it to one
239
+ * tab and one origin, and it must stay that way because `session_replays`
240
+ * resolves each `(OrgId, SessionId)` by `argMax(field, Version)` over the whole
241
+ * row, so two tabs sharing an id would overwrite each other.
242
+ *
243
+ * The marker is the visit's latest activity, not the moment it was claimed:
244
+ * active tabs keep extending it, so a tab opened an hour into a busy visit is
245
+ * not a second charge, and 30 idle minutes (a session rotation) start a new one.
246
+ */
247
+ const STORAGE_KEY$2 = "maple.visit";
248
+ /** Cookie names cannot contain `.` per RFC 6265's token grammar. */
249
+ const COOKIE_NAME$1 = "maple_visit";
250
+ /** In-memory copy: all a page load has when persistence is off or blocked. */
251
+ let ephemeral$1;
252
+ function parseMarker(raw) {
253
+ if (!raw) return void 0;
254
+ const parsed = Number.parseInt(raw, 10);
255
+ return Number.isFinite(parsed) ? parsed : void 0;
256
+ }
257
+ function readMarker(persist) {
258
+ if (!persist) return ephemeral$1;
259
+ const fromCookie = parseMarker(readRawCookie(COOKIE_NAME$1));
260
+ if (fromCookie !== void 0) return fromCookie;
261
+ try {
262
+ const fromStorage = parseMarker(window.localStorage.getItem(STORAGE_KEY$2));
263
+ if (fromStorage !== void 0) return fromStorage;
264
+ } catch {}
265
+ return ephemeral$1;
266
+ }
267
+ function writeMarker(at, persist) {
268
+ ephemeral$1 = at;
269
+ if (!persist) return;
270
+ const value = String(at);
271
+ try {
272
+ window.localStorage.setItem(STORAGE_KEY$2, value);
273
+ } catch {}
274
+ setRawCookie(COOKIE_NAME$1, value, cookieDomain(), (IDLE_TIMEOUT_MS - (Date.now() - at)) / 1e3);
275
+ }
276
+ function withinVisit(marker, at) {
277
+ return marker !== void 0 && Math.abs(at - marker) < 18e5;
278
+ }
279
+ /**
280
+ * Claim the visit `at` belongs to, returning whether this caller is the one
281
+ * charged for it. `persist` is false when visitor tracking is off, which keeps
282
+ * the claim in memory: no identifier-adjacent cookie the host app opted out of.
283
+ */
284
+ function claimVisit(at, persist) {
285
+ if (typeof window === "undefined") return false;
286
+ const marker = readMarker(persist);
287
+ if (withinVisit(marker, at)) {
288
+ if (at > marker) writeMarker(at, persist);
289
+ return false;
290
+ }
291
+ writeMarker(at, persist);
292
+ return true;
293
+ }
294
+ /** Extend the current visit to `at`. Never opens one: only `claimVisit` does. */
295
+ function touchVisit(at, persist) {
296
+ if (typeof window === "undefined") return;
297
+ const marker = readMarker(persist);
298
+ if (withinVisit(marker, at) && at > marker) writeMarker(at, persist);
299
+ }
300
+ /** Drop the claim from every store, for a visitor-tracking opt-out. */
301
+ function clearVisitClaim() {
302
+ ephemeral$1 = void 0;
303
+ try {
304
+ window.localStorage.removeItem(STORAGE_KEY$2);
305
+ } catch {}
306
+ deleteRawCookie(COOKIE_NAME$1);
307
+ }
308
+ //#endregion
45
309
  //#region ../browser-session/src/identity/visitor.ts
46
310
  /**
47
311
  * A persistent per-browser visitor id.
@@ -99,96 +363,11 @@ let enabled = true;
99
363
  let cached;
100
364
  let persisted = false;
101
365
  let mintedThisLoad = false;
102
- /** Scope the cookie to the registered domain so subdomains share the id. */
103
- let crossSubdomainCookie = true;
104
- /** Explicit `Domain=` override; `""` forces a host-only cookie. */
105
- let cookieDomainOverride;
106
- /** Memoized probe result. `undefined` = not resolved yet. */
107
- let probedCookieDomain;
108
366
  /**
109
- * Apply the host app's cookie configuration. Called from `configurePrivacy`, so
110
- * both SDKs get it from the single call they already make.
111
- *
112
- * Like the consent gates, this only ever *tightens*: an app that initializes two
113
- * SDKs, only one of which passes a `privacy` block, must not have the other's
114
- * absent option widen the cookie back out to every subdomain.
115
- *
116
- * "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
117
- * first-write-wins: `""` (host-only) is the tightest value there is, and a
118
- * second SDK asking for it has to win over an earlier `"example.com"`. Between
119
- * two non-empty domains the shorter one is the broader — `example.com` covers
120
- * `app.example.com` and not the reverse — so the longer string wins.
367
+ * Kept under this name because `configurePrivacy` and the SDK tests call it;
368
+ * the domain decision lives in `platform/cookie.ts`, shared with the visit claim.
121
369
  */
122
- function configureVisitorCookie(options) {
123
- if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
124
- if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
125
- probedCookieDomain = void 0;
126
- }
127
- /** The narrower of two `Domain=` values, treating `undefined` as "unset". */
128
- function tighterCookieDomain(current, next) {
129
- if (current === void 0) return next;
130
- if (current === "" || next === "") return "";
131
- return next.length > current.length ? next : current;
132
- }
133
- function readRawCookie(name) {
134
- if (typeof document === "undefined") return void 0;
135
- try {
136
- for (const part of document.cookie.split(";")) {
137
- const raw = part.trim();
138
- if (!raw.startsWith(`${name}=`)) continue;
139
- return decodeURIComponent(raw.slice(name.length + 1));
140
- }
141
- } catch {}
142
- }
143
- function setRawCookie(name, value, domain, maxAgeSeconds) {
144
- if (typeof document === "undefined") return false;
145
- const attributes = [
146
- `${name}=${encodeURIComponent(value)}`,
147
- "path=/",
148
- `max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
149
- "SameSite=Lax"
150
- ];
151
- if (domain) attributes.push(`domain=.${domain}`);
152
- if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
153
- try {
154
- document.cookie = attributes.join("; ");
155
- return true;
156
- } catch {
157
- return false;
158
- }
159
- }
160
- /**
161
- * The broadest domain this browser will actually accept a cookie for, found by
162
- * probing rather than by carrying a public-suffix list — the same trick
163
- * posthog-js uses. Candidates start at the broadest (the last two labels) and
164
- * narrow a label at a time, with the first that sticks winning — so
165
- * `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
166
- *
167
- * Returns `""` (host-only cookie) for single-label hosts like `localhost` and
168
- * for bare IPs, neither of which can carry a `Domain=` attribute.
169
- */
170
- function probeCookieDomain() {
171
- if (typeof document === "undefined" || typeof location === "undefined") return "";
172
- const hostname = location.hostname;
173
- if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
174
- const parts = hostname.split(".");
175
- if (parts.length < 2) return "";
176
- for (let i = parts.length - 2; i >= 0; i--) {
177
- const candidate = parts.slice(i).join(".");
178
- const probe = "__maple_probe";
179
- if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
180
- setRawCookie(probe, "", candidate, 0);
181
- return candidate;
182
- }
183
- }
184
- return "";
185
- }
186
- function cookieDomain() {
187
- if (cookieDomainOverride !== void 0) return cookieDomainOverride;
188
- if (!crossSubdomainCookie) return "";
189
- if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
190
- return probedCookieDomain;
191
- }
370
+ const configureVisitorCookie = configureCookieScope;
192
371
  function parseRecord(raw) {
193
372
  if (!raw) return void 0;
194
373
  const value = parseJsonObject(raw);
@@ -299,9 +478,8 @@ function setVisitorTracking(nextEnabled) {
299
478
  try {
300
479
  window.localStorage.removeItem(STORAGE_KEY$1);
301
480
  } catch {}
302
- const domain = cookieDomain();
303
- setRawCookie(COOKIE_NAME, "", domain, 0);
304
- if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
481
+ deleteRawCookie(COOKIE_NAME);
482
+ clearVisitClaim();
305
483
  }
306
484
  //#endregion
307
485
  //#region ../browser-session/src/events/trace-id.ts
@@ -316,6 +494,26 @@ function activeTraceId() {
316
494
  const id = traceIdProvider();
317
495
  return id && id !== ZERO_TRACE_ID ? id : void 0;
318
496
  }
497
+ const slots = [];
498
+ /** Report a span that just started. Fills the innermost open slot, first span wins. */
499
+ function noteStartedTraceId(traceId) {
500
+ const slot = slots.at(-1);
501
+ if (slot && slot.traceId === void 0 && traceId !== ZERO_TRACE_ID) slot.traceId = traceId;
502
+ }
503
+ /** Run `fn`, returning its result and the trace id of the first span started during it. */
504
+ function withStartedTraceId(fn) {
505
+ const slot = { traceId: void 0 };
506
+ slots.push(slot);
507
+ try {
508
+ return {
509
+ result: fn(),
510
+ traceId: slot.traceId
511
+ };
512
+ } finally {
513
+ const index = slots.lastIndexOf(slot);
514
+ if (index !== -1) slots.splice(index, 1);
515
+ }
516
+ }
319
517
  //#endregion
320
518
  //#region ../browser-session/src/capture/shared.ts
321
519
  /** Emit best-effort: capture must never throw into the host app's call site. */
@@ -359,6 +557,21 @@ function truncate(stack) {
359
557
  if (!stack) return void 0;
360
558
  return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
361
559
  }
560
+ const BLOCK_SELECTOR = `[data-rr-block]`;
561
+ /**
562
+ * Whether `el` sits inside a blocked subtree. Mirrors rrweb's own ancestor
563
+ * check so the two capture paths agree on what is hidden.
564
+ *
565
+ * Walks parents rather than using `closest()`: no string reaches a selector
566
+ * parser, so a malformed marker cannot throw into the host app's event handler.
567
+ */
568
+ const isBlocked = (el) => {
569
+ for (let node = el; node !== null; node = node.parentElement) {
570
+ if (node.classList?.contains("rr-block")) return true;
571
+ if (node.hasAttribute?.("data-rr-block")) return true;
572
+ }
573
+ return false;
574
+ };
362
575
  //#endregion
363
576
  //#region ../browser-session/src/capture/interactions.ts
364
577
  const MAX_TEXT = 120;
@@ -366,7 +579,8 @@ const MAX_TEXT = 120;
366
579
  * Capture clicks and input events as session events. Listens in the capture
367
580
  * phase so it sees interactions even when the host app calls
368
581
  * `stopPropagation()`. Input *values* are never recorded; only the target
369
- * element. Click target text is omitted when `maskAllText` is set.
582
+ * element. Click target text is omitted when `maskAllText` is set, and when the
583
+ * target sits inside a blocked (`.rr-block` / `data-rr-block`) subtree.
370
584
  */
371
585
  function installInteractionCapture(emit, maskAllText) {
372
586
  const onClick = (event) => {
@@ -375,7 +589,7 @@ function installInteractionCapture(emit, maskAllText) {
375
589
  safeEmit(emit, {
376
590
  type: "click",
377
591
  targetSelector: selectorOf(target),
378
- targetText: maskAllText ? void 0 : textOf(target)
592
+ targetText: maskAllText || isBlocked(target) ? void 0 : textOf(target)
379
593
  });
380
594
  };
381
595
  const onInput = (event) => {
@@ -448,11 +662,13 @@ const browserDocument = () => globalThis.document;
448
662
  const SDK_HINT_HEADER = "x-maple-sdk";
449
663
  /** `<name>/<version>` — the one shape ingest expects in `x-maple-sdk`. */
450
664
  const sdkHint = (name, version) => `${name}/${version}`;
451
- /** Auth + identity headers shared by every ingest request. */
665
+ /** Auth + identity headers shared by every ingest request. No key, no `Authorization`. */
452
666
  function ingestHeaders(config) {
667
+ const identity = { [SDK_HINT_HEADER]: config.sdk };
668
+ if (!config.ingestKey) return identity;
453
669
  return {
454
670
  Authorization: `Bearer ${config.ingestKey}`,
455
- [SDK_HINT_HEADER]: config.sdk
671
+ ...identity
456
672
  };
457
673
  }
458
674
  const lastWarnAt = /* @__PURE__ */ new Map();
@@ -463,40 +679,107 @@ function warnDropped(what, error) {
463
679
  console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
464
680
  }
465
681
  /**
466
- * Largest body we will hand to a `keepalive` fetch.
682
+ * Combined budget for this SDK's in-flight `keepalive` bodies.
683
+ *
684
+ * The Fetch spec caps the *combined* in-flight keepalive body at 64 KiB per
685
+ * page, and the browser rejects a request that would cross it. On the way out
686
+ * up to three of our writes go at once (metadata row, final events batch, last
687
+ * replay chunk), so the budget has to be shared rather than checked per
688
+ * request. It stops short of 64 KiB because the OTLP trace exporter spends
689
+ * from the same page-wide allowance under its own accounting.
467
690
  *
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.
691
+ * Over the budget a write goes out as a normal request, which the page may or
692
+ * may not survive long enough to finish: strictly better than a guaranteed
693
+ * rejection.
694
+ */
695
+ const KEEPALIVE_BUDGET_BYTES = 49152;
696
+ /** On `globalThis`: two bundled SDK copies still share one page-wide allowance. */
697
+ const KEEPALIVE_KEY = "__MAPLE_KEEPALIVE_INFLIGHT__";
698
+ function keepaliveInflight() {
699
+ const owner = globalThis;
700
+ const existing = owner[KEEPALIVE_KEY];
701
+ if (existing) return existing;
702
+ const fresh = { bytes: 0 };
703
+ owner[KEEPALIVE_KEY] = fresh;
704
+ return fresh;
705
+ }
706
+ /**
707
+ * Reserve `bytes` of the shared keepalive budget. Returns the release function
708
+ * when the request may use `keepalive`, `undefined` when it must not.
709
+ */
710
+ function reserveKeepalive(requested, bytes) {
711
+ if (!requested) return void 0;
712
+ const inflight = keepaliveInflight();
713
+ if (inflight.bytes + bytes > KEEPALIVE_BUDGET_BYTES) return void 0;
714
+ inflight.bytes += bytes;
715
+ let released = false;
716
+ return () => {
717
+ if (released) return;
718
+ released = true;
719
+ inflight.bytes = Math.max(0, inflight.bytes - bytes);
720
+ };
721
+ }
722
+ /** Body size in bytes: the keepalive limit counts bytes, and `string.length` counts UTF-16 units. */
723
+ function byteLength(body) {
724
+ return typeof body === "string" ? new TextEncoder().encode(body).byteLength : body.byteLength;
725
+ }
726
+ /**
727
+ * POST to ingest, spending the shared keepalive budget when `keepalive` is
728
+ * requested. Resolves with the status only: the response body is cancelled
729
+ * before the reservation is released, because the browser counts a keepalive
730
+ * request against the page-wide limit until its response body ends, not
731
+ * until headers arrive. Rejects exactly as `fetch` does; callers own the
732
+ * error policy.
473
733
  */
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;
734
+ async function postToIngest(url, headers, body, keepalive) {
735
+ const release = reserveKeepalive(keepalive, byteLength(body));
736
+ try {
737
+ const response = await fetch(url, {
738
+ method: "POST",
739
+ headers,
740
+ body,
741
+ keepalive: release !== void 0
742
+ });
743
+ await response.body?.cancel().catch(() => {});
744
+ return {
745
+ ok: response.ok,
746
+ status: response.status
747
+ };
748
+ } finally {
749
+ release?.();
750
+ }
478
751
  }
479
- /** gzip a byte buffer using the native CompressionStream (no library). */
752
+ /**
753
+ * Smallest well-formed gzip stream: a 10-byte header plus an 8-byte trailer.
754
+ * Anything shorter cannot carry a complete member, whatever it contains.
755
+ */
756
+ const GZIP_MIN_BYTES = 18;
757
+ /**
758
+ * gzip a byte buffer using the native CompressionStream (no library).
759
+ *
760
+ * Throws rather than returning a partial stream. The write and close promises
761
+ * used to be discarded, so a page torn down mid-compression still resolved
762
+ * `arrayBuffer()` — with only the bytes already flushed — and the caller posted
763
+ * a 3-byte header stub as if it were a complete chunk. Ingest rejected those as
764
+ * corrupt gzip; they were 82% of all replay-blob failures, overwhelmingly from
765
+ * crawlers, which tear the page down on nearly every session.
766
+ */
480
767
  async function gzip(bytes) {
481
768
  const stream = new CompressionStream("gzip");
482
769
  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);
770
+ const written = writer.write(bytes).then(() => writer.close());
771
+ const [buffer] = await Promise.all([new Response(stream.readable).arrayBuffer(), written]);
772
+ const out = new Uint8Array(buffer);
773
+ if (out.length < GZIP_MIN_BYTES || out[0] !== 31 || out[1] !== 139 || out[2] !== 8) throw new Error(`gzip produced ${out.length} bytes, not a complete gzip stream`);
774
+ return out;
487
775
  }
488
776
  /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
489
777
  async function postSessionMeta(config, row, keepalive = false) {
490
778
  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) => {
779
+ await postToIngest(`${config.endpoint}/v1/sessionReplays/meta`, {
780
+ ...ingestHeaders(config),
781
+ "content-type": "application/x-ndjson"
782
+ }, body, keepalive).catch((error) => {
500
783
  warnDropped("metadata POST", error);
501
784
  });
502
785
  }
@@ -504,15 +787,10 @@ async function postSessionMeta(config, row, keepalive = false) {
504
787
  async function postSessionEvents(config, rows, keepalive = false) {
505
788
  if (rows.length === 0) return;
506
789
  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) => {
790
+ await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
791
+ ...ingestHeaders(config),
792
+ "content-type": "application/x-ndjson"
793
+ }, body, keepalive).catch((error) => {
516
794
  warnDropped("events POST", error);
517
795
  });
518
796
  }
@@ -520,20 +798,15 @@ const SESSION_EXHAUSTED_STATUS = 413;
520
798
  /** POST a gzipped rrweb event chunk. */
521
799
  async function postSessionBlob(config, meta, gzipped, keepalive = false) {
522
800
  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
- });
801
+ const response = await postToIngest(`${config.endpoint}/v1/sessionReplays/blob`, {
802
+ ...ingestHeaders(config),
803
+ "content-type": "application/octet-stream",
804
+ "x-maple-session-id": meta.sessionId,
805
+ "x-maple-chunk-seq": String(meta.chunkSeq),
806
+ "x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
807
+ "x-maple-event-count": String(meta.eventCount),
808
+ "x-maple-duration-ms": String(meta.durationMs)
809
+ }, gzipped, keepalive);
537
810
  if (response.ok) return "accepted";
538
811
  return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
539
812
  } catch (error) {
@@ -558,9 +831,51 @@ function parse(ua) {
558
831
  return {
559
832
  browserName: /edg/i.test(ua) ? "Edge" : /opr|opera/i.test(ua) ? "Opera" : /chrome|crios/i.test(ua) ? "Chrome" : /firefox|fxios/i.test(ua) ? "Firefox" : /safari/i.test(ua) ? "Safari" : "Unknown",
560
833
  osName: /windows/i.test(ua) ? "Windows" : /iphone|ipad|ios/i.test(ua) ? "iOS" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /linux/i.test(ua) ? "Linux" : "Unknown",
561
- deviceType: /mobile|iphone|android.*mobile/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
834
+ deviceType: /mobile|iphone/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
562
835
  };
563
836
  }
837
+ /**
838
+ * Crawler tokens, every one carrying a delimiter or a scheme on purpose.
839
+ *
840
+ * A bare `bot` substring matches `CUBOT`, an Android phone brand that appears in
841
+ * ordinary mobile UAs, and would file those visitors as crawlers. Requiring
842
+ * `bot/`, `bot;` or `bot)` keeps the `Name/version` and `(compatible; Name;
843
+ * +url)` conventions real crawlers follow while missing the phone. This mirrors
844
+ * the generic tail of the server-side classifier in
845
+ * `@maple/query-engine`'s `user-agent.ts`, deliberately without the ~50 named
846
+ * signatures: every one of those ends in a token already listed here, and the
847
+ * list ships in a size-budgeted bundle to customers.
848
+ */
849
+ const BOT_TOKENS = [
850
+ "bot/",
851
+ "bot;",
852
+ "bot)",
853
+ "crawler",
854
+ "spider",
855
+ "+http",
856
+ "headless"
857
+ ];
858
+ /**
859
+ * Whether the UA is a crawler rather than a person.
860
+ *
861
+ * Used to route crawlers to a metadata-only session: they still appear in Web
862
+ * Analytics — the server-side classifier is what labels them there — but they do
863
+ * not download rrweb or upload replay chunks. Crawlers execute the beacon and
864
+ * then tear the page down mid-flush, which produced the large majority of all
865
+ * corrupt replay-chunk uploads, and every chunk they did manage to upload spent
866
+ * a customer's replay quota on a session nobody will ever watch.
867
+ *
868
+ * Deliberately does not consult `navigator.webdriver`: it is set by every
869
+ * Playwright and Puppeteer run, headed or not. The `headless` token does match
870
+ * the old headless shell's `HeadlessChrome` UA, which production crawlers use
871
+ * too (see the test cases), so an e2e suite that wants replay recorded runs
872
+ * headed, or sets an explicit `userAgent` without `headless` in it. Chrome's
873
+ * new headless mode can still report `HeadlessChrome/`, so it is not enough.
874
+ */
875
+ function isLikelyBot(ua) {
876
+ const lower = ua.toLowerCase();
877
+ return BOT_TOKENS.some((token) => lower.includes(token));
878
+ }
564
879
  //#endregion
565
880
  //#region ../browser-session/src/events/meta-row.ts
566
881
  /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
@@ -580,8 +895,8 @@ function buildSessionMetaRow(input) {
580
895
  const now = /* @__PURE__ */ new Date();
581
896
  const location = browserLocation();
582
897
  const identity = input.identity;
583
- const entryUrl = input.entry?.entryUrl ?? location?.href ?? "";
584
- const referrer = input.entry?.referrer ?? "";
898
+ const entryUrl = scrubUrl(input.entry?.entryUrl || location?.href || "");
899
+ const referrer = scrubUrl(input.entry?.referrer ?? "");
585
900
  const utm = input.entry?.utm ?? {};
586
901
  const row = {
587
902
  session_id: input.sessionId,
@@ -589,7 +904,7 @@ function buildSessionMetaRow(input) {
589
904
  status: input.status,
590
905
  version: input.version,
591
906
  user_id: identity?.id ?? input.userId ?? "",
592
- url_initial: location?.href ?? "",
907
+ url_initial: entryUrl,
593
908
  user_agent: userAgent,
594
909
  browser_name: ua.browserName,
595
910
  os_name: ua.osName,
@@ -598,15 +913,17 @@ function buildSessionMetaRow(input) {
598
913
  resource_attributes: {
599
914
  "maple.session.recorded": input.recorded ? "true" : "false",
600
915
  "maple.session.replay_format": "rrweb",
916
+ ...input.recorded && input.replayTrigger ? { "maple.session.replay_trigger": input.replayTrigger } : void 0,
601
917
  ...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : void 0,
602
918
  ...input.environment ? {
603
919
  "deployment.environment": input.environment,
604
920
  "deployment.environment.name": input.environment
605
921
  } : void 0,
606
- ...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : void 0
922
+ ...input.serviceVersion && /^[0-9a-f]{7,40}$/i.test(input.serviceVersion) ? { "vcs.ref.head.revision": input.serviceVersion } : void 0
607
923
  },
608
924
  visitor_id: input.visitorId ?? "",
609
925
  visitor_is_new: input.visitorIsNew ? 1 : 0,
926
+ billable_start: input.billableStart ? 1 : 0,
610
927
  user_email: (input.captureUserEmail === false ? void 0 : identity?.email) ?? "",
611
928
  user_name: identity?.username ?? "",
612
929
  group_id: identity?.groupId ?? "",
@@ -652,15 +969,10 @@ function pathOf(url) {
652
969
  /** POST one session metadata row (NDJSON). Best-effort — never throws. */
653
970
  async function postSessionMetaRow(target, row, keepalive = false) {
654
971
  const body = `${JSON.stringify(row)}\n`;
655
- await fetch(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
656
- method: "POST",
657
- headers: {
658
- ...ingestHeaders(target),
659
- "content-type": "application/x-ndjson"
660
- },
661
- body,
662
- keepalive: keepaliveFor(keepalive, body.length)
663
- }).catch(() => {});
972
+ await postToIngest(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
973
+ ...ingestHeaders(target),
974
+ "content-type": "application/x-ndjson"
975
+ }, body, keepalive).catch(() => {});
664
976
  }
665
977
  //#endregion
666
978
  //#region ../browser-session/src/platform/approximate-size.ts
@@ -676,8 +988,6 @@ function approximateSize(value) {
676
988
  //#endregion
677
989
  //#region ../browser-session/src/session/session.ts
678
990
  const STORAGE_KEY = "maple.session";
679
- /** Rotate the session after this much inactivity (PostHog's default). */
680
- const IDLE_TIMEOUT_MS = 18e5;
681
991
  /** Hard cap on a single session's lifetime regardless of activity. */
682
992
  const MAX_SESSION_MS = 864e5;
683
993
  /**
@@ -735,6 +1045,19 @@ function parseSessionRecord(raw) {
735
1045
  if (typeof value.visitorIsNew !== "boolean") return void 0;
736
1046
  record.visitorIsNew = value.visitorIsNew;
737
1047
  }
1048
+ if (value.billable !== void 0) {
1049
+ if (typeof value.billable !== "boolean") return void 0;
1050
+ record.billable = value.billable;
1051
+ }
1052
+ if (value.replaySampled !== void 0) {
1053
+ if (typeof value.replaySampled !== "boolean") return void 0;
1054
+ record.replaySampled = value.replaySampled;
1055
+ }
1056
+ if (value.replayBuffered !== void 0) {
1057
+ if (typeof value.replayBuffered !== "boolean") return void 0;
1058
+ record.replayBuffered = value.replayBuffered;
1059
+ }
1060
+ if (value.replayTrigger === "error") record.replayTrigger = value.replayTrigger;
738
1061
  if (value.utm !== void 0) {
739
1062
  if (!isStringRecord(value.utm)) return void 0;
740
1063
  record.utm = value.utm;
@@ -750,6 +1073,12 @@ const UTM_KEYS = [
750
1073
  ];
751
1074
  /** In-memory fallback when sessionStorage is unavailable (private mode). */
752
1075
  let ephemeral;
1076
+ /**
1077
+ * Set while sessionStorage writes throw. On quota exhaustion reads still work,
1078
+ * so the stored copy is a frozen snapshot: `metaVersion` pinned at 1 billed
1079
+ * every heartbeat as a new session. Keep this separate from the read try/catch.
1080
+ */
1081
+ let writesFailed = false;
753
1082
  const rotationListeners = /* @__PURE__ */ new Set();
754
1083
  /**
755
1084
  * Read the entry URL/referrer/UTM of the current page.
@@ -768,11 +1097,12 @@ function readEntryContext() {
768
1097
  if (value) utm[key] = value.slice(0, 128);
769
1098
  }
770
1099
  } catch {}
1100
+ const href = scrubUrl(location.href);
771
1101
  return {
772
- entryUrl: location.href,
773
- entryReferrer: typeof document !== "undefined" ? document.referrer : "",
1102
+ entryUrl: href,
1103
+ entryReferrer: typeof document !== "undefined" ? scrubUrl(document.referrer) : "",
774
1104
  utm,
775
- lastUrl: location.href,
1105
+ lastUrl: href,
776
1106
  pageViews: 0,
777
1107
  clickCount: 0,
778
1108
  errorCount: 0
@@ -790,6 +1120,7 @@ function freshRecord(now) {
790
1120
  };
791
1121
  }
792
1122
  function readRecord() {
1123
+ if (writesFailed && ephemeral) return ephemeral;
793
1124
  try {
794
1125
  const raw = window.sessionStorage.getItem(STORAGE_KEY);
795
1126
  if (!raw) return void 0;
@@ -802,10 +1133,112 @@ function writeRecord(record) {
802
1133
  ephemeral = record;
803
1134
  try {
804
1135
  window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
1136
+ writesFailed = false;
1137
+ } catch {
1138
+ writesFailed = true;
1139
+ }
1140
+ claimTab(record.id);
1141
+ }
1142
+ const TAB_LEASE_PREFIX = "maple.session.tab.";
1143
+ const TAB_LEASE_TTL_MS = 12e4;
1144
+ const TAB_LEASE_REFRESH_MS = 5e3;
1145
+ const TAB_NONCE_KEY = "__MAPLE_TAB_NONCE__";
1146
+ /** The session id this tab has checked (or minted) and may keep using. */
1147
+ let verifiedSessionId;
1148
+ let leaseWrittenAt = 0;
1149
+ let releaseInstalled = false;
1150
+ /**
1151
+ * Set from `pagehide` until the page is shown again. The lifecycle's own
1152
+ * `pagehide` handler posts an `ended` row after ours runs, which writes the
1153
+ * record; re-leasing there would make the next load of this tab look like a
1154
+ * duplicate and rotate on every reload.
1155
+ */
1156
+ let leaseReleased = false;
1157
+ function tabNonce() {
1158
+ const owner = globalThis;
1159
+ const existing = owner[TAB_NONCE_KEY];
1160
+ if (typeof existing === "string") return existing;
1161
+ const fresh = crypto.randomUUID();
1162
+ owner[TAB_NONCE_KEY] = fresh;
1163
+ return fresh;
1164
+ }
1165
+ function leaseStorage() {
1166
+ try {
1167
+ return typeof window !== "undefined" ? window.localStorage : void 0;
1168
+ } catch {
1169
+ return;
1170
+ }
1171
+ }
1172
+ function readLease(storage, sessionId) {
1173
+ const raw = storage.getItem(`${TAB_LEASE_PREFIX}${sessionId}`);
1174
+ if (!raw) return void 0;
1175
+ const split = raw.lastIndexOf(":");
1176
+ const at = Number(raw.slice(split + 1));
1177
+ return split > 0 && Number.isFinite(at) ? {
1178
+ nonce: raw.slice(0, split),
1179
+ at
1180
+ } : void 0;
1181
+ }
1182
+ function claimTab(sessionId) {
1183
+ const now = Date.now();
1184
+ if (verifiedSessionId === sessionId && now - leaseWrittenAt < TAB_LEASE_REFRESH_MS) return;
1185
+ if (verifiedSessionId !== void 0 && verifiedSessionId !== sessionId) releaseTab(verifiedSessionId);
1186
+ verifiedSessionId = sessionId;
1187
+ if (leaseReleased) return;
1188
+ leaseWrittenAt = now;
1189
+ const storage = leaseStorage();
1190
+ if (!storage) return;
1191
+ try {
1192
+ storage.setItem(`${TAB_LEASE_PREFIX}${sessionId}`, `${tabNonce()}:${now}`);
1193
+ } catch {}
1194
+ installLeaseRelease();
1195
+ }
1196
+ function releaseTab(sessionId) {
1197
+ const storage = leaseStorage();
1198
+ if (!storage) return;
1199
+ try {
1200
+ if (readLease(storage, sessionId)?.nonce === tabNonce()) storage.removeItem(`${TAB_LEASE_PREFIX}${sessionId}`);
805
1201
  } catch {}
806
1202
  }
1203
+ function installLeaseRelease() {
1204
+ if (releaseInstalled || typeof globalThis.addEventListener !== "function") return;
1205
+ releaseInstalled = true;
1206
+ globalThis.addEventListener("pagehide", () => {
1207
+ leaseReleased = true;
1208
+ if (verifiedSessionId !== void 0) releaseTab(verifiedSessionId);
1209
+ });
1210
+ globalThis.addEventListener("pageshow", () => {
1211
+ leaseReleased = false;
1212
+ leaseWrittenAt = 0;
1213
+ });
1214
+ }
1215
+ /** Drop leases a crashed tab never released. */
1216
+ function pruneLeases(storage, now) {
1217
+ for (let i = storage.length - 1; i >= 0; i--) {
1218
+ const key = storage.key(i);
1219
+ if (!key?.startsWith(TAB_LEASE_PREFIX)) continue;
1220
+ const lease = readLease(storage, key.slice(18));
1221
+ if (!lease || now - lease.at > TAB_LEASE_TTL_MS) storage.removeItem(key);
1222
+ }
1223
+ }
1224
+ /**
1225
+ * Whether another live tab holds `record`'s session. Checked once per session
1226
+ * id per tab; after that this tab has claimed it.
1227
+ */
1228
+ function ownedByAnotherTab(record, now) {
1229
+ if (verifiedSessionId === record.id) return false;
1230
+ const storage = leaseStorage();
1231
+ if (!storage) return false;
1232
+ try {
1233
+ pruneLeases(storage, now);
1234
+ const lease = readLease(storage, record.id);
1235
+ return lease !== void 0 && lease.nonce !== tabNonce() && now - lease.at <= TAB_LEASE_TTL_MS;
1236
+ } catch {
1237
+ return false;
1238
+ }
1239
+ }
807
1240
  function isSessionExpired(record, now = Date.now()) {
808
- return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
1241
+ return now - record.lastActivityAt > 18e5 || now - record.startedAt > MAX_SESSION_MS;
809
1242
  }
810
1243
  /** Observe genuine idle/lifetime rotation. Invoked before the new record is installed. */
811
1244
  function onSessionRotate(listener) {
@@ -839,7 +1272,7 @@ function installRotatedRecord(previous, next) {
839
1272
  function getSession() {
840
1273
  const now = Date.now();
841
1274
  const existing = readRecord();
842
- if (existing && !isSessionExpired(existing, now)) {
1275
+ if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
843
1276
  const record = {
844
1277
  ...migrateRecord(existing),
845
1278
  lastActivityAt: now
@@ -867,13 +1300,13 @@ function rotateSession() {
867
1300
  */
868
1301
  function touchSession(now) {
869
1302
  const existing = readRecord();
870
- if (existing && !isSessionExpired(existing, now)) {
1303
+ if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
871
1304
  const migrated = migrateRecord(existing);
872
1305
  const touched = {
873
1306
  ...migrated,
874
1307
  lastActivityAt: now
875
1308
  };
876
- if (migrated !== existing || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
1309
+ if (migrated !== existing || verifiedSessionId !== existing.id || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
877
1310
  return touched;
878
1311
  }
879
1312
  const record = freshRecord(now);
@@ -900,7 +1333,7 @@ function noteNavigation(url) {
900
1333
  const record = touchSession(now);
901
1334
  writeRecord({
902
1335
  ...record,
903
- lastUrl: url,
1336
+ lastUrl: scrubUrl(url),
904
1337
  pageViews: (record.pageViews ?? 0) + 1,
905
1338
  lastActivityAt: now
906
1339
  });
@@ -916,6 +1349,22 @@ function noteCounts(counts) {
916
1349
  });
917
1350
  }
918
1351
  /**
1352
+ * Whether `sessionId` is the session charged for its visit. `claim` runs once,
1353
+ * on the first row, and the answer is persisted so heartbeats, reloads and the
1354
+ * `ended` row all agree with it.
1355
+ */
1356
+ function resolveBillable(sessionId, claim) {
1357
+ const record = readRecord();
1358
+ if (!record || record.id !== sessionId) return false;
1359
+ if (record.billable !== void 0) return record.billable;
1360
+ const billable = claim();
1361
+ writeRecord({
1362
+ ...record,
1363
+ billable
1364
+ });
1365
+ return billable;
1366
+ }
1367
+ /**
919
1368
  * The persisted session record as-is — no activity touch, no rotation. Use
920
1369
  * this to read counters when posting a metadata row; `getSession()` would
921
1370
  * rotate an idle session out from under the row being written.
@@ -966,8 +1415,87 @@ function nextMetaVersion() {
966
1415
  });
967
1416
  return version;
968
1417
  }
1418
+ /** A uniform draw in [0, 1) from Web Crypto, the same source session ids use. */
1419
+ function uniformRandom() {
1420
+ const values = /* @__PURE__ */ new Uint32Array(1);
1421
+ crypto.getRandomValues(values);
1422
+ return (values[0] ?? 0) / 4294967296;
1423
+ }
1424
+ /**
1425
+ * This session's replay sampling decision, rolled against `sampleRate` the
1426
+ * first time it is asked and then persisted, so every page load of the session
1427
+ * agrees. Call after `getSession()` has resolved the session.
1428
+ */
1429
+ function claimReplaySample(sampleRate) {
1430
+ const record = readRecord() ?? getSession();
1431
+ if (record.replaySampled !== void 0) return record.replaySampled;
1432
+ const sampled = uniformRandom() < sampleRate;
1433
+ writeRecord({
1434
+ ...record,
1435
+ replaySampled: sampled
1436
+ });
1437
+ return sampled;
1438
+ }
1439
+ /**
1440
+ * This session's replay mode: recorded at `sampleRate`, otherwise buffered for
1441
+ * errors at `onErrorSampleRate`. Rolled once per session and persisted, like
1442
+ * `claimReplaySample`.
1443
+ */
1444
+ function claimReplayMode(sampleRate, onErrorSampleRate) {
1445
+ if (claimReplaySample(sampleRate)) return "record";
1446
+ const record = readRecord() ?? getSession();
1447
+ if (record.replayBuffered === void 0) {
1448
+ const buffered = onErrorSampleRate > 0 && uniformRandom() < onErrorSampleRate;
1449
+ writeRecord({
1450
+ ...record,
1451
+ replayBuffered: buffered
1452
+ });
1453
+ return buffered ? "buffer" : "off";
1454
+ }
1455
+ return record.replayBuffered ? "buffer" : "off";
1456
+ }
1457
+ /** An error made this buffered session a recorded one: later loads record it from the start. */
1458
+ function markReplayTriggered(sessionId) {
1459
+ const record = readRecord();
1460
+ if (!record || record.id !== sessionId) return;
1461
+ writeRecord({
1462
+ ...record,
1463
+ replaySampled: true,
1464
+ replayTrigger: "error"
1465
+ });
1466
+ }
1467
+ /**
1468
+ * Pin a session to the capture mode a running page is already in. A session
1469
+ * minted by idle rotation mid-page inherits that page's mode rather than
1470
+ * rolling again, and later loads of it honour the same answer.
1471
+ */
1472
+ function adoptReplayDecision(sessionId, recorded, buffered) {
1473
+ const record = readRecord();
1474
+ if (!record || record.id !== sessionId || record.replaySampled !== void 0) return;
1475
+ writeRecord({
1476
+ ...record,
1477
+ replaySampled: recorded,
1478
+ ...buffered !== void 0 && !recorded ? { replayBuffered: buffered } : void 0
1479
+ });
1480
+ }
969
1481
  //#endregion
970
1482
  //#region ../browser-session/src/events/events-sink.ts
1483
+ /** On `globalThis` like the sink: every bundled copy of this module must see the same listeners. */
1484
+ const LISTENERS_KEY = "__MAPLE_SESSION_EVENT_LISTENERS__";
1485
+ function listeners() {
1486
+ const global = globalThis;
1487
+ let set = global[LISTENERS_KEY];
1488
+ if (!set) {
1489
+ set = /* @__PURE__ */ new Set();
1490
+ global[LISTENERS_KEY] = set;
1491
+ }
1492
+ return set;
1493
+ }
1494
+ /** Observe every event the live sink records, e.g. for an error's breadcrumb trail. Returns an unsubscribe. */
1495
+ function onSessionEvent(listener) {
1496
+ listeners().add(listener);
1497
+ return () => listeners().delete(listener);
1498
+ }
971
1499
  const FLUSH_INTERVAL_MS = 5e3;
972
1500
  const FLUSH_BYTES = 65536;
973
1501
  /**
@@ -1014,7 +1542,7 @@ function startEventSink(config, sessionId) {
1014
1542
  const batch = buffer;
1015
1543
  buffer = [];
1016
1544
  bufferBytes = 0;
1017
- await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
1545
+ await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(config, sessionId, ev, seq)), keepalive);
1018
1546
  };
1019
1547
  const emit = (ev) => {
1020
1548
  const session = markActivity();
@@ -1034,6 +1562,9 @@ function startEventSink(config, sessionId) {
1034
1562
  });
1035
1563
  bufferBytes += approximateSize(ev);
1036
1564
  if (bufferBytes >= FLUSH_BYTES) flush();
1565
+ for (const listener of listeners()) try {
1566
+ listener(ev);
1567
+ } catch {}
1037
1568
  };
1038
1569
  const stopNavigation = installNavigationObserver((url) => {
1039
1570
  emit({
@@ -1081,13 +1612,14 @@ function installNavigationObserver(onNavigate) {
1081
1612
  emitNav();
1082
1613
  const origPush = history.pushState;
1083
1614
  const origReplace = history.replaceState;
1615
+ const receiver = (self) => self instanceof History ? self : history;
1084
1616
  history.pushState = function(...args) {
1085
- const result = origPush.apply(this, args);
1617
+ const result = origPush.apply(receiver(this), args);
1086
1618
  emitNav();
1087
1619
  return result;
1088
1620
  };
1089
1621
  history.replaceState = function(...args) {
1090
- const result = origReplace.apply(this, args);
1622
+ const result = origReplace.apply(receiver(this), args);
1091
1623
  emitNav();
1092
1624
  return result;
1093
1625
  };
@@ -1150,21 +1682,33 @@ function drainPending(sink) {
1150
1682
  state.bytes = 0;
1151
1683
  for (const ev of queued) sink.emit(ev);
1152
1684
  }
1153
- /** Map an internal event to the snake_case ingest row (org_id is added server-side). */
1154
- function toRow(sessionId, ev, seq) {
1685
+ /**
1686
+ * Map an internal event to the snake_case ingest row (org_id is added server-side).
1687
+ *
1688
+ * Identity is resolved here, at flush time, alongside the trace id: it is the
1689
+ * person key funnels group on, and reading it late means an `identify()` that
1690
+ * lands shortly after init still stamps the initial page view. `visitor_id` is
1691
+ * `""` whenever the visitor cookie is off (consent, GPC, `persistVisitorId:
1692
+ * false`) — the same rule the metadata row applies.
1693
+ */
1694
+ function toRow(config, sessionId, ev, seq) {
1695
+ const identity = config.getIdentity?.();
1155
1696
  return {
1156
1697
  session_id: sessionId,
1698
+ visitor_id: getVisitorId() ?? "",
1699
+ user_id: identity?.id ?? "",
1700
+ group_id: identity?.groupId ?? "",
1157
1701
  timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
1158
1702
  seq,
1159
1703
  type: ev.type,
1160
- url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
1704
+ url: scrubUrl(ev.url ?? (typeof location !== "undefined" ? location.href : "")),
1161
1705
  trace_id: ev.traceId ?? activeTraceId() ?? "",
1162
1706
  level: ev.level ?? "",
1163
1707
  message: ev.message ?? "",
1164
1708
  target_selector: ev.targetSelector ?? "",
1165
1709
  target_text: ev.targetText ?? "",
1166
1710
  net_method: ev.net?.method ?? "",
1167
- net_url: ev.net?.url ?? "",
1711
+ net_url: ev.net ? scrubUrl(ev.net.url) : "",
1168
1712
  net_status: ev.net?.status ?? 0,
1169
1713
  net_duration_ms: ev.net?.durationMs ?? 0,
1170
1714
  error_stack: ev.errorStack ?? "",
@@ -1181,7 +1725,7 @@ function toRow(sessionId, ev, seq) {
1181
1725
  * posted at session start, whose counters are all zero and therefore read as a
1182
1726
  * bounce. 60s is the floor worth using: the table is a ReplacingMergeTree, so
1183
1727
  * every heartbeat is an unmerged part until the next merge. Billing meters only
1184
- * `version == 1` rows, so heartbeats do not double-bill.
1728
+ * `billable_start == 1 && version == 1` rows, so heartbeats do not double-bill.
1185
1729
  */
1186
1730
  const HEARTBEAT_INTERVAL_MS = 6e4;
1187
1731
  /**
@@ -1199,6 +1743,7 @@ function startSessionLifecycle(options, hooks) {
1199
1743
  let errorCountBase = 0;
1200
1744
  let sinkClickCountAtStart = 0;
1201
1745
  let sinkErrorCountAtStart = 0;
1746
+ const isRecorded = () => typeof hooks.recorded === "function" ? hooks.recorded() : hooks.recorded;
1202
1747
  /**
1203
1748
  * The persisted record of the session this lifecycle owns.
1204
1749
  *
@@ -1247,6 +1792,10 @@ function startSessionLifecycle(options, hooks) {
1247
1792
  const post = (status, keepalive) => {
1248
1793
  const record = liveRecord();
1249
1794
  const counts = countsFor(record);
1795
+ const visitorId = getVisitorId();
1796
+ const persistVisit = visitorId !== void 0;
1797
+ const billableStart = resolveBillable(record.id, () => claimVisit(Date.now(), persistVisit));
1798
+ touchVisit(record.lastActivityAt, persistVisit);
1250
1799
  hooks.post(buildSessionMetaRow({
1251
1800
  sessionId: record.id,
1252
1801
  startedAt: new Date(record.startedAt),
@@ -1257,7 +1806,7 @@ function startSessionLifecycle(options, hooks) {
1257
1806
  captureUserEmail: options.captureUserEmail,
1258
1807
  environment: options.environment,
1259
1808
  serviceVersion: options.serviceVersion,
1260
- visitorId: getVisitorId(),
1809
+ visitorId,
1261
1810
  visitorIsNew: record.visitorIsNew === true,
1262
1811
  visitorIdPersisted: isVisitorIdPersisted(),
1263
1812
  entry: entryContextOf(record),
@@ -1266,7 +1815,9 @@ function startSessionLifecycle(options, hooks) {
1266
1815
  pageViews: counts.pageViews,
1267
1816
  errorCount: counts.errorCount,
1268
1817
  traceIds: status === "ended" ? options.getTraceIds?.(record.id) : void 0,
1269
- recorded: hooks.recorded
1818
+ recorded: isRecorded(),
1819
+ replayTrigger: record.replayTrigger,
1820
+ billableStart
1270
1821
  }), keepalive);
1271
1822
  };
1272
1823
  const stopHeartbeat = () => {
@@ -1278,6 +1829,7 @@ function startSessionLifecycle(options, hooks) {
1278
1829
  if (stopped || running) return;
1279
1830
  running = true;
1280
1831
  const record = liveRecord();
1832
+ adoptReplayDecision(record.id, isRecorded(), hooks.buffered?.());
1281
1833
  rebaseCounts(record);
1282
1834
  hooks.onStart?.(record);
1283
1835
  post("active", false);
@@ -1339,6 +1891,9 @@ function startSessionLifecycle(options, hooks) {
1339
1891
  get sessionId() {
1340
1892
  return current.id;
1341
1893
  },
1894
+ announce: () => {
1895
+ if (running) post("active", false);
1896
+ },
1342
1897
  shutdown: async (shutdownOptions) => {
1343
1898
  if (stopped) return;
1344
1899
  stopped = true;
@@ -1374,6 +1929,7 @@ const observedTraceIdsBySession = /* @__PURE__ */ new Map();
1374
1929
  const MAX_TRACE_IDS_PER_SESSION = 200;
1375
1930
  /** Record a trace id seen during the session. Idempotent per id. */
1376
1931
  function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
1932
+ noteStartedTraceId(traceId);
1377
1933
  if (!sessionId) return;
1378
1934
  let ids = observedTraceIdsBySession.get(sessionId);
1379
1935
  if (!ids) {
@@ -1421,4 +1977,4 @@ function readSessionSink() {
1421
1977
  return globalThis[SESSION_SINK_KEY];
1422
1978
  }
1423
1979
  //#endregion
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 };
1980
+ export { setActiveTraceIdProvider as A, postSessionMeta as C, BLOCK_SELECTOR as D, selectorOf as E, scrubUrl as F, configureVisitorCookie as M, setVisitorTracking as N, safeEmit as O, addUrlSanitizer as P, postSessionBlob as S, warnDropped as T, rotateSession as _, recordTraceId as a, gzip as b, getActiveSink as c, startEventSink as d, claimReplayMode as f, nextChunkSeq as g, markReplayTriggered as h, readSessionSink as i, withStartedTraceId as j, activeTraceId as k, onSessionEvent as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, queuePending as u, postSessionMetaRow as v, sdkHint as w, ingestHeaders as x, isLikelyBot as y };