@maple-dev/browser 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,87 @@
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
1
85
  //#region ../browser-session/src/platform/json.ts
2
86
  /**
3
87
  * Minimal structural guards for records read back out of browser storage.
@@ -316,6 +400,26 @@ function activeTraceId() {
316
400
  const id = traceIdProvider();
317
401
  return id && id !== ZERO_TRACE_ID ? id : void 0;
318
402
  }
403
+ const slots = [];
404
+ /** Report a span that just started. Fills the innermost open slot, first span wins. */
405
+ function noteStartedTraceId(traceId) {
406
+ const slot = slots.at(-1);
407
+ if (slot && slot.traceId === void 0 && traceId !== ZERO_TRACE_ID) slot.traceId = traceId;
408
+ }
409
+ /** Run `fn`, returning its result and the trace id of the first span started during it. */
410
+ function withStartedTraceId(fn) {
411
+ const slot = { traceId: void 0 };
412
+ slots.push(slot);
413
+ try {
414
+ return {
415
+ result: fn(),
416
+ traceId: slot.traceId
417
+ };
418
+ } finally {
419
+ const index = slots.lastIndexOf(slot);
420
+ if (index !== -1) slots.splice(index, 1);
421
+ }
422
+ }
319
423
  //#endregion
320
424
  //#region ../browser-session/src/capture/shared.ts
321
425
  /** Emit best-effort: capture must never throw into the host app's call site. */
@@ -359,6 +463,21 @@ function truncate(stack) {
359
463
  if (!stack) return void 0;
360
464
  return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
361
465
  }
466
+ const BLOCK_SELECTOR = `[data-rr-block]`;
467
+ /**
468
+ * Whether `el` sits inside a blocked subtree. Mirrors rrweb's own ancestor
469
+ * check so the two capture paths agree on what is hidden.
470
+ *
471
+ * Walks parents rather than using `closest()`: no string reaches a selector
472
+ * parser, so a malformed marker cannot throw into the host app's event handler.
473
+ */
474
+ const isBlocked = (el) => {
475
+ for (let node = el; node !== null; node = node.parentElement) {
476
+ if (node.classList?.contains("rr-block")) return true;
477
+ if (node.hasAttribute?.("data-rr-block")) return true;
478
+ }
479
+ return false;
480
+ };
362
481
  //#endregion
363
482
  //#region ../browser-session/src/capture/interactions.ts
364
483
  const MAX_TEXT = 120;
@@ -366,7 +485,8 @@ const MAX_TEXT = 120;
366
485
  * Capture clicks and input events as session events. Listens in the capture
367
486
  * phase so it sees interactions even when the host app calls
368
487
  * `stopPropagation()`. Input *values* are never recorded; only the target
369
- * element. Click target text is omitted when `maskAllText` is set.
488
+ * element. Click target text is omitted when `maskAllText` is set, and when the
489
+ * target sits inside a blocked (`.rr-block` / `data-rr-block`) subtree.
370
490
  */
371
491
  function installInteractionCapture(emit, maskAllText) {
372
492
  const onClick = (event) => {
@@ -375,7 +495,7 @@ function installInteractionCapture(emit, maskAllText) {
375
495
  safeEmit(emit, {
376
496
  type: "click",
377
497
  targetSelector: selectorOf(target),
378
- targetText: maskAllText ? void 0 : textOf(target)
498
+ targetText: maskAllText || isBlocked(target) ? void 0 : textOf(target)
379
499
  });
380
500
  };
381
501
  const onInput = (event) => {
@@ -463,40 +583,107 @@ function warnDropped(what, error) {
463
583
  console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
464
584
  }
465
585
  /**
466
- * Largest body we will hand to a `keepalive` fetch.
586
+ * Combined budget for this SDK's in-flight `keepalive` bodies.
587
+ *
588
+ * The Fetch spec caps the *combined* in-flight keepalive body at 64 KiB per
589
+ * page, and the browser rejects a request that would cross it. On the way out
590
+ * up to three of our writes go at once (metadata row, final events batch, last
591
+ * replay chunk), so the budget has to be shared rather than checked per
592
+ * request. It stops short of 64 KiB because the OTLP trace exporter spends
593
+ * from the same page-wide allowance under its own accounting.
467
594
  *
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.
595
+ * Over the budget a write goes out as a normal request, which the page may or
596
+ * may not survive long enough to finish: strictly better than a guaranteed
597
+ * rejection.
473
598
  */
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;
599
+ const KEEPALIVE_BUDGET_BYTES = 49152;
600
+ /** On `globalThis`: two bundled SDK copies still share one page-wide allowance. */
601
+ const KEEPALIVE_KEY = "__MAPLE_KEEPALIVE_INFLIGHT__";
602
+ function keepaliveInflight() {
603
+ const owner = globalThis;
604
+ const existing = owner[KEEPALIVE_KEY];
605
+ if (existing) return existing;
606
+ const fresh = { bytes: 0 };
607
+ owner[KEEPALIVE_KEY] = fresh;
608
+ return fresh;
609
+ }
610
+ /**
611
+ * Reserve `bytes` of the shared keepalive budget. Returns the release function
612
+ * when the request may use `keepalive`, `undefined` when it must not.
613
+ */
614
+ function reserveKeepalive(requested, bytes) {
615
+ if (!requested) return void 0;
616
+ const inflight = keepaliveInflight();
617
+ if (inflight.bytes + bytes > KEEPALIVE_BUDGET_BYTES) return void 0;
618
+ inflight.bytes += bytes;
619
+ let released = false;
620
+ return () => {
621
+ if (released) return;
622
+ released = true;
623
+ inflight.bytes = Math.max(0, inflight.bytes - bytes);
624
+ };
625
+ }
626
+ /** Body size in bytes: the keepalive limit counts bytes, and `string.length` counts UTF-16 units. */
627
+ function byteLength(body) {
628
+ return typeof body === "string" ? new TextEncoder().encode(body).byteLength : body.byteLength;
629
+ }
630
+ /**
631
+ * POST to ingest, spending the shared keepalive budget when `keepalive` is
632
+ * requested. Resolves with the status only: the response body is cancelled
633
+ * before the reservation is released, because the browser counts a keepalive
634
+ * request against the page-wide limit until its response body ends, not
635
+ * until headers arrive. Rejects exactly as `fetch` does; callers own the
636
+ * error policy.
637
+ */
638
+ async function postToIngest(url, headers, body, keepalive) {
639
+ const release = reserveKeepalive(keepalive, byteLength(body));
640
+ try {
641
+ const response = await fetch(url, {
642
+ method: "POST",
643
+ headers,
644
+ body,
645
+ keepalive: release !== void 0
646
+ });
647
+ await response.body?.cancel().catch(() => {});
648
+ return {
649
+ ok: response.ok,
650
+ status: response.status
651
+ };
652
+ } finally {
653
+ release?.();
654
+ }
478
655
  }
479
- /** gzip a byte buffer using the native CompressionStream (no library). */
656
+ /**
657
+ * Smallest well-formed gzip stream: a 10-byte header plus an 8-byte trailer.
658
+ * Anything shorter cannot carry a complete member, whatever it contains.
659
+ */
660
+ const GZIP_MIN_BYTES = 18;
661
+ /**
662
+ * gzip a byte buffer using the native CompressionStream (no library).
663
+ *
664
+ * Throws rather than returning a partial stream. The write and close promises
665
+ * used to be discarded, so a page torn down mid-compression still resolved
666
+ * `arrayBuffer()` — with only the bytes already flushed — and the caller posted
667
+ * a 3-byte header stub as if it were a complete chunk. Ingest rejected those as
668
+ * corrupt gzip; they were 82% of all replay-blob failures, overwhelmingly from
669
+ * crawlers, which tear the page down on nearly every session.
670
+ */
480
671
  async function gzip(bytes) {
481
672
  const stream = new CompressionStream("gzip");
482
673
  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);
674
+ const written = writer.write(bytes).then(() => writer.close());
675
+ const [buffer] = await Promise.all([new Response(stream.readable).arrayBuffer(), written]);
676
+ const out = new Uint8Array(buffer);
677
+ 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`);
678
+ return out;
487
679
  }
488
680
  /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
489
681
  async function postSessionMeta(config, row, keepalive = false) {
490
682
  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) => {
683
+ await postToIngest(`${config.endpoint}/v1/sessionReplays/meta`, {
684
+ ...ingestHeaders(config),
685
+ "content-type": "application/x-ndjson"
686
+ }, body, keepalive).catch((error) => {
500
687
  warnDropped("metadata POST", error);
501
688
  });
502
689
  }
@@ -504,15 +691,10 @@ async function postSessionMeta(config, row, keepalive = false) {
504
691
  async function postSessionEvents(config, rows, keepalive = false) {
505
692
  if (rows.length === 0) return;
506
693
  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) => {
694
+ await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
695
+ ...ingestHeaders(config),
696
+ "content-type": "application/x-ndjson"
697
+ }, body, keepalive).catch((error) => {
516
698
  warnDropped("events POST", error);
517
699
  });
518
700
  }
@@ -520,20 +702,15 @@ const SESSION_EXHAUSTED_STATUS = 413;
520
702
  /** POST a gzipped rrweb event chunk. */
521
703
  async function postSessionBlob(config, meta, gzipped, keepalive = false) {
522
704
  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
- });
705
+ const response = await postToIngest(`${config.endpoint}/v1/sessionReplays/blob`, {
706
+ ...ingestHeaders(config),
707
+ "content-type": "application/octet-stream",
708
+ "x-maple-session-id": meta.sessionId,
709
+ "x-maple-chunk-seq": String(meta.chunkSeq),
710
+ "x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
711
+ "x-maple-event-count": String(meta.eventCount),
712
+ "x-maple-duration-ms": String(meta.durationMs)
713
+ }, gzipped, keepalive);
537
714
  if (response.ok) return "accepted";
538
715
  return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
539
716
  } catch (error) {
@@ -558,9 +735,51 @@ function parse(ua) {
558
735
  return {
559
736
  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
737
  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"
738
+ deviceType: /mobile|iphone/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
562
739
  };
563
740
  }
741
+ /**
742
+ * Crawler tokens, every one carrying a delimiter or a scheme on purpose.
743
+ *
744
+ * A bare `bot` substring matches `CUBOT`, an Android phone brand that appears in
745
+ * ordinary mobile UAs, and would file those visitors as crawlers. Requiring
746
+ * `bot/`, `bot;` or `bot)` keeps the `Name/version` and `(compatible; Name;
747
+ * +url)` conventions real crawlers follow while missing the phone. This mirrors
748
+ * the generic tail of the server-side classifier in
749
+ * `@maple/query-engine`'s `user-agent.ts`, deliberately without the ~50 named
750
+ * signatures: every one of those ends in a token already listed here, and the
751
+ * list ships in a size-budgeted bundle to customers.
752
+ */
753
+ const BOT_TOKENS = [
754
+ "bot/",
755
+ "bot;",
756
+ "bot)",
757
+ "crawler",
758
+ "spider",
759
+ "+http",
760
+ "headless"
761
+ ];
762
+ /**
763
+ * Whether the UA is a crawler rather than a person.
764
+ *
765
+ * Used to route crawlers to a metadata-only session: they still appear in Web
766
+ * Analytics — the server-side classifier is what labels them there — but they do
767
+ * not download rrweb or upload replay chunks. Crawlers execute the beacon and
768
+ * then tear the page down mid-flush, which produced the large majority of all
769
+ * corrupt replay-chunk uploads, and every chunk they did manage to upload spent
770
+ * a customer's replay quota on a session nobody will ever watch.
771
+ *
772
+ * Deliberately does not consult `navigator.webdriver`: it is set by every
773
+ * Playwright and Puppeteer run, headed or not. The `headless` token does match
774
+ * the old headless shell's `HeadlessChrome` UA, which production crawlers use
775
+ * too (see the test cases), so an e2e suite that wants replay recorded runs
776
+ * headed, or sets an explicit `userAgent` without `headless` in it. Chrome's
777
+ * new headless mode can still report `HeadlessChrome/`, so it is not enough.
778
+ */
779
+ function isLikelyBot(ua) {
780
+ const lower = ua.toLowerCase();
781
+ return BOT_TOKENS.some((token) => lower.includes(token));
782
+ }
564
783
  //#endregion
565
784
  //#region ../browser-session/src/events/meta-row.ts
566
785
  /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
@@ -580,8 +799,8 @@ function buildSessionMetaRow(input) {
580
799
  const now = /* @__PURE__ */ new Date();
581
800
  const location = browserLocation();
582
801
  const identity = input.identity;
583
- const entryUrl = input.entry?.entryUrl ?? location?.href ?? "";
584
- const referrer = input.entry?.referrer ?? "";
802
+ const entryUrl = scrubUrl(input.entry?.entryUrl || location?.href || "");
803
+ const referrer = scrubUrl(input.entry?.referrer ?? "");
585
804
  const utm = input.entry?.utm ?? {};
586
805
  const row = {
587
806
  session_id: input.sessionId,
@@ -589,7 +808,7 @@ function buildSessionMetaRow(input) {
589
808
  status: input.status,
590
809
  version: input.version,
591
810
  user_id: identity?.id ?? input.userId ?? "",
592
- url_initial: location?.href ?? "",
811
+ url_initial: entryUrl,
593
812
  user_agent: userAgent,
594
813
  browser_name: ua.browserName,
595
814
  os_name: ua.osName,
@@ -603,7 +822,7 @@ function buildSessionMetaRow(input) {
603
822
  "deployment.environment": input.environment,
604
823
  "deployment.environment.name": input.environment
605
824
  } : void 0,
606
- ...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : void 0
825
+ ...input.serviceVersion && /^[0-9a-f]{7,40}$/i.test(input.serviceVersion) ? { "vcs.ref.head.revision": input.serviceVersion } : void 0
607
826
  },
608
827
  visitor_id: input.visitorId ?? "",
609
828
  visitor_is_new: input.visitorIsNew ? 1 : 0,
@@ -652,15 +871,10 @@ function pathOf(url) {
652
871
  /** POST one session metadata row (NDJSON). Best-effort — never throws. */
653
872
  async function postSessionMetaRow(target, row, keepalive = false) {
654
873
  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(() => {});
874
+ await postToIngest(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
875
+ ...ingestHeaders(target),
876
+ "content-type": "application/x-ndjson"
877
+ }, body, keepalive).catch(() => {});
664
878
  }
665
879
  //#endregion
666
880
  //#region ../browser-session/src/platform/approximate-size.ts
@@ -735,6 +949,10 @@ function parseSessionRecord(raw) {
735
949
  if (typeof value.visitorIsNew !== "boolean") return void 0;
736
950
  record.visitorIsNew = value.visitorIsNew;
737
951
  }
952
+ if (value.replaySampled !== void 0) {
953
+ if (typeof value.replaySampled !== "boolean") return void 0;
954
+ record.replaySampled = value.replaySampled;
955
+ }
738
956
  if (value.utm !== void 0) {
739
957
  if (!isStringRecord(value.utm)) return void 0;
740
958
  record.utm = value.utm;
@@ -768,11 +986,12 @@ function readEntryContext() {
768
986
  if (value) utm[key] = value.slice(0, 128);
769
987
  }
770
988
  } catch {}
989
+ const href = scrubUrl(location.href);
771
990
  return {
772
- entryUrl: location.href,
773
- entryReferrer: typeof document !== "undefined" ? document.referrer : "",
991
+ entryUrl: href,
992
+ entryReferrer: typeof document !== "undefined" ? scrubUrl(document.referrer) : "",
774
993
  utm,
775
- lastUrl: location.href,
994
+ lastUrl: href,
776
995
  pageViews: 0,
777
996
  clickCount: 0,
778
997
  errorCount: 0
@@ -803,6 +1022,105 @@ function writeRecord(record) {
803
1022
  try {
804
1023
  window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
805
1024
  } catch {}
1025
+ claimTab(record.id);
1026
+ }
1027
+ const TAB_LEASE_PREFIX = "maple.session.tab.";
1028
+ const TAB_LEASE_TTL_MS = 12e4;
1029
+ const TAB_LEASE_REFRESH_MS = 5e3;
1030
+ const TAB_NONCE_KEY = "__MAPLE_TAB_NONCE__";
1031
+ /** The session id this tab has checked (or minted) and may keep using. */
1032
+ let verifiedSessionId;
1033
+ let leaseWrittenAt = 0;
1034
+ let releaseInstalled = false;
1035
+ /**
1036
+ * Set from `pagehide` until the page is shown again. The lifecycle's own
1037
+ * `pagehide` handler posts an `ended` row after ours runs, which writes the
1038
+ * record; re-leasing there would make the next load of this tab look like a
1039
+ * duplicate and rotate on every reload.
1040
+ */
1041
+ let leaseReleased = false;
1042
+ function tabNonce() {
1043
+ const owner = globalThis;
1044
+ const existing = owner[TAB_NONCE_KEY];
1045
+ if (typeof existing === "string") return existing;
1046
+ const fresh = crypto.randomUUID();
1047
+ owner[TAB_NONCE_KEY] = fresh;
1048
+ return fresh;
1049
+ }
1050
+ function leaseStorage() {
1051
+ try {
1052
+ return typeof window !== "undefined" ? window.localStorage : void 0;
1053
+ } catch {
1054
+ return;
1055
+ }
1056
+ }
1057
+ function readLease(storage, sessionId) {
1058
+ const raw = storage.getItem(`${TAB_LEASE_PREFIX}${sessionId}`);
1059
+ if (!raw) return void 0;
1060
+ const split = raw.lastIndexOf(":");
1061
+ const at = Number(raw.slice(split + 1));
1062
+ return split > 0 && Number.isFinite(at) ? {
1063
+ nonce: raw.slice(0, split),
1064
+ at
1065
+ } : void 0;
1066
+ }
1067
+ function claimTab(sessionId) {
1068
+ const now = Date.now();
1069
+ if (verifiedSessionId === sessionId && now - leaseWrittenAt < TAB_LEASE_REFRESH_MS) return;
1070
+ if (verifiedSessionId !== void 0 && verifiedSessionId !== sessionId) releaseTab(verifiedSessionId);
1071
+ verifiedSessionId = sessionId;
1072
+ if (leaseReleased) return;
1073
+ leaseWrittenAt = now;
1074
+ const storage = leaseStorage();
1075
+ if (!storage) return;
1076
+ try {
1077
+ storage.setItem(`${TAB_LEASE_PREFIX}${sessionId}`, `${tabNonce()}:${now}`);
1078
+ } catch {}
1079
+ installLeaseRelease();
1080
+ }
1081
+ function releaseTab(sessionId) {
1082
+ const storage = leaseStorage();
1083
+ if (!storage) return;
1084
+ try {
1085
+ if (readLease(storage, sessionId)?.nonce === tabNonce()) storage.removeItem(`${TAB_LEASE_PREFIX}${sessionId}`);
1086
+ } catch {}
1087
+ }
1088
+ function installLeaseRelease() {
1089
+ if (releaseInstalled || typeof globalThis.addEventListener !== "function") return;
1090
+ releaseInstalled = true;
1091
+ globalThis.addEventListener("pagehide", () => {
1092
+ leaseReleased = true;
1093
+ if (verifiedSessionId !== void 0) releaseTab(verifiedSessionId);
1094
+ });
1095
+ globalThis.addEventListener("pageshow", () => {
1096
+ leaseReleased = false;
1097
+ leaseWrittenAt = 0;
1098
+ });
1099
+ }
1100
+ /** Drop leases a crashed tab never released. */
1101
+ function pruneLeases(storage, now) {
1102
+ for (let i = storage.length - 1; i >= 0; i--) {
1103
+ const key = storage.key(i);
1104
+ if (!key?.startsWith(TAB_LEASE_PREFIX)) continue;
1105
+ const lease = readLease(storage, key.slice(18));
1106
+ if (!lease || now - lease.at > TAB_LEASE_TTL_MS) storage.removeItem(key);
1107
+ }
1108
+ }
1109
+ /**
1110
+ * Whether another live tab holds `record`'s session. Checked once per session
1111
+ * id per tab; after that this tab has claimed it.
1112
+ */
1113
+ function ownedByAnotherTab(record, now) {
1114
+ if (verifiedSessionId === record.id) return false;
1115
+ const storage = leaseStorage();
1116
+ if (!storage) return false;
1117
+ try {
1118
+ pruneLeases(storage, now);
1119
+ const lease = readLease(storage, record.id);
1120
+ return lease !== void 0 && lease.nonce !== tabNonce() && now - lease.at <= TAB_LEASE_TTL_MS;
1121
+ } catch {
1122
+ return false;
1123
+ }
806
1124
  }
807
1125
  function isSessionExpired(record, now = Date.now()) {
808
1126
  return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
@@ -839,7 +1157,7 @@ function installRotatedRecord(previous, next) {
839
1157
  function getSession() {
840
1158
  const now = Date.now();
841
1159
  const existing = readRecord();
842
- if (existing && !isSessionExpired(existing, now)) {
1160
+ if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
843
1161
  const record = {
844
1162
  ...migrateRecord(existing),
845
1163
  lastActivityAt: now
@@ -867,13 +1185,13 @@ function rotateSession() {
867
1185
  */
868
1186
  function touchSession(now) {
869
1187
  const existing = readRecord();
870
- if (existing && !isSessionExpired(existing, now)) {
1188
+ if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
871
1189
  const migrated = migrateRecord(existing);
872
1190
  const touched = {
873
1191
  ...migrated,
874
1192
  lastActivityAt: now
875
1193
  };
876
- if (migrated !== existing || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
1194
+ if (migrated !== existing || verifiedSessionId !== existing.id || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
877
1195
  return touched;
878
1196
  }
879
1197
  const record = freshRecord(now);
@@ -900,7 +1218,7 @@ function noteNavigation(url) {
900
1218
  const record = touchSession(now);
901
1219
  writeRecord({
902
1220
  ...record,
903
- lastUrl: url,
1221
+ lastUrl: scrubUrl(url),
904
1222
  pageViews: (record.pageViews ?? 0) + 1,
905
1223
  lastActivityAt: now
906
1224
  });
@@ -966,6 +1284,40 @@ function nextMetaVersion() {
966
1284
  });
967
1285
  return version;
968
1286
  }
1287
+ /** A uniform draw in [0, 1) from Web Crypto, the same source session ids use. */
1288
+ function uniformRandom() {
1289
+ const values = /* @__PURE__ */ new Uint32Array(1);
1290
+ crypto.getRandomValues(values);
1291
+ return (values[0] ?? 0) / 4294967296;
1292
+ }
1293
+ /**
1294
+ * This session's replay sampling decision, rolled against `sampleRate` the
1295
+ * first time it is asked and then persisted, so every page load of the session
1296
+ * agrees. Call after `getSession()` has resolved the session.
1297
+ */
1298
+ function claimReplaySample(sampleRate) {
1299
+ const record = readRecord() ?? getSession();
1300
+ if (record.replaySampled !== void 0) return record.replaySampled;
1301
+ const sampled = uniformRandom() < sampleRate;
1302
+ writeRecord({
1303
+ ...record,
1304
+ replaySampled: sampled
1305
+ });
1306
+ return sampled;
1307
+ }
1308
+ /**
1309
+ * Pin a session to the capture mode a running page is already in. A session
1310
+ * minted by idle rotation mid-page inherits that page's mode rather than
1311
+ * rolling again, and later loads of it honour the same answer.
1312
+ */
1313
+ function adoptReplayDecision(sessionId, recorded) {
1314
+ const record = readRecord();
1315
+ if (!record || record.id !== sessionId || record.replaySampled !== void 0) return;
1316
+ writeRecord({
1317
+ ...record,
1318
+ replaySampled: recorded
1319
+ });
1320
+ }
969
1321
  //#endregion
970
1322
  //#region ../browser-session/src/events/events-sink.ts
971
1323
  const FLUSH_INTERVAL_MS = 5e3;
@@ -1014,7 +1366,7 @@ function startEventSink(config, sessionId) {
1014
1366
  const batch = buffer;
1015
1367
  buffer = [];
1016
1368
  bufferBytes = 0;
1017
- await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
1369
+ await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(config, sessionId, ev, seq)), keepalive);
1018
1370
  };
1019
1371
  const emit = (ev) => {
1020
1372
  const session = markActivity();
@@ -1081,13 +1433,14 @@ function installNavigationObserver(onNavigate) {
1081
1433
  emitNav();
1082
1434
  const origPush = history.pushState;
1083
1435
  const origReplace = history.replaceState;
1436
+ const receiver = (self) => self instanceof History ? self : history;
1084
1437
  history.pushState = function(...args) {
1085
- const result = origPush.apply(this, args);
1438
+ const result = origPush.apply(receiver(this), args);
1086
1439
  emitNav();
1087
1440
  return result;
1088
1441
  };
1089
1442
  history.replaceState = function(...args) {
1090
- const result = origReplace.apply(this, args);
1443
+ const result = origReplace.apply(receiver(this), args);
1091
1444
  emitNav();
1092
1445
  return result;
1093
1446
  };
@@ -1150,21 +1503,33 @@ function drainPending(sink) {
1150
1503
  state.bytes = 0;
1151
1504
  for (const ev of queued) sink.emit(ev);
1152
1505
  }
1153
- /** Map an internal event to the snake_case ingest row (org_id is added server-side). */
1154
- function toRow(sessionId, ev, seq) {
1506
+ /**
1507
+ * Map an internal event to the snake_case ingest row (org_id is added server-side).
1508
+ *
1509
+ * Identity is resolved here, at flush time, alongside the trace id: it is the
1510
+ * person key funnels group on, and reading it late means an `identify()` that
1511
+ * lands shortly after init still stamps the initial page view. `visitor_id` is
1512
+ * `""` whenever the visitor cookie is off (consent, GPC, `persistVisitorId:
1513
+ * false`) — the same rule the metadata row applies.
1514
+ */
1515
+ function toRow(config, sessionId, ev, seq) {
1516
+ const identity = config.getIdentity?.();
1155
1517
  return {
1156
1518
  session_id: sessionId,
1519
+ visitor_id: getVisitorId() ?? "",
1520
+ user_id: identity?.id ?? "",
1521
+ group_id: identity?.groupId ?? "",
1157
1522
  timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
1158
1523
  seq,
1159
1524
  type: ev.type,
1160
- url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
1525
+ url: scrubUrl(ev.url ?? (typeof location !== "undefined" ? location.href : "")),
1161
1526
  trace_id: ev.traceId ?? activeTraceId() ?? "",
1162
1527
  level: ev.level ?? "",
1163
1528
  message: ev.message ?? "",
1164
1529
  target_selector: ev.targetSelector ?? "",
1165
1530
  target_text: ev.targetText ?? "",
1166
1531
  net_method: ev.net?.method ?? "",
1167
- net_url: ev.net?.url ?? "",
1532
+ net_url: ev.net ? scrubUrl(ev.net.url) : "",
1168
1533
  net_status: ev.net?.status ?? 0,
1169
1534
  net_duration_ms: ev.net?.durationMs ?? 0,
1170
1535
  error_stack: ev.errorStack ?? "",
@@ -1278,6 +1643,7 @@ function startSessionLifecycle(options, hooks) {
1278
1643
  if (stopped || running) return;
1279
1644
  running = true;
1280
1645
  const record = liveRecord();
1646
+ adoptReplayDecision(record.id, hooks.recorded);
1281
1647
  rebaseCounts(record);
1282
1648
  hooks.onStart?.(record);
1283
1649
  post("active", false);
@@ -1374,6 +1740,7 @@ const observedTraceIdsBySession = /* @__PURE__ */ new Map();
1374
1740
  const MAX_TRACE_IDS_PER_SESSION = 200;
1375
1741
  /** Record a trace id seen during the session. Idempotent per id. */
1376
1742
  function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
1743
+ noteStartedTraceId(traceId);
1377
1744
  if (!sessionId) return;
1378
1745
  let ids = observedTraceIdsBySession.get(sessionId);
1379
1746
  if (!ids) {
@@ -1421,4 +1788,4 @@ function readSessionSink() {
1421
1788
  return globalThis[SESSION_SINK_KEY];
1422
1789
  }
1423
1790
  //#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 };
1791
+ export { setVisitorTracking as A, warnDropped as C, setActiveTraceIdProvider as D, activeTraceId as E, scrubUrl as M, withStartedTraceId as O, sdkHint as S, safeEmit as T, isLikelyBot as _, recordTraceId as a, postSessionBlob as b, getActiveSink as c, claimReplaySample as d, getSession as f, postSessionMetaRow as g, rotateSession as h, readSessionSink as i, addUrlSanitizer as j, configureVisitorCookie as k, queuePending as l, nextChunkSeq as m, getObservedTraceIds as n, startSessionLifecycle as o, markActivity as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, startEventSink as u, SDK_HINT_HEADER as v, BLOCK_SELECTOR as w, postSessionMeta as x, gzip as y };