@maple-dev/browser 0.3.0 → 0.4.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.
@@ -0,0 +1,1149 @@
1
+ //#region ../browser-session/src/visitor.ts
2
+ /**
3
+ * A persistent per-browser visitor id.
4
+ *
5
+ * Sessions rotate after 30 minutes idle (see `session.ts`), which makes them
6
+ * useless for "how many people visited" — every long gap mints a new one. The
7
+ * visitor id is the stable identifier that `uniq(VisitorId)` counts, and the
8
+ * only place that knows whether a visitor is new (a self-join against earlier
9
+ * sessions is both a second full scan and wrong past the warehouse's 30-day
10
+ * TTL, which drops the history the join would need).
11
+ *
12
+ * This is a first-party persistent identifier: it requires a cookie/consent
13
+ * notice under ePrivacy, and it is the one thing here that `persistVisitorId:
14
+ * false` and Global Privacy Control turn off.
15
+ *
16
+ * ## Why two stores
17
+ *
18
+ * The id lives in **both** localStorage and a cookie, the same hybrid posthog-js
19
+ * uses for its `localStorage+cookie` persistence:
20
+ *
21
+ * - localStorage is the durable copy. Safari's ITP caps the lifetime of any
22
+ * cookie set from `document.cookie` at 7 days, so a cookie alone would silently
23
+ * turn returning visitors into new ones every week.
24
+ * - The cookie is the *cross-subdomain carrier*. localStorage is origin-scoped,
25
+ * so a marketing site on `example.com` and an app on `app.example.com` can
26
+ * never see each other's id; a cookie scoped to the registered domain can.
27
+ * That is what links an anonymous pre-signup visit to the account it becomes.
28
+ *
29
+ * Reads prefer the cookie and mirror the winner back, so an existing
30
+ * localStorage-only visitor converges onto the shared id instead of being reset.
31
+ *
32
+ * ## What is deliberately *not* shared
33
+ *
34
+ * The **session** id stays origin-scoped (sessionStorage, `session.ts`). Unlike
35
+ * PostHog, our `session_replays` table is a ReplacingMergeTree keyed
36
+ * `(OrgId, SessionId)` whose fields resolve by `argMax(field, Version)` over the
37
+ * whole row — two origins posting different ServiceName/EntryPath under one
38
+ * session id would overwrite each other rather than merge. Sessions stay
39
+ * per-surface; `VisitorId` is the join key between them.
40
+ */
41
+ const STORAGE_KEY$1 = "maple.visitor";
42
+ /**
43
+ * Cookie names cannot contain `.` per RFC 6265's token grammar, so this is not
44
+ * simply `maple.visitor`.
45
+ */
46
+ const COOKIE_NAME = "maple_visitor";
47
+ /**
48
+ * Match the ~13-month ceiling browsers and privacy regimes settled on for
49
+ * first-party identifiers. Cheap to enforce from the start; retrofitting an
50
+ * expiry onto ids already in the wild is not.
51
+ */
52
+ const MAX_AGE_MS = 3456e7;
53
+ let enabled = true;
54
+ /** Memoized so the hot path never re-reads storage. */
55
+ let cached;
56
+ let persisted = false;
57
+ let mintedThisLoad = false;
58
+ /** Scope the cookie to the registered domain so subdomains share the id. */
59
+ let crossSubdomainCookie = true;
60
+ /** Explicit `Domain=` override; `""` forces a host-only cookie. */
61
+ let cookieDomainOverride;
62
+ /** Memoized probe result. `undefined` = not resolved yet. */
63
+ let probedCookieDomain;
64
+ /**
65
+ * Apply the host app's cookie configuration. Called from `configurePrivacy`, so
66
+ * both SDKs get it from the single call they already make.
67
+ *
68
+ * Like the consent gates, this only ever *tightens*: an app that initializes two
69
+ * SDKs, only one of which passes a `privacy` block, must not have the other's
70
+ * absent option widen the cookie back out to every subdomain.
71
+ *
72
+ * "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
73
+ * first-write-wins: `""` (host-only) is the tightest value there is, and a
74
+ * second SDK asking for it has to win over an earlier `"example.com"`. Between
75
+ * two non-empty domains the shorter one is the broader — `example.com` covers
76
+ * `app.example.com` and not the reverse — so the longer string wins.
77
+ */
78
+ function configureVisitorCookie(options) {
79
+ if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
80
+ if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
81
+ probedCookieDomain = void 0;
82
+ }
83
+ /** The narrower of two `Domain=` values, treating `undefined` as "unset". */
84
+ function tighterCookieDomain(current, next) {
85
+ if (current === void 0) return next;
86
+ if (current === "" || next === "") return "";
87
+ return next.length > current.length ? next : current;
88
+ }
89
+ function readRawCookie(name) {
90
+ if (typeof document === "undefined") return void 0;
91
+ try {
92
+ for (const part of document.cookie.split(";")) {
93
+ const raw = part.trim();
94
+ if (!raw.startsWith(`${name}=`)) continue;
95
+ return decodeURIComponent(raw.slice(name.length + 1));
96
+ }
97
+ } catch {}
98
+ }
99
+ function setRawCookie(name, value, domain, maxAgeSeconds) {
100
+ if (typeof document === "undefined") return false;
101
+ const attributes = [
102
+ `${name}=${encodeURIComponent(value)}`,
103
+ "path=/",
104
+ `max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
105
+ "SameSite=Lax"
106
+ ];
107
+ if (domain) attributes.push(`domain=.${domain}`);
108
+ if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
109
+ try {
110
+ document.cookie = attributes.join("; ");
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+ /**
117
+ * The broadest domain this browser will actually accept a cookie for, found by
118
+ * probing rather than by carrying a public-suffix list — the same trick
119
+ * posthog-js uses. Candidates start at the broadest (the last two labels) and
120
+ * narrow a label at a time, with the first that sticks winning — so
121
+ * `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
122
+ *
123
+ * Returns `""` (host-only cookie) for single-label hosts like `localhost` and
124
+ * for bare IPs, neither of which can carry a `Domain=` attribute.
125
+ */
126
+ function probeCookieDomain() {
127
+ if (typeof document === "undefined" || typeof location === "undefined") return "";
128
+ const hostname = location.hostname;
129
+ if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
130
+ const parts = hostname.split(".");
131
+ if (parts.length < 2) return "";
132
+ for (let i = parts.length - 2; i >= 0; i--) {
133
+ const candidate = parts.slice(i).join(".");
134
+ const probe = "__maple_probe";
135
+ if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
136
+ setRawCookie(probe, "", candidate, 0);
137
+ return candidate;
138
+ }
139
+ }
140
+ return "";
141
+ }
142
+ function cookieDomain() {
143
+ if (cookieDomainOverride !== void 0) return cookieDomainOverride;
144
+ if (!crossSubdomainCookie) return "";
145
+ if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
146
+ return probedCookieDomain;
147
+ }
148
+ function parseRecord(raw) {
149
+ 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
+ }
158
+ }
159
+ function readFromStorage() {
160
+ try {
161
+ return parseRecord(window.localStorage.getItem(STORAGE_KEY$1) ?? void 0);
162
+ } catch {
163
+ return;
164
+ }
165
+ }
166
+ function readFromCookie() {
167
+ return parseRecord(readRawCookie(COOKIE_NAME));
168
+ }
169
+ function writeToStorage(record) {
170
+ try {
171
+ window.localStorage.setItem(STORAGE_KEY$1, JSON.stringify(record));
172
+ return true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+ /**
178
+ * Mirror the record into the cookie. The max-age is the id's *remaining* life,
179
+ * not a fresh 400 days, so re-writing it on every page load can't turn the
180
+ * expiry into a sliding window that never fires.
181
+ */
182
+ function writeToCookie(record) {
183
+ const remainingMs = MAX_AGE_MS - (Date.now() - record.mintedAt);
184
+ if (remainingMs <= 0) return false;
185
+ return setRawCookie(COOKIE_NAME, JSON.stringify(record), cookieDomain(), remainingMs / 1e3);
186
+ }
187
+ function write(record) {
188
+ const inStorage = writeToStorage(record);
189
+ const inCookie = writeToCookie(record);
190
+ persisted = inStorage || inCookie;
191
+ }
192
+ /**
193
+ * The current visitor id, minting one on first use. `undefined` when visitor
194
+ * tracking is off or outside a browser.
195
+ */
196
+ function getVisitorId() {
197
+ if (!enabled || typeof window === "undefined") return void 0;
198
+ if (cached) return cached.id;
199
+ const fromCookie = readFromCookie();
200
+ const fromStorage = readFromStorage();
201
+ const existing = fromCookie ?? fromStorage;
202
+ if (existing) {
203
+ cached = existing;
204
+ persisted = true;
205
+ if (!fromCookie) writeToCookie(existing);
206
+ if (!fromStorage || fromStorage.id !== existing.id) writeToStorage(existing);
207
+ return existing.id;
208
+ }
209
+ const record = {
210
+ id: crypto.randomUUID(),
211
+ mintedAt: Date.now()
212
+ };
213
+ cached = record;
214
+ mintedThisLoad = true;
215
+ write(record);
216
+ return record.id;
217
+ }
218
+ /**
219
+ * Claim the "this visitor was minted just now" flag for the session being
220
+ * created — drives new vs returning without a self-join. Reading it mints the
221
+ * id if needed, so callers get an answer consistent with `getVisitorId()`.
222
+ *
223
+ * One-shot on purpose. The flag is page-load scoped, but sessions are not: a
224
+ * page load can both start a session and (30 minutes idle later) rotate into a
225
+ * second one, and only the first of those belongs to a new visitor. The
226
+ * claimed value is persisted on the session record, which is also what keeps a
227
+ * reload from re-answering the question — see `session.ts`.
228
+ */
229
+ function claimNewVisitor() {
230
+ getVisitorId();
231
+ if (!mintedThisLoad) return false;
232
+ mintedThisLoad = false;
233
+ return true;
234
+ }
235
+ /**
236
+ * Whether the id actually survives this page load. `false` means both stores
237
+ * were blocked and the id is in-memory only, so uniques would be over-counted;
238
+ * the metadata row carries this as `maple.visitor.persisted` so the analytics
239
+ * layer can flag it rather than quietly inflate.
240
+ */
241
+ function isVisitorIdPersisted() {
242
+ return persisted;
243
+ }
244
+ /**
245
+ * Turn persistent visitor tracking on or off. Turning it off also purges any
246
+ * id already stored — an opt-out that leaves the identifier behind is not one.
247
+ */
248
+ function setVisitorTracking(nextEnabled) {
249
+ enabled = nextEnabled;
250
+ if (nextEnabled) return;
251
+ cached = void 0;
252
+ persisted = false;
253
+ mintedThisLoad = false;
254
+ try {
255
+ window.localStorage.removeItem(STORAGE_KEY$1);
256
+ } catch {}
257
+ const domain = cookieDomain();
258
+ setRawCookie(COOKIE_NAME, "", domain, 0);
259
+ if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
260
+ }
261
+ //#endregion
262
+ //#region ../browser-session/src/user-agent.ts
263
+ let memo;
264
+ /** Best-effort UA parse — enough to populate filterable session facets. */
265
+ function parseUserAgent(ua) {
266
+ if (memo?.ua === ua) return memo.parsed;
267
+ const parsed = parse(ua);
268
+ memo = {
269
+ parsed,
270
+ ua
271
+ };
272
+ return parsed;
273
+ }
274
+ function parse(ua) {
275
+ return {
276
+ 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
+ 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"
279
+ };
280
+ }
281
+ //#endregion
282
+ //#region ../browser-session/src/meta-row.ts
283
+ /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
284
+ function formatCHDateTime(date) {
285
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
286
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
287
+ }
288
+ /**
289
+ * Build one `/v1/sessionReplays/meta` NDJSON row. Shared by `@maple-dev/browser`
290
+ * and the Effect client SDK so a session looks identical no matter which SDK
291
+ * posted it. UA/URL facets come from the live browser globals; absent (tests,
292
+ * exotic embedders) they fall back to empty strings.
293
+ */
294
+ function buildSessionMetaRow(input) {
295
+ const g = globalThis;
296
+ const userAgent = g["navigator"]?.userAgent ?? "";
297
+ const ua = parseUserAgent(userAgent);
298
+ const now = /* @__PURE__ */ new Date();
299
+ const location = g["window"]?.location;
300
+ const identity = input.identity;
301
+ const entryUrl = input.entry?.entryUrl ?? location?.href ?? "";
302
+ const referrer = input.entry?.referrer ?? "";
303
+ const utm = input.entry?.utm ?? {};
304
+ const row = {
305
+ session_id: input.sessionId,
306
+ start_time: formatCHDateTime(input.startedAt),
307
+ status: input.status,
308
+ version: input.version,
309
+ user_id: identity?.id ?? input.userId ?? "",
310
+ url_initial: location?.href ?? "",
311
+ user_agent: userAgent,
312
+ browser_name: ua.browserName,
313
+ os_name: ua.osName,
314
+ device_type: ua.deviceType,
315
+ service_name: input.serviceName,
316
+ resource_attributes: {
317
+ "maple.session.recorded": input.recorded ? "true" : "false",
318
+ ...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : {},
319
+ ...input.environment ? {
320
+ "deployment.environment": input.environment,
321
+ "deployment.environment.name": input.environment
322
+ } : {},
323
+ ...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : {}
324
+ },
325
+ visitor_id: input.visitorId ?? "",
326
+ visitor_is_new: input.visitorIsNew ? 1 : 0,
327
+ user_email: (input.captureUserEmail === false ? void 0 : identity?.email) ?? "",
328
+ user_name: identity?.username ?? "",
329
+ group_id: identity?.groupId ?? "",
330
+ group_name: identity?.groupName ?? "",
331
+ user_traits: identity?.traits ?? {},
332
+ referrer,
333
+ utm_source: utm.utm_source ?? "",
334
+ utm_medium: utm.utm_medium ?? "",
335
+ utm_campaign: utm.utm_campaign ?? "",
336
+ utm_term: utm.utm_term ?? "",
337
+ utm_content: utm.utm_content ?? "",
338
+ host: location?.host ?? "",
339
+ entry_path: pathOf(entryUrl),
340
+ exit_path: pathOf(input.lastUrl ?? location?.href ?? ""),
341
+ language: g["navigator"]?.language ?? "",
342
+ last_activity_at: formatCHDateTime(now),
343
+ click_count: input.clickCount ?? 0,
344
+ page_views: input.pageViews ?? 0,
345
+ error_count: input.errorCount ?? 0
346
+ };
347
+ if (input.status === "ended") {
348
+ row.end_time = formatCHDateTime(now);
349
+ row.duration_ms = Math.max(0, now.getTime() - input.startedAt.getTime());
350
+ row.trace_ids = input.traceIds ? Array.from(input.traceIds) : [];
351
+ }
352
+ return row;
353
+ }
354
+ /**
355
+ * Pathname of a URL, without query string or hash.
356
+ *
357
+ * Query strings are the most common accidental PII carrier (`?email=`,
358
+ * `?token=`), and paths are the analytics dimension — nobody groups by
359
+ * `/pricing?ref=twitter` separately from `/pricing`.
360
+ */
361
+ function pathOf(url) {
362
+ if (!url) return "";
363
+ try {
364
+ return new URL(url).pathname;
365
+ } catch {
366
+ return "";
367
+ }
368
+ }
369
+ /** 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) {
401
+ 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
+ });
448
+ }
449
+ //#endregion
450
+ //#region ../browser-session/src/replay/util.ts
451
+ /** Approximate byte size of an event for flush-threshold accounting. Falls back
452
+ * to a fixed estimate for values that can't be serialized (e.g. cycles). */
453
+ function approximateSize(value) {
454
+ try {
455
+ return JSON.stringify(value).length;
456
+ } catch {
457
+ return 256;
458
+ }
459
+ }
460
+ //#endregion
461
+ //#region ../browser-session/src/session.ts
462
+ const STORAGE_KEY = "maple.session";
463
+ /** Rotate the session after this much inactivity (PostHog's default). */
464
+ const IDLE_TIMEOUT_MS = 18e5;
465
+ /** Hard cap on a single session's lifetime regardless of activity. */
466
+ const MAX_SESSION_MS = 864e5;
467
+ /**
468
+ * The activity path runs per span creation (`getSessionId`) and per captured
469
+ * event (`markActivity` — every console call, keystroke, click, fetch);
470
+ * persisting the bump on every call would hammer sessionStorage for no benefit
471
+ * — rotation correctness only needs sub-idle-timeout granularity.
472
+ */
473
+ const ACTIVITY_TOUCH_THROTTLE_MS = 5e3;
474
+ const UTM_KEYS = [
475
+ "utm_source",
476
+ "utm_medium",
477
+ "utm_campaign",
478
+ "utm_term",
479
+ "utm_content"
480
+ ];
481
+ /** In-memory fallback when sessionStorage is unavailable (private mode). */
482
+ let ephemeral;
483
+ const rotationListeners = /* @__PURE__ */ new Set();
484
+ /**
485
+ * Read the entry URL/referrer/UTM of the current page.
486
+ *
487
+ * Guarded for non-DOM runtimes: `freshRecord` is reachable from `getSessionId`
488
+ * on the span path and from the `nextChunkSeq`/`nextMetaVersion` fallbacks, so
489
+ * this must not assume `location`/`document` exist.
490
+ */
491
+ function readEntryContext() {
492
+ if (typeof window === "undefined" || typeof location === "undefined") return {};
493
+ const utm = {};
494
+ try {
495
+ const params = new URLSearchParams(location.search);
496
+ for (const key of UTM_KEYS) {
497
+ const value = params.get(key)?.trim();
498
+ if (value) utm[key] = value.slice(0, 128);
499
+ }
500
+ } catch {}
501
+ return {
502
+ entryUrl: location.href,
503
+ entryReferrer: typeof document !== "undefined" ? document.referrer : "",
504
+ utm,
505
+ lastUrl: location.href,
506
+ pageViews: 0,
507
+ clickCount: 0,
508
+ errorCount: 0
509
+ };
510
+ }
511
+ function freshRecord(now) {
512
+ return {
513
+ id: crypto.randomUUID(),
514
+ startedAt: now,
515
+ lastActivityAt: now,
516
+ chunkSeq: 0,
517
+ metaVersion: 0,
518
+ visitorIsNew: claimNewVisitor(),
519
+ ...readEntryContext()
520
+ };
521
+ }
522
+ function readRecord() {
523
+ try {
524
+ const raw = window.sessionStorage.getItem(STORAGE_KEY);
525
+ 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;
529
+ } catch {
530
+ return ephemeral;
531
+ }
532
+ }
533
+ function writeRecord(record) {
534
+ ephemeral = record;
535
+ try {
536
+ window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
537
+ } catch {}
538
+ }
539
+ function isSessionExpired(record, now = Date.now()) {
540
+ return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
541
+ }
542
+ /** Observe genuine idle/lifetime rotation. Invoked before the new record is installed. */
543
+ function onSessionRotate(listener) {
544
+ rotationListeners.add(listener);
545
+ return () => rotationListeners.delete(listener);
546
+ }
547
+ /**
548
+ * Upgrade an in-flight session written by an older SDK. The visitor feature
549
+ * can land while a tab still has the old record in sessionStorage; claiming
550
+ * newness here makes that current session the new visitor's session and
551
+ * consumes the one-shot claim so a later idle rotation cannot steal it.
552
+ */
553
+ function migrateRecord(record) {
554
+ return record.visitorIsNew === void 0 ? {
555
+ ...record,
556
+ visitorIsNew: claimNewVisitor()
557
+ } : record;
558
+ }
559
+ function installRotatedRecord(previous, next) {
560
+ if (previous) for (const listener of rotationListeners) try {
561
+ listener(previous, next);
562
+ } catch {}
563
+ writeRecord(next);
564
+ }
565
+ /**
566
+ * Resolve the active session, rotating to a fresh one if the previous session
567
+ * has gone idle (or hit the lifetime cap). Touches `lastActivityAt` so calling
568
+ * it on page load keeps a live session alive. The id is the correlation key
569
+ * shared by OTel traces and replay events.
570
+ */
571
+ function getSession() {
572
+ const now = Date.now();
573
+ const existing = readRecord();
574
+ if (existing && !isSessionExpired(existing, now)) {
575
+ const record = {
576
+ ...migrateRecord(existing),
577
+ lastActivityAt: now
578
+ };
579
+ writeRecord(record);
580
+ return record;
581
+ }
582
+ const record = freshRecord(now);
583
+ installRotatedRecord(existing, record);
584
+ return record;
585
+ }
586
+ /** Force a new session boundary (used after a consent revoke/re-grant cycle). */
587
+ function rotateSession() {
588
+ if (typeof window === "undefined") return void 0;
589
+ const previous = readRecord();
590
+ const next = freshRecord(Date.now());
591
+ installRotatedRecord(previous, next);
592
+ return next;
593
+ }
594
+ /**
595
+ * Resolve the active session, rotating when the stored one has expired, and
596
+ * persist the activity bump only once it has gone stale by
597
+ * `ACTIVITY_TOUCH_THROTTLE_MS`. Shared by the two hot-path entry points so a
598
+ * chatty page doesn't pay a sessionStorage read *and* write per span/event.
599
+ */
600
+ function touchSession(now) {
601
+ const existing = readRecord();
602
+ if (existing && !isSessionExpired(existing, now)) {
603
+ const migrated = migrateRecord(existing);
604
+ const touched = {
605
+ ...migrated,
606
+ lastActivityAt: now
607
+ };
608
+ if (migrated !== existing || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
609
+ return touched;
610
+ }
611
+ const record = freshRecord(now);
612
+ installRotatedRecord(existing, record);
613
+ return record;
614
+ }
615
+ /**
616
+ * Mark activity, rotating first when the stored session has expired. Called per
617
+ * captured event, so it shares `getSessionId`'s throttled touch — callers read
618
+ * the returned record's `id` to detect that rotation.
619
+ */
620
+ function markActivity() {
621
+ if (typeof window === "undefined") return void 0;
622
+ return touchSession(Date.now());
623
+ }
624
+ /**
625
+ * Record a page view. Persisted on the session record rather than held in the
626
+ * capture loop's memory so the count survives reloads within the session — a
627
+ * two-page visit split by a refresh is not a bounce.
628
+ */
629
+ function noteNavigation(url) {
630
+ if (typeof window === "undefined") return;
631
+ const now = Date.now();
632
+ const record = touchSession(now);
633
+ writeRecord({
634
+ ...record,
635
+ lastUrl: url,
636
+ pageViews: (record.pageViews ?? 0) + 1,
637
+ lastActivityAt: now
638
+ });
639
+ }
640
+ /** Accumulate interaction/error counts onto the persisted session record. */
641
+ function noteCounts(counts) {
642
+ const record = readRecord();
643
+ if (!record) return;
644
+ writeRecord({
645
+ ...record,
646
+ clickCount: counts.clickCount ?? record.clickCount ?? 0,
647
+ errorCount: counts.errorCount ?? record.errorCount ?? 0
648
+ });
649
+ }
650
+ /**
651
+ * The persisted session record as-is — no activity touch, no rotation. Use
652
+ * this to read counters when posting a metadata row; `getSession()` would
653
+ * rotate an idle session out from under the row being written.
654
+ */
655
+ function peekSession() {
656
+ return readRecord();
657
+ }
658
+ /**
659
+ * The acquisition context carried by a record. Takes the record rather than
660
+ * re-reading storage, because every caller already holds one — the metadata
661
+ * lifecycle posts from it.
662
+ */
663
+ function entryContextOf(record) {
664
+ return {
665
+ entryUrl: record.entryUrl ?? "",
666
+ referrer: record.entryReferrer ?? "",
667
+ utm: record.utm ?? {}
668
+ };
669
+ }
670
+ /**
671
+ * Take the next replay chunk sequence number for the current session. Monotonic
672
+ * across reloads (persisted on the session record), so a refresh continues the
673
+ * sequence instead of restarting at 0 and overwriting the previous load's blobs.
674
+ */
675
+ function nextChunkSeq() {
676
+ const record = readRecord() ?? freshRecord(Date.now());
677
+ const seq = record.chunkSeq;
678
+ writeRecord({
679
+ ...record,
680
+ chunkSeq: seq + 1
681
+ });
682
+ return seq;
683
+ }
684
+ /**
685
+ * Take the next session-metadata row version for the current session.
686
+ * Monotonic per session across reloads, hide/resume cycles, and writers (both
687
+ * SDKs share the persisted counter), so `argMax(field, Version)` on the
688
+ * backend always resolves to the most recently posted row. Records written by
689
+ * older SDKs (no `metaVersion`) already posted versions 1 and 2, so the
690
+ * counter resumes at 3 for them; a fresh session starts at 1.
691
+ */
692
+ function nextMetaVersion() {
693
+ const record = readRecord() ?? freshRecord(Date.now());
694
+ const version = (record.metaVersion ?? 2) + 1;
695
+ writeRecord({
696
+ ...record,
697
+ metaVersion: version
698
+ });
699
+ return version;
700
+ }
701
+ //#endregion
702
+ //#region ../browser-session/src/events-sink.ts
703
+ const FLUSH_INTERVAL_MS = 5e3;
704
+ 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
+ /**
717
+ * The sink is a per-session singleton, published on `globalThis` rather than a
718
+ * module-level variable.
719
+ *
720
+ * Two consumers can end up with separate copies of this module in one page (an
721
+ * app bundling both `@maple-dev/browser` and the Effect SDK, or a
722
+ * lazily-imported replay chunk). Two sinks would mean two `seq` counters
723
+ * starting at 0, and `Seq` is part of the `session_events` sorting key — the
724
+ * rows would collide. One global owner avoids that.
725
+ */
726
+ const SINK_KEY = "__MAPLE_SESSION_EVENT_SINK__";
727
+ function holder() {
728
+ return globalThis;
729
+ }
730
+ /** The live sink, if one has been started. */
731
+ function getActiveSink() {
732
+ return holder()[SINK_KEY]?.sink;
733
+ }
734
+ /**
735
+ * Start (or reuse) the distilled-event sink for a session.
736
+ *
737
+ * Runs on **every** page load, not just sampled-for-replay ones: page views and
738
+ * `track()` calls are the analytics substrate, and gating them behind replay
739
+ * sampling would make unique visitors and top pages a sample rather than a
740
+ * count. The rrweb recorder stays sampled — it is the expensive part.
741
+ */
742
+ function startEventSink(config, sessionId) {
743
+ const existing = holder()[SINK_KEY];
744
+ if (existing && existing.sessionId === sessionId) return existing.sink;
745
+ if (existing) {
746
+ existing.sink.flush();
747
+ existing.sink.stop();
748
+ }
749
+ let buffer = [];
750
+ let bufferBytes = 0;
751
+ let seq = 0;
752
+ let pageViews = 0;
753
+ let clickCount = 0;
754
+ let errorCount = 0;
755
+ const flush = async (keepalive = false) => {
756
+ if (buffer.length === 0) return;
757
+ const batch = buffer;
758
+ buffer = [];
759
+ bufferBytes = 0;
760
+ await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
761
+ };
762
+ const emit = (ev) => {
763
+ const session = markActivity();
764
+ if (session && session.id !== sessionId) {
765
+ const nextSink = startEventSink(config, session.id);
766
+ if (ev.type !== "navigation") nextSink.emit(ev);
767
+ return;
768
+ }
769
+ if (ev.type === "navigation") {
770
+ pageViews++;
771
+ noteNavigation(ev.url ?? (typeof location !== "undefined" ? location.href : ""));
772
+ } else if (ev.type === "click") clickCount++;
773
+ else if (ev.type === "error" || ev.type === "console" && ev.level === "error") errorCount++;
774
+ buffer.push({
775
+ ev,
776
+ seq: seq++
777
+ });
778
+ bufferBytes += approximateSize(ev);
779
+ if (bufferBytes >= FLUSH_BYTES) flush();
780
+ };
781
+ const stopNavigation = installNavigationObserver((url) => {
782
+ emit({
783
+ type: "navigation",
784
+ url
785
+ });
786
+ });
787
+ const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
788
+ const sink = {
789
+ sessionId,
790
+ emit,
791
+ flush,
792
+ stop: () => {
793
+ clearInterval(flushTimer);
794
+ stopNavigation();
795
+ if (holder()[SINK_KEY]?.sink === sink) holder()[SINK_KEY] = void 0;
796
+ },
797
+ getPageViews: () => pageViews,
798
+ getClickCount: () => clickCount,
799
+ getErrorCount: () => errorCount,
800
+ ignoreUrl: (url) => url.startsWith(`${config.endpoint}/v1/`)
801
+ };
802
+ holder()[SINK_KEY] = {
803
+ sessionId,
804
+ sink
805
+ };
806
+ drainPending(sink);
807
+ return sink;
808
+ }
809
+ /**
810
+ * Watch page views: the initial load plus every SPA navigation (history
811
+ * pushState/replaceState, popstate, hashchange).
812
+ */
813
+ function installNavigationObserver(onNavigate) {
814
+ if (typeof window === "undefined" || typeof history === "undefined") return () => {};
815
+ let lastUrl = "";
816
+ const emitNav = () => {
817
+ const url = location.href;
818
+ if (url === lastUrl) return;
819
+ lastUrl = url;
820
+ onNavigate(url);
821
+ };
822
+ emitNav();
823
+ const origPush = history.pushState;
824
+ const origReplace = history.replaceState;
825
+ history.pushState = function(...args) {
826
+ const result = origPush.apply(this, args);
827
+ emitNav();
828
+ return result;
829
+ };
830
+ history.replaceState = function(...args) {
831
+ const result = origReplace.apply(this, args);
832
+ emitNav();
833
+ return result;
834
+ };
835
+ window.addEventListener("popstate", emitNav);
836
+ window.addEventListener("hashchange", emitNav);
837
+ return () => {
838
+ history.pushState = origPush;
839
+ history.replaceState = origReplace;
840
+ window.removeEventListener("popstate", emitNav);
841
+ window.removeEventListener("hashchange", emitNav);
842
+ };
843
+ }
844
+ const PENDING_KEY = "__MAPLE_SESSION_EVENT_PENDING__";
845
+ const MAX_PENDING_EVENTS = 100;
846
+ const MAX_PENDING_BYTES = 65536;
847
+ function pending() {
848
+ const global = globalThis;
849
+ let state = global[PENDING_KEY];
850
+ if (!state) {
851
+ state = {
852
+ events: [],
853
+ bytes: 0,
854
+ warned: false
855
+ };
856
+ global[PENDING_KEY] = state;
857
+ }
858
+ return state;
859
+ }
860
+ /**
861
+ * Buffer an event until a sink exists. Drops oldest-first at the cap: an app
862
+ * that calls `track()` in a loop before init should not be able to grow this
863
+ * without bound.
864
+ */
865
+ function queuePending(ev) {
866
+ const state = pending();
867
+ state.events.push(ev);
868
+ state.bytes += approximateSize(ev);
869
+ while (state.events.length > MAX_PENDING_EVENTS || state.bytes > MAX_PENDING_BYTES && state.events.length > 1) {
870
+ const dropped = state.events.shift();
871
+ if (!dropped) break;
872
+ state.bytes -= approximateSize(dropped);
873
+ if (!state.warned) {
874
+ state.warned = true;
875
+ console.warn("[maple] dropping session events queued before init — call Maple.init/identify earlier, or track() less before the SDK is ready.");
876
+ }
877
+ }
878
+ }
879
+ /** Discard events captured before a consent-gated SDK was configured. */
880
+ function clearPendingEvents() {
881
+ const state = pending();
882
+ state.events = [];
883
+ state.bytes = 0;
884
+ }
885
+ /** Hand everything queued before init to the sink, oldest first. */
886
+ function drainPending(sink) {
887
+ const state = pending();
888
+ if (state.events.length === 0) return;
889
+ const queued = state.events;
890
+ state.events = [];
891
+ state.bytes = 0;
892
+ for (const ev of queued) sink.emit(ev);
893
+ }
894
+ /** Map an internal event to the snake_case ingest row (org_id is added server-side). */
895
+ function toRow(sessionId, ev, seq) {
896
+ return {
897
+ session_id: sessionId,
898
+ timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
899
+ seq,
900
+ type: ev.type,
901
+ url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
902
+ trace_id: ev.traceId ?? activeTraceId() ?? "",
903
+ level: ev.level ?? "",
904
+ message: ev.message ?? "",
905
+ target_selector: ev.targetSelector ?? "",
906
+ target_text: ev.targetText ?? "",
907
+ net_method: ev.net?.method ?? "",
908
+ net_url: ev.net?.url ?? "",
909
+ net_status: ev.net?.status ?? 0,
910
+ net_duration_ms: ev.net?.durationMs ?? 0,
911
+ error_stack: ev.errorStack ?? "",
912
+ attributes: ev.attrs ?? {}
913
+ };
914
+ }
915
+ //#endregion
916
+ //#region ../browser-session/src/session-lifecycle.ts
917
+ /**
918
+ * How often a visible tab re-posts its `active` row.
919
+ *
920
+ * This is what makes exit page, page views and duration survive a tab killed
921
+ * without an unload beacon — otherwise the only surviving row is the v1 row
922
+ * posted at session start, whose counters are all zero and therefore read as a
923
+ * bounce. 60s is the floor worth using: the table is a ReplacingMergeTree, so
924
+ * every heartbeat is an unmerged part until the next merge. Billing meters only
925
+ * `version == 1` rows, so heartbeats do not double-bill.
926
+ */
927
+ const HEARTBEAT_INTERVAL_MS = 6e4;
928
+ /**
929
+ * Drive one session's metadata lifecycle. Returns undefined outside a browser;
930
+ * sampling and consent are the caller's decisions.
931
+ */
932
+ function startSessionLifecycle(options, hooks) {
933
+ if (typeof window === "undefined") return void 0;
934
+ let current = getSession();
935
+ let stopped = false;
936
+ /** Whether a run is live: capture started, `active` posted, heartbeat armed. */
937
+ let running = false;
938
+ let heartbeat;
939
+ let clickCountBase = 0;
940
+ let errorCountBase = 0;
941
+ let sinkClickCountAtStart = 0;
942
+ let sinkErrorCountAtStart = 0;
943
+ /**
944
+ * The persisted record of the session this lifecycle owns.
945
+ *
946
+ * `current` fixes only the identity of the run — counters, `lastUrl` and
947
+ * `lastActivityAt` move underneath it as the page is used, so a row built
948
+ * from the captured object alone reports the exit path and page views of the
949
+ * session's *first* page forever. Reading storage back per row keeps them
950
+ * live, and the id check keeps a rotation from attributing the incoming
951
+ * session's context to the outgoing session's `ended` row.
952
+ */
953
+ const liveRecord = () => {
954
+ const stored = peekSession();
955
+ return stored?.id === current.id ? stored : current;
956
+ };
957
+ /**
958
+ * Re-baseline the cumulative counters against the persisted record.
959
+ *
960
+ * The record is what carries counts across a reload and across a hide/resume
961
+ * cycle (each `ended` row writes them back via `noteCounts`), while live
962
+ * capture restarts from zero every run — so a run's row is the persisted base
963
+ * plus what capture has seen since this run began.
964
+ */
965
+ const rebaseCounts = (record) => {
966
+ clickCountBase = record.clickCount ?? 0;
967
+ errorCountBase = record.errorCount ?? 0;
968
+ const sink = getActiveSink();
969
+ sinkClickCountAtStart = sink?.getClickCount() ?? 0;
970
+ sinkErrorCountAtStart = sink?.getErrorCount() ?? 0;
971
+ };
972
+ const countsFor = (record) => {
973
+ const sink = getActiveSink();
974
+ const sinkMatches = sink !== void 0 && sink.sessionId === record.id;
975
+ const clicks = hooks.clicksSinceStart?.() ?? (sinkMatches ? sink.getClickCount() - sinkClickCountAtStart : 0);
976
+ const errors = sinkMatches ? sink.getErrorCount() - sinkErrorCountAtStart : 0;
977
+ const counts = {
978
+ clickCount: clickCountBase + Math.max(0, clicks),
979
+ errorCount: errorCountBase + Math.max(0, errors),
980
+ pageViews: record.pageViews ?? (sinkMatches ? sink.getPageViews() : 0)
981
+ };
982
+ if (peekSession()?.id === record.id) noteCounts({
983
+ clickCount: counts.clickCount,
984
+ errorCount: counts.errorCount
985
+ });
986
+ return counts;
987
+ };
988
+ const post = (status, keepalive) => {
989
+ const record = liveRecord();
990
+ const counts = countsFor(record);
991
+ hooks.post(buildSessionMetaRow({
992
+ sessionId: record.id,
993
+ startedAt: new Date(record.startedAt),
994
+ version: nextMetaVersion(),
995
+ status,
996
+ serviceName: options.serviceName,
997
+ identity: options.getIdentity?.(),
998
+ captureUserEmail: options.captureUserEmail,
999
+ environment: options.environment,
1000
+ serviceVersion: options.serviceVersion,
1001
+ visitorId: getVisitorId(),
1002
+ visitorIsNew: record.visitorIsNew === true,
1003
+ visitorIdPersisted: isVisitorIdPersisted(),
1004
+ entry: entryContextOf(record),
1005
+ lastUrl: record.lastUrl,
1006
+ clickCount: counts.clickCount,
1007
+ pageViews: counts.pageViews,
1008
+ errorCount: counts.errorCount,
1009
+ traceIds: status === "ended" ? options.getTraceIds?.(record.id) : void 0,
1010
+ recorded: hooks.recorded
1011
+ }), keepalive);
1012
+ };
1013
+ const stopHeartbeat = () => {
1014
+ if (heartbeat === void 0) return;
1015
+ clearInterval(heartbeat);
1016
+ heartbeat = void 0;
1017
+ };
1018
+ const startRun = () => {
1019
+ if (stopped || running) return;
1020
+ running = true;
1021
+ const record = liveRecord();
1022
+ rebaseCounts(record);
1023
+ hooks.onStart?.(record);
1024
+ post("active", false);
1025
+ heartbeat = setInterval(() => {
1026
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
1027
+ if (isSessionExpired(liveRecord())) {
1028
+ endRun({
1029
+ flush: true,
1030
+ keepalive: false
1031
+ });
1032
+ return;
1033
+ }
1034
+ post("active", false);
1035
+ }, HEARTBEAT_INTERVAL_MS);
1036
+ };
1037
+ const endRun = (suspend) => {
1038
+ if (!running) return;
1039
+ running = false;
1040
+ stopHeartbeat();
1041
+ if (suspend.flush) post("ended", suspend.keepalive);
1042
+ return hooks.onSuspend?.(suspend);
1043
+ };
1044
+ const stopRotationListener = onSessionRotate((previous, next) => {
1045
+ if (stopped || previous.id !== current.id) return;
1046
+ endRun({
1047
+ flush: true,
1048
+ keepalive: false
1049
+ });
1050
+ current = next;
1051
+ hooks.onSessionChange?.(next.id);
1052
+ queueMicrotask(() => startRun());
1053
+ });
1054
+ const onPageHide = () => void endRun({
1055
+ flush: true,
1056
+ keepalive: true
1057
+ });
1058
+ const onVisibilityChange = () => {
1059
+ const doc = globalThis["document"];
1060
+ if (!doc) return;
1061
+ if (doc.visibilityState === "hidden") {
1062
+ endRun({
1063
+ flush: true,
1064
+ keepalive: true
1065
+ });
1066
+ return;
1067
+ }
1068
+ if (isSessionExpired(liveRecord())) {
1069
+ getSession();
1070
+ return;
1071
+ }
1072
+ startRun();
1073
+ };
1074
+ const visibilityTarget = typeof document !== "undefined" && typeof document.addEventListener === "function" ? document : typeof globalThis.addEventListener === "function" ? globalThis : void 0;
1075
+ const pageHideTarget = typeof globalThis.addEventListener === "function" ? globalThis : void 0;
1076
+ startRun();
1077
+ visibilityTarget?.addEventListener("visibilitychange", onVisibilityChange);
1078
+ pageHideTarget?.addEventListener("pagehide", onPageHide);
1079
+ return {
1080
+ get sessionId() {
1081
+ return current.id;
1082
+ },
1083
+ shutdown: async (shutdownOptions) => {
1084
+ if (stopped) return;
1085
+ stopped = true;
1086
+ stopRotationListener();
1087
+ visibilityTarget?.removeEventListener("visibilitychange", onVisibilityChange);
1088
+ pageHideTarget?.removeEventListener("pagehide", onPageHide);
1089
+ const flush = shutdownOptions?.flush ?? true;
1090
+ await endRun({
1091
+ flush,
1092
+ keepalive: flush
1093
+ });
1094
+ }
1095
+ };
1096
+ }
1097
+ //#endregion
1098
+ //#region ../browser-session/src/sink.ts
1099
+ const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
1100
+ const observedTraceIdsBySession = /* @__PURE__ */ new Map();
1101
+ /** Record a trace id seen during the session. Idempotent per id. */
1102
+ function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
1103
+ if (!sessionId) return;
1104
+ let ids = observedTraceIdsBySession.get(sessionId);
1105
+ if (!ids) {
1106
+ ids = /* @__PURE__ */ new Set();
1107
+ observedTraceIdsBySession.set(sessionId, ids);
1108
+ }
1109
+ ids.add(traceId);
1110
+ }
1111
+ function getObservedTraceIds(sessionId = readSessionSink()?.sessionId) {
1112
+ return sessionId ? Array.from(observedTraceIdsBySession.get(sessionId) ?? []) : [];
1113
+ }
1114
+ /**
1115
+ * Drop the trace ids of every session but `sessionId`. Called when the sink is
1116
+ * republished under a rotated id — the outgoing session's `ended` row, the only
1117
+ * reader of its ids, has already been built by then. Without this a tab left
1118
+ * open for a day accumulates one id Set per 30-minute rotation, forever.
1119
+ */
1120
+ function forgetOtherSessions(sessionId) {
1121
+ for (const key of observedTraceIdsBySession.keys()) if (key !== sessionId) observedTraceIdsBySession.delete(key);
1122
+ }
1123
+ /**
1124
+ * Publish the session sink on `globalThis` so other tracers in the page can
1125
+ * attach their trace ids to the active replay session without a direct
1126
+ * dependency on the publishing SDK. Reads are lazy/per-span on the consumer
1127
+ * side, so init ordering between SDKs does not matter.
1128
+ */
1129
+ function publishSessionSink(sessionId) {
1130
+ globalThis[SESSION_SINK_KEY] = {
1131
+ sessionId,
1132
+ recordTraceId: (traceId) => recordTraceId(traceId, sessionId)
1133
+ };
1134
+ forgetOtherSessions(sessionId);
1135
+ }
1136
+ /** Remove a sink published by this SDK runtime without clobbering a newer one. */
1137
+ function clearSessionSink(sessionId) {
1138
+ const owner = globalThis;
1139
+ const current = owner[SESSION_SINK_KEY];
1140
+ if (!current || sessionId !== void 0 && current.sessionId !== sessionId) return;
1141
+ delete owner[SESSION_SINK_KEY];
1142
+ observedTraceIdsBySession.delete(current.sessionId);
1143
+ }
1144
+ /** Look up the published sink, if any page-level replay session is active. */
1145
+ function readSessionSink() {
1146
+ return globalThis[SESSION_SINK_KEY];
1147
+ }
1148
+ //#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 };