@maple-dev/browser 0.4.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,132 @@
1
- //#region ../browser-session/src/visitor.ts
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/json.ts
86
+ /**
87
+ * Minimal structural guards for records read back out of browser storage.
88
+ *
89
+ * These replace what was an `effect/Schema` decoder. This package is *bundled*
90
+ * into `@maple-dev/browser`, whose eager chunk every visitor downloads before
91
+ * any sampling decision runs — and pulling Schema in to validate two flat
92
+ * records cost ~30 kB gzipped, most of that SDK's entire page-load budget, for
93
+ * shapes the callers already declare in TypeScript. Schema earns its size where
94
+ * the input is genuinely unknown and the errors need to be legible; here the
95
+ * only question is "did we write this, and is it still the shape we write",
96
+ * and the only answer anyone acts on is yes/no.
97
+ *
98
+ * The semantics deliberately match what the Schema decoders did, because the
99
+ * records in the wild were written by them:
100
+ *
101
+ * - unknown keys are dropped rather than rejected (callers rebuild an explicit
102
+ * object from the keys they know),
103
+ * - a required key that is absent or wrongly typed rejects the whole record,
104
+ * - an optional key must be either absent or correctly typed — `null` is not
105
+ * an accepted stand-in for absent, matching `Schema.optionalKey`.
106
+ */
107
+ /** A non-null, non-array object — the only JSON shape these records take. */
108
+ function isJsonObject(value) {
109
+ return typeof value === "object" && value !== null && !Array.isArray(value);
110
+ }
111
+ /**
112
+ * Parse a storage string into a plain object, or `undefined` if it is not
113
+ * valid JSON or not an object. Never throws: every caller here treats a
114
+ * corrupt record as a cache miss, not as an error worth surfacing.
115
+ */
116
+ function parseJsonObject(raw) {
117
+ try {
118
+ const parsed = JSON.parse(raw);
119
+ return isJsonObject(parsed) ? parsed : void 0;
120
+ } catch {
121
+ return;
122
+ }
123
+ }
124
+ /** A `Record<string, string>` — every value a string, as `Schema.Record` required. */
125
+ function isStringRecord(value) {
126
+ return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
127
+ }
128
+ //#endregion
129
+ //#region ../browser-session/src/identity/visitor.ts
2
130
  /**
3
131
  * A persistent per-browser visitor id.
4
132
  *
@@ -147,14 +275,15 @@ function cookieDomain() {
147
275
  }
148
276
  function parseRecord(raw) {
149
277
  if (!raw) return void 0;
150
- try {
151
- const parsed = JSON.parse(raw);
152
- if (typeof parsed.id !== "string" || typeof parsed.mintedAt !== "number") return void 0;
153
- if (Date.now() - parsed.mintedAt > MAX_AGE_MS) return void 0;
154
- return parsed;
155
- } catch {
156
- return;
157
- }
278
+ const value = parseJsonObject(raw);
279
+ if (!value) return void 0;
280
+ const { id, mintedAt } = value;
281
+ if (typeof id !== "string" || typeof mintedAt !== "number") return void 0;
282
+ if (Date.now() - mintedAt > MAX_AGE_MS) return void 0;
283
+ return {
284
+ id,
285
+ mintedAt
286
+ };
158
287
  }
159
288
  function readFromStorage() {
160
289
  try {
@@ -259,7 +388,338 @@ function setVisitorTracking(nextEnabled) {
259
388
  if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
260
389
  }
261
390
  //#endregion
262
- //#region ../browser-session/src/user-agent.ts
391
+ //#region ../browser-session/src/events/trace-id.ts
392
+ const ZERO_TRACE_ID = "00000000000000000000000000000000";
393
+ let traceIdProvider = () => void 0;
394
+ /** Wire the host SDK's active-trace-id lookup into event capture. */
395
+ function setActiveTraceIdProvider(provider) {
396
+ traceIdProvider = provider;
397
+ }
398
+ /** The trace id of the active span, or undefined when none is active. */
399
+ function activeTraceId() {
400
+ const id = traceIdProvider();
401
+ return id && id !== ZERO_TRACE_ID ? id : void 0;
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
+ }
423
+ //#endregion
424
+ //#region ../browser-session/src/capture/shared.ts
425
+ /** Emit best-effort: capture must never throw into the host app's call site. */
426
+ function safeEmit(emit, ev) {
427
+ try {
428
+ emit(ev);
429
+ } catch {}
430
+ }
431
+ //#endregion
432
+ //#region ../browser-session/src/capture/errors.ts
433
+ const MAX_STACK = 4e3;
434
+ /** Capture uncaught errors + unhandled promise rejections as session events. */
435
+ function installErrorCapture(emit) {
436
+ const onError = (event) => {
437
+ safeEmit(emit, {
438
+ type: "error",
439
+ level: "error",
440
+ message: event.message || String(event.error ?? "Error"),
441
+ errorStack: truncate(event.error?.stack),
442
+ traceId: activeTraceId()
443
+ });
444
+ };
445
+ const onRejection = (event) => {
446
+ const reason = event.reason;
447
+ safeEmit(emit, {
448
+ type: "error",
449
+ level: "error",
450
+ message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
451
+ errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
452
+ traceId: activeTraceId()
453
+ });
454
+ };
455
+ window.addEventListener("error", onError);
456
+ window.addEventListener("unhandledrejection", onRejection);
457
+ return () => {
458
+ window.removeEventListener("error", onError);
459
+ window.removeEventListener("unhandledrejection", onRejection);
460
+ };
461
+ }
462
+ function truncate(stack) {
463
+ if (!stack) return void 0;
464
+ return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
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
+ };
481
+ //#endregion
482
+ //#region ../browser-session/src/capture/interactions.ts
483
+ const MAX_TEXT = 120;
484
+ /**
485
+ * Capture clicks and input events as session events. Listens in the capture
486
+ * phase so it sees interactions even when the host app calls
487
+ * `stopPropagation()`. Input *values* are never recorded; only the target
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.
490
+ */
491
+ function installInteractionCapture(emit, maskAllText) {
492
+ const onClick = (event) => {
493
+ const target = event.target;
494
+ if (!(target instanceof Element)) return;
495
+ safeEmit(emit, {
496
+ type: "click",
497
+ targetSelector: selectorOf(target),
498
+ targetText: maskAllText || isBlocked(target) ? void 0 : textOf(target)
499
+ });
500
+ };
501
+ const onInput = (event) => {
502
+ const target = event.target;
503
+ if (!(target instanceof Element)) return;
504
+ safeEmit(emit, {
505
+ type: "input",
506
+ targetSelector: selectorOf(target)
507
+ });
508
+ };
509
+ document.addEventListener("click", onClick, true);
510
+ document.addEventListener("input", onInput, true);
511
+ return () => {
512
+ document.removeEventListener("click", onClick, true);
513
+ document.removeEventListener("input", onInput, true);
514
+ };
515
+ }
516
+ /** A short, human-readable selector: tag + #id + .first-class. */
517
+ function selectorOf(el) {
518
+ return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
519
+ }
520
+ function textOf(el) {
521
+ const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
522
+ if (!text) return void 0;
523
+ return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
524
+ }
525
+ //#endregion
526
+ //#region ../browser-session/src/capture/baseline.ts
527
+ /**
528
+ * The capture that runs on **every** page load, sampled for replay or not.
529
+ *
530
+ * Errors and clicks are the analytics substrate, for the same reason navigation
531
+ * is: `error_count` drives the Sessions UI "has errors" filter and `click_count`
532
+ * separates a real visit from a bounce, so gating them behind replay sampling
533
+ * makes both a sample rather than a count — and a session row that reports zero
534
+ * errors because nothing was listening is worse than one that reports none,
535
+ * because it looks complete.
536
+ *
537
+ * Console and network capture stay on the sampled replay path: they patch
538
+ * `console.*`, `window.fetch` and `XMLHttpRequest`, which is a cost (and a
539
+ * surface) worth paying only for sessions that get a recording to attach it to.
540
+ *
541
+ * Owned by the sink rather than by the replay lifecycle, which is also what
542
+ * keeps the listeners installed exactly once — the recorder starting and
543
+ * stopping across visibility changes must not re-register them.
544
+ */
545
+ function startBaselineCapture(emit, maskAllText) {
546
+ if (typeof window === "undefined" || typeof document === "undefined") return () => {};
547
+ const uninstall = [installErrorCapture(emit), installInteractionCapture(emit, maskAllText)];
548
+ return () => {
549
+ for (const off of uninstall) off();
550
+ };
551
+ }
552
+ //#endregion
553
+ //#region ../browser-session/src/platform/browser-globals.ts
554
+ const browserNavigator = () => globalThis.navigator;
555
+ const browserLocation = () => globalThis.window?.location;
556
+ const browserDocument = () => globalThis.document;
557
+ //#endregion
558
+ //#region ../browser-session/src/platform/transport.ts
559
+ /**
560
+ * Header stamped on every request to ingest: `<sdk-name>/<version>`, e.g.
561
+ * `maple-browser/0.3.0`. Browsers refuse to let a page set `user-agent`, so
562
+ * without this a rejected request carried nothing that said which SDK build
563
+ * produced it. Ingest records it as `maple.sdk` on the request span. Every
564
+ * gateway that receives browser traffic must allow it in CORS (`apps/ingest`
565
+ * and the CLI's local listener do) — a header the SDK always sends and
566
+ * preflight refuses blocks the whole SDK, not just the header.
567
+ */
568
+ const SDK_HINT_HEADER = "x-maple-sdk";
569
+ /** `<name>/<version>` — the one shape ingest expects in `x-maple-sdk`. */
570
+ const sdkHint = (name, version) => `${name}/${version}`;
571
+ /** Auth + identity headers shared by every ingest request. */
572
+ function ingestHeaders(config) {
573
+ return {
574
+ Authorization: `Bearer ${config.ingestKey}`,
575
+ [SDK_HINT_HEADER]: config.sdk
576
+ };
577
+ }
578
+ const lastWarnAt = /* @__PURE__ */ new Map();
579
+ function warnDropped(what, error) {
580
+ const now = Date.now();
581
+ if (now - (lastWarnAt.get(what) ?? 0) < 3e4) return;
582
+ lastWarnAt.set(what, now);
583
+ console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
584
+ }
585
+ /**
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.
594
+ *
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.
598
+ */
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
+ }
655
+ }
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
+ */
671
+ async function gzip(bytes) {
672
+ const stream = new CompressionStream("gzip");
673
+ const writer = stream.writable.getWriter();
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;
679
+ }
680
+ /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
681
+ async function postSessionMeta(config, row, keepalive = false) {
682
+ const body = `${JSON.stringify(row)}\n`;
683
+ await postToIngest(`${config.endpoint}/v1/sessionReplays/meta`, {
684
+ ...ingestHeaders(config),
685
+ "content-type": "application/x-ndjson"
686
+ }, body, keepalive).catch((error) => {
687
+ warnDropped("metadata POST", error);
688
+ });
689
+ }
690
+ /** POST distilled session events (NDJSON, one row per event). Best-effort. */
691
+ async function postSessionEvents(config, rows, keepalive = false) {
692
+ if (rows.length === 0) return;
693
+ const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
694
+ await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
695
+ ...ingestHeaders(config),
696
+ "content-type": "application/x-ndjson"
697
+ }, body, keepalive).catch((error) => {
698
+ warnDropped("events POST", error);
699
+ });
700
+ }
701
+ const SESSION_EXHAUSTED_STATUS = 413;
702
+ /** POST a gzipped rrweb event chunk. */
703
+ async function postSessionBlob(config, meta, gzipped, keepalive = false) {
704
+ try {
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);
714
+ if (response.ok) return "accepted";
715
+ return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
716
+ } catch (error) {
717
+ warnDropped("blob PUT", error);
718
+ return "failed";
719
+ }
720
+ }
721
+ //#endregion
722
+ //#region ../browser-session/src/platform/user-agent.ts
263
723
  let memo;
264
724
  /** Best-effort UA parse — enough to populate filterable session facets. */
265
725
  function parseUserAgent(ua) {
@@ -275,11 +735,53 @@ function parse(ua) {
275
735
  return {
276
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",
277
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",
278
- 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"
279
739
  };
280
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
+ }
281
783
  //#endregion
282
- //#region ../browser-session/src/meta-row.ts
784
+ //#region ../browser-session/src/events/meta-row.ts
283
785
  /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
284
786
  function formatCHDateTime(date) {
285
787
  const pad = (n, width = 2) => String(n).padStart(width, "0");
@@ -292,14 +794,13 @@ function formatCHDateTime(date) {
292
794
  * exotic embedders) they fall back to empty strings.
293
795
  */
294
796
  function buildSessionMetaRow(input) {
295
- const g = globalThis;
296
- const userAgent = g["navigator"]?.userAgent ?? "";
797
+ const userAgent = browserNavigator()?.userAgent ?? "";
297
798
  const ua = parseUserAgent(userAgent);
298
799
  const now = /* @__PURE__ */ new Date();
299
- const location = g["window"]?.location;
800
+ const location = browserLocation();
300
801
  const identity = input.identity;
301
- const entryUrl = input.entry?.entryUrl ?? location?.href ?? "";
302
- const referrer = input.entry?.referrer ?? "";
802
+ const entryUrl = scrubUrl(input.entry?.entryUrl || location?.href || "");
803
+ const referrer = scrubUrl(input.entry?.referrer ?? "");
303
804
  const utm = input.entry?.utm ?? {};
304
805
  const row = {
305
806
  session_id: input.sessionId,
@@ -307,7 +808,7 @@ function buildSessionMetaRow(input) {
307
808
  status: input.status,
308
809
  version: input.version,
309
810
  user_id: identity?.id ?? input.userId ?? "",
310
- url_initial: location?.href ?? "",
811
+ url_initial: entryUrl,
311
812
  user_agent: userAgent,
312
813
  browser_name: ua.browserName,
313
814
  os_name: ua.osName,
@@ -315,12 +816,13 @@ function buildSessionMetaRow(input) {
315
816
  service_name: input.serviceName,
316
817
  resource_attributes: {
317
818
  "maple.session.recorded": input.recorded ? "true" : "false",
318
- ...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : {},
819
+ "maple.session.replay_format": "rrweb",
820
+ ...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : void 0,
319
821
  ...input.environment ? {
320
822
  "deployment.environment": input.environment,
321
823
  "deployment.environment.name": input.environment
322
- } : {},
323
- ...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : {}
824
+ } : void 0,
825
+ ...input.serviceVersion && /^[0-9a-f]{7,40}$/i.test(input.serviceVersion) ? { "vcs.ref.head.revision": input.serviceVersion } : void 0
324
826
  },
325
827
  visitor_id: input.visitorId ?? "",
326
828
  visitor_is_new: input.visitorIsNew ? 1 : 0,
@@ -338,7 +840,7 @@ function buildSessionMetaRow(input) {
338
840
  host: location?.host ?? "",
339
841
  entry_path: pathOf(entryUrl),
340
842
  exit_path: pathOf(input.lastUrl ?? location?.href ?? ""),
341
- language: g["navigator"]?.language ?? "",
843
+ language: browserNavigator()?.language ?? "",
342
844
  last_activity_at: formatCHDateTime(now),
343
845
  click_count: input.clickCount ?? 0,
344
846
  page_views: input.pageViews ?? 0,
@@ -367,87 +869,15 @@ function pathOf(url) {
367
869
  }
368
870
  }
369
871
  /** POST one session metadata row (NDJSON). Best-effort — never throws. */
370
- async function postSessionMetaRow(endpoint, ingestKey, row, keepalive = false) {
371
- await fetch(`${endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
372
- method: "POST",
373
- headers: {
374
- Authorization: `Bearer ${ingestKey}`,
375
- "content-type": "application/x-ndjson"
376
- },
377
- body: `${JSON.stringify(row)}\n`,
378
- keepalive
379
- }).catch(() => {});
380
- }
381
- //#endregion
382
- //#region ../browser-session/src/replay/transport.ts
383
- let lastWarnAt = 0;
384
- function warnDropped(what, error) {
385
- const now = Date.now();
386
- if (now - lastWarnAt < 3e4) return;
387
- lastWarnAt = now;
388
- console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
389
- }
390
- /** gzip a byte buffer using the native CompressionStream (no library). */
391
- async function gzip(bytes) {
392
- const stream = new CompressionStream("gzip");
393
- const writer = stream.writable.getWriter();
394
- writer.write(bytes);
395
- writer.close();
396
- const buffer = await new Response(stream.readable).arrayBuffer();
397
- return new Uint8Array(buffer);
398
- }
399
- /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
400
- async function postSessionMeta(config, row, keepalive = false) {
872
+ async function postSessionMetaRow(target, row, keepalive = false) {
401
873
  const body = `${JSON.stringify(row)}\n`;
402
- await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
403
- method: "POST",
404
- headers: {
405
- Authorization: `Bearer ${config.ingestKey}`,
406
- "content-type": "application/x-ndjson"
407
- },
408
- body,
409
- keepalive
410
- }).catch((error) => {
411
- warnDropped("metadata POST", error);
412
- });
413
- }
414
- /** POST distilled session events (NDJSON, one row per event). Best-effort. */
415
- async function postSessionEvents(config, rows, keepalive = false) {
416
- if (rows.length === 0) return;
417
- const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
418
- await fetch(`${config.endpoint}/v1/sessionEvents`, {
419
- method: "POST",
420
- headers: {
421
- Authorization: `Bearer ${config.ingestKey}`,
422
- "content-type": "application/x-ndjson"
423
- },
424
- body,
425
- keepalive
426
- }).catch((error) => {
427
- warnDropped("events POST", error);
428
- });
429
- }
430
- /** PUT a gzipped rrweb event chunk. */
431
- async function postSessionBlob(config, meta, gzipped, keepalive = false) {
432
- await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
433
- method: "POST",
434
- headers: {
435
- Authorization: `Bearer ${config.ingestKey}`,
436
- "content-type": "application/octet-stream",
437
- "x-maple-session-id": meta.sessionId,
438
- "x-maple-chunk-seq": String(meta.chunkSeq),
439
- "x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
440
- "x-maple-event-count": String(meta.eventCount),
441
- "x-maple-duration-ms": String(meta.durationMs)
442
- },
443
- body: gzipped,
444
- keepalive
445
- }).catch((error) => {
446
- warnDropped("blob PUT", error);
447
- });
874
+ await postToIngest(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
875
+ ...ingestHeaders(target),
876
+ "content-type": "application/x-ndjson"
877
+ }, body, keepalive).catch(() => {});
448
878
  }
449
879
  //#endregion
450
- //#region ../browser-session/src/replay/util.ts
880
+ //#region ../browser-session/src/platform/approximate-size.ts
451
881
  /** Approximate byte size of an event for flush-threshold accounting. Falls back
452
882
  * to a fixed estimate for values that can't be serialized (e.g. cycles). */
453
883
  function approximateSize(value) {
@@ -458,7 +888,7 @@ function approximateSize(value) {
458
888
  }
459
889
  }
460
890
  //#endregion
461
- //#region ../browser-session/src/session.ts
891
+ //#region ../browser-session/src/session/session.ts
462
892
  const STORAGE_KEY = "maple.session";
463
893
  /** Rotate the session after this much inactivity (PostHog's default). */
464
894
  const IDLE_TIMEOUT_MS = 18e5;
@@ -471,6 +901,64 @@ const MAX_SESSION_MS = 864e5;
471
901
  * — rotation correctness only needs sub-idle-timeout granularity.
472
902
  */
473
903
  const ACTIVITY_TOUCH_THROTTLE_MS = 5e3;
904
+ /** Optional keys carrying a plain number. Absent is fine; wrongly typed is not. */
905
+ const OPTIONAL_NUMBERS = [
906
+ "metaVersion",
907
+ "pageViews",
908
+ "clickCount",
909
+ "errorCount"
910
+ ];
911
+ /** Optional keys carrying a plain string. */
912
+ const OPTIONAL_STRINGS = [
913
+ "entryUrl",
914
+ "entryReferrer",
915
+ "lastUrl"
916
+ ];
917
+ /**
918
+ * Validate a persisted session record. Deliberately still accepts records
919
+ * written by older SDKs, which have none of the optional fields — see the
920
+ * `SessionRecord` field comments.
921
+ *
922
+ * Returns `undefined` rather than throwing: the sole caller treats a corrupt
923
+ * record exactly as it treats unreadable storage.
924
+ */
925
+ function parseSessionRecord(raw) {
926
+ const value = parseJsonObject(raw);
927
+ if (!value) return void 0;
928
+ const { id, startedAt, lastActivityAt, chunkSeq } = value;
929
+ if (typeof id !== "string" || typeof startedAt !== "number" || typeof lastActivityAt !== "number" || typeof chunkSeq !== "number") return;
930
+ const record = {
931
+ id,
932
+ startedAt,
933
+ lastActivityAt,
934
+ chunkSeq
935
+ };
936
+ for (const key of OPTIONAL_NUMBERS) {
937
+ const entry = value[key];
938
+ if (entry === void 0) continue;
939
+ if (typeof entry !== "number") return void 0;
940
+ record[key] = entry;
941
+ }
942
+ for (const key of OPTIONAL_STRINGS) {
943
+ const entry = value[key];
944
+ if (entry === void 0) continue;
945
+ if (typeof entry !== "string") return void 0;
946
+ record[key] = entry;
947
+ }
948
+ if (value.visitorIsNew !== void 0) {
949
+ if (typeof value.visitorIsNew !== "boolean") return void 0;
950
+ record.visitorIsNew = value.visitorIsNew;
951
+ }
952
+ if (value.replaySampled !== void 0) {
953
+ if (typeof value.replaySampled !== "boolean") return void 0;
954
+ record.replaySampled = value.replaySampled;
955
+ }
956
+ if (value.utm !== void 0) {
957
+ if (!isStringRecord(value.utm)) return void 0;
958
+ record.utm = value.utm;
959
+ }
960
+ return record;
961
+ }
474
962
  const UTM_KEYS = [
475
963
  "utm_source",
476
964
  "utm_medium",
@@ -498,11 +986,12 @@ function readEntryContext() {
498
986
  if (value) utm[key] = value.slice(0, 128);
499
987
  }
500
988
  } catch {}
989
+ const href = scrubUrl(location.href);
501
990
  return {
502
- entryUrl: location.href,
503
- entryReferrer: typeof document !== "undefined" ? document.referrer : "",
991
+ entryUrl: href,
992
+ entryReferrer: typeof document !== "undefined" ? scrubUrl(document.referrer) : "",
504
993
  utm,
505
- lastUrl: location.href,
994
+ lastUrl: href,
506
995
  pageViews: 0,
507
996
  clickCount: 0,
508
997
  errorCount: 0
@@ -523,9 +1012,7 @@ function readRecord() {
523
1012
  try {
524
1013
  const raw = window.sessionStorage.getItem(STORAGE_KEY);
525
1014
  if (!raw) return void 0;
526
- const parsed = JSON.parse(raw);
527
- if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
528
- return;
1015
+ return parseSessionRecord(raw) ?? ephemeral;
529
1016
  } catch {
530
1017
  return ephemeral;
531
1018
  }
@@ -535,6 +1022,105 @@ function writeRecord(record) {
535
1022
  try {
536
1023
  window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
537
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
+ }
538
1124
  }
539
1125
  function isSessionExpired(record, now = Date.now()) {
540
1126
  return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
@@ -571,7 +1157,7 @@ function installRotatedRecord(previous, next) {
571
1157
  function getSession() {
572
1158
  const now = Date.now();
573
1159
  const existing = readRecord();
574
- if (existing && !isSessionExpired(existing, now)) {
1160
+ if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
575
1161
  const record = {
576
1162
  ...migrateRecord(existing),
577
1163
  lastActivityAt: now
@@ -599,13 +1185,13 @@ function rotateSession() {
599
1185
  */
600
1186
  function touchSession(now) {
601
1187
  const existing = readRecord();
602
- if (existing && !isSessionExpired(existing, now)) {
1188
+ if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
603
1189
  const migrated = migrateRecord(existing);
604
1190
  const touched = {
605
1191
  ...migrated,
606
1192
  lastActivityAt: now
607
1193
  };
608
- 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);
609
1195
  return touched;
610
1196
  }
611
1197
  const record = freshRecord(now);
@@ -632,7 +1218,7 @@ function noteNavigation(url) {
632
1218
  const record = touchSession(now);
633
1219
  writeRecord({
634
1220
  ...record,
635
- lastUrl: url,
1221
+ lastUrl: scrubUrl(url),
636
1222
  pageViews: (record.pageViews ?? 0) + 1,
637
1223
  lastActivityAt: now
638
1224
  });
@@ -698,21 +1284,44 @@ function nextMetaVersion() {
698
1284
  });
699
1285
  return version;
700
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
+ }
701
1321
  //#endregion
702
- //#region ../browser-session/src/events-sink.ts
1322
+ //#region ../browser-session/src/events/events-sink.ts
703
1323
  const FLUSH_INTERVAL_MS = 5e3;
704
1324
  const FLUSH_BYTES = 65536;
705
- const ZERO_TRACE_ID = "00000000000000000000000000000000";
706
- let traceIdProvider = () => void 0;
707
- /** Wire the host SDK's active-trace-id lookup into event capture. */
708
- function setActiveTraceIdProvider(provider) {
709
- traceIdProvider = provider;
710
- }
711
- /** The trace id of the active span, or undefined when none is active. */
712
- function activeTraceId() {
713
- const id = traceIdProvider();
714
- return id && id !== ZERO_TRACE_ID ? id : void 0;
715
- }
716
1325
  /**
717
1326
  * The sink is a per-session singleton, published on `globalThis` rather than a
718
1327
  * module-level variable.
@@ -757,7 +1366,7 @@ function startEventSink(config, sessionId) {
757
1366
  const batch = buffer;
758
1367
  buffer = [];
759
1368
  bufferBytes = 0;
760
- 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);
761
1370
  };
762
1371
  const emit = (ev) => {
763
1372
  const session = markActivity();
@@ -784,6 +1393,7 @@ function startEventSink(config, sessionId) {
784
1393
  url
785
1394
  });
786
1395
  });
1396
+ const stopBaselineCapture = startBaselineCapture(emit, config.maskAllText);
787
1397
  const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
788
1398
  const sink = {
789
1399
  sessionId,
@@ -792,6 +1402,7 @@ function startEventSink(config, sessionId) {
792
1402
  stop: () => {
793
1403
  clearInterval(flushTimer);
794
1404
  stopNavigation();
1405
+ stopBaselineCapture();
795
1406
  if (holder()[SINK_KEY]?.sink === sink) holder()[SINK_KEY] = void 0;
796
1407
  },
797
1408
  getPageViews: () => pageViews,
@@ -822,13 +1433,14 @@ function installNavigationObserver(onNavigate) {
822
1433
  emitNav();
823
1434
  const origPush = history.pushState;
824
1435
  const origReplace = history.replaceState;
1436
+ const receiver = (self) => self instanceof History ? self : history;
825
1437
  history.pushState = function(...args) {
826
- const result = origPush.apply(this, args);
1438
+ const result = origPush.apply(receiver(this), args);
827
1439
  emitNav();
828
1440
  return result;
829
1441
  };
830
1442
  history.replaceState = function(...args) {
831
- const result = origReplace.apply(this, args);
1443
+ const result = origReplace.apply(receiver(this), args);
832
1444
  emitNav();
833
1445
  return result;
834
1446
  };
@@ -891,21 +1503,33 @@ function drainPending(sink) {
891
1503
  state.bytes = 0;
892
1504
  for (const ev of queued) sink.emit(ev);
893
1505
  }
894
- /** Map an internal event to the snake_case ingest row (org_id is added server-side). */
895
- 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?.();
896
1517
  return {
897
1518
  session_id: sessionId,
1519
+ visitor_id: getVisitorId() ?? "",
1520
+ user_id: identity?.id ?? "",
1521
+ group_id: identity?.groupId ?? "",
898
1522
  timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
899
1523
  seq,
900
1524
  type: ev.type,
901
- url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
1525
+ url: scrubUrl(ev.url ?? (typeof location !== "undefined" ? location.href : "")),
902
1526
  trace_id: ev.traceId ?? activeTraceId() ?? "",
903
1527
  level: ev.level ?? "",
904
1528
  message: ev.message ?? "",
905
1529
  target_selector: ev.targetSelector ?? "",
906
1530
  target_text: ev.targetText ?? "",
907
1531
  net_method: ev.net?.method ?? "",
908
- net_url: ev.net?.url ?? "",
1532
+ net_url: ev.net ? scrubUrl(ev.net.url) : "",
909
1533
  net_status: ev.net?.status ?? 0,
910
1534
  net_duration_ms: ev.net?.durationMs ?? 0,
911
1535
  error_stack: ev.errorStack ?? "",
@@ -913,7 +1537,7 @@ function toRow(sessionId, ev, seq) {
913
1537
  };
914
1538
  }
915
1539
  //#endregion
916
- //#region ../browser-session/src/session-lifecycle.ts
1540
+ //#region ../browser-session/src/session/lifecycle.ts
917
1541
  /**
918
1542
  * How often a visible tab re-posts its `active` row.
919
1543
  *
@@ -1019,6 +1643,7 @@ function startSessionLifecycle(options, hooks) {
1019
1643
  if (stopped || running) return;
1020
1644
  running = true;
1021
1645
  const record = liveRecord();
1646
+ adoptReplayDecision(record.id, hooks.recorded);
1022
1647
  rebaseCounts(record);
1023
1648
  hooks.onStart?.(record);
1024
1649
  post("active", false);
@@ -1056,7 +1681,7 @@ function startSessionLifecycle(options, hooks) {
1056
1681
  keepalive: true
1057
1682
  });
1058
1683
  const onVisibilityChange = () => {
1059
- const doc = globalThis["document"];
1684
+ const doc = browserDocument();
1060
1685
  if (!doc) return;
1061
1686
  if (doc.visibilityState === "hidden") {
1062
1687
  endRun({
@@ -1095,17 +1720,34 @@ function startSessionLifecycle(options, hooks) {
1095
1720
  };
1096
1721
  }
1097
1722
  //#endregion
1098
- //#region ../browser-session/src/sink.ts
1723
+ //#region ../browser-session/src/session/sink.ts
1099
1724
  const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
1100
1725
  const observedTraceIdsBySession = /* @__PURE__ */ new Map();
1726
+ /**
1727
+ * Ceiling on trace ids retained per session.
1728
+ *
1729
+ * A session lives up to 24h and every span feeds this, so it is otherwise
1730
+ * unbounded — and the whole set is serialized into the `ended` metadata row,
1731
+ * which is written with `keepalive` on the way out. The Fetch spec caps the
1732
+ * *combined* keepalive body across in-flight requests at 64 KiB, shared here
1733
+ * with the final events flush and the last replay chunk, so an app emitting a
1734
+ * span a second would silently lose its entire session row after ~30 minutes.
1735
+ *
1736
+ * Keep-first rather than keep-last: the ids are a join key for "show me this
1737
+ * session's traces", the UI paginates them anyway, and dropping the tail of a
1738
+ * long session is a smaller loss than dropping the row.
1739
+ */
1740
+ const MAX_TRACE_IDS_PER_SESSION = 200;
1101
1741
  /** Record a trace id seen during the session. Idempotent per id. */
1102
1742
  function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
1743
+ noteStartedTraceId(traceId);
1103
1744
  if (!sessionId) return;
1104
1745
  let ids = observedTraceIdsBySession.get(sessionId);
1105
1746
  if (!ids) {
1106
1747
  ids = /* @__PURE__ */ new Set();
1107
1748
  observedTraceIdsBySession.set(sessionId, ids);
1108
1749
  }
1750
+ if (ids.size >= MAX_TRACE_IDS_PER_SESSION && !ids.has(traceId)) return;
1109
1751
  ids.add(traceId);
1110
1752
  }
1111
1753
  function getObservedTraceIds(sessionId = readSessionSink()?.sessionId) {
@@ -1146,4 +1788,4 @@ function readSessionSink() {
1146
1788
  return globalThis[SESSION_SINK_KEY];
1147
1789
  }
1148
1790
  //#endregion
1149
- export { setVisitorTracking as S, gzip as _, recordTraceId as a, postSessionMetaRow as b, clearPendingEvents as c, setActiveTraceIdProvider as d, startEventSink as f, rotateSession as g, nextChunkSeq as h, readSessionSink as i, getActiveSink as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, activeTraceId as s, clearSessionSink as t, queuePending as u, postSessionBlob as v, configureVisitorCookie as x, postSessionMeta as y };
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 };