@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.
package/dist/index.mjs CHANGED
@@ -1,737 +1,304 @@
1
+ import { S as setVisitorTracking, a as recordTraceId, b as postSessionMetaRow, c as clearPendingEvents, d as setActiveTraceIdProvider, f as startEventSink, g as rotateSession, i as readSessionSink, l as getActiveSink, n as getObservedTraceIds, o as startSessionLifecycle, p as getSession, r as publishSessionSink, t as clearSessionSink, u as queuePending, x as configureVisitorCookie } from "./sink-DHTfvFgl.mjs";
1
2
  import { trace } from "@opentelemetry/api";
2
- import { record } from "rrweb";
3
- import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
4
- import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
5
3
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
6
- import { resourceFromAttributes } from "@opentelemetry/resources";
7
4
  import { registerInstrumentations } from "@opentelemetry/instrumentation";
8
5
  import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
6
+ import { resourceFromAttributes } from "@opentelemetry/resources";
7
+ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
8
+ import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
9
9
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
10
- //#region ../browser-session/src/session.ts
11
- const STORAGE_KEY = "maple.session";
12
- /** Rotate the session after this much inactivity (PostHog's default). */
13
- const IDLE_TIMEOUT_MS = 30 * 6e4;
14
- /** Hard cap on a single session's lifetime regardless of activity. */
15
- const MAX_SESSION_MS = 1440 * 6e4;
16
- /** In-memory fallback when sessionStorage is unavailable (private mode). */
17
- let ephemeral;
18
- function freshRecord(now) {
19
- return {
20
- id: crypto.randomUUID(),
21
- startedAt: now,
22
- lastActivityAt: now,
23
- chunkSeq: 0,
24
- metaVersion: 0
25
- };
26
- }
27
- function readRecord() {
28
- try {
29
- const raw = window.sessionStorage.getItem(STORAGE_KEY);
30
- if (!raw) return void 0;
31
- const parsed = JSON.parse(raw);
32
- if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
33
- return;
34
- } catch {
35
- return ephemeral;
36
- }
37
- }
38
- function writeRecord(record) {
39
- ephemeral = record;
40
- try {
41
- window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
42
- } catch {}
43
- }
44
- function isExpired(record, now) {
45
- return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
46
- }
10
+ //#region ../browser-session/src/consent.ts
47
11
  /**
48
- * Resolve the active session, rotating to a fresh one if the previous session
49
- * has gone idle (or hit the lifetime cap). Touches `lastActivityAt` so calling
50
- * it on page load keeps a live session alive. The id is the correlation key
51
- * shared by OTel traces and replay events.
12
+ * Consent gating for session capture.
13
+ *
14
+ * The posture, deliberately:
15
+ *
16
+ * - `requireConsent` defaults to **false**. Flipping that default would
17
+ * silently stop telemetry for every existing install — an availability
18
+ * incident dressed up as a privacy win. Apps that need a consent gate opt in.
19
+ * - Global Privacy Control **is** honored by default, but only against the
20
+ * persistent visitor id, not the session itself. GPC is a legally recognized
21
+ * signal in several US states and it maps exactly onto the one cross-session
22
+ * identifier this SDK stores; the session-scoped capture that powers replay
23
+ * and error triage is unaffected.
24
+ * - `doNotTrack` is **not** honored by default. It is deprecated, removed from
25
+ * Safari, and ignored across the industry, so respecting it would mostly mean
26
+ * dropping data from users who never intended to opt out. Apps can turn it on.
52
27
  */
53
- function getSession() {
54
- const now = Date.now();
55
- const existing = readRecord();
56
- const record = existing && !isExpired(existing, now) ? {
57
- ...existing,
58
- lastActivityAt: now
59
- } : freshRecord(now);
60
- writeRecord(record);
61
- return record;
62
- }
63
- /** Mark the session as active right now (called as replay chunks flush). */
64
- function markActivity() {
65
- const record = readRecord();
66
- if (!record) return;
67
- writeRecord({
68
- ...record,
69
- lastActivityAt: Date.now()
70
- });
71
- }
72
28
  /**
73
- * Take the next replay chunk sequence number for the current session. Monotonic
74
- * across reloads (persisted on the session record), so a refresh continues the
75
- * sequence instead of restarting at 0 and overwriting the previous load's blobs.
29
+ * Consent lives on `globalThis`, for the same reason the event sink does
30
+ * (`events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
31
+ * Effect SDK gets **two copies** of this module, and per-copy state would mean
32
+ * `setConsent(true)` on one never releases the other copy's exporters — or, the
33
+ * other way round, one copy capturing while the other believes consent is
34
+ * required. There is one user and one decision, so there is one state.
76
35
  */
77
- function nextChunkSeq() {
78
- const record = readRecord() ?? freshRecord(Date.now());
79
- const seq = record.chunkSeq;
80
- writeRecord({
81
- ...record,
82
- chunkSeq: seq + 1
83
- });
84
- return seq;
36
+ const CONSENT_KEY = "__MAPLE_BROWSER_CONSENT__";
37
+ function consentState() {
38
+ const owner = globalThis;
39
+ const existing = owner[CONSENT_KEY];
40
+ if (existing) return existing;
41
+ const fresh = {
42
+ allowedSince: 0,
43
+ granted: false,
44
+ listeners: /* @__PURE__ */ new Set(),
45
+ requireConsent: false,
46
+ respectDoNotTrack: false
47
+ };
48
+ owner[CONSENT_KEY] = fresh;
49
+ return fresh;
50
+ }
51
+ function updateEffectiveConsent(previous) {
52
+ const state = consentState();
53
+ const allowed = hasConsent();
54
+ if (allowed === previous) return;
55
+ state.allowedSince = allowed ? Date.now() : Number.POSITIVE_INFINITY;
56
+ for (const listener of state.listeners) try {
57
+ listener(allowed);
58
+ } catch {}
85
59
  }
86
60
  /**
87
- * Take the next session-metadata row version for the current session.
88
- * Monotonic per session across reloads, hide/resume cycles, and writers (both
89
- * SDKs share the persisted counter), so `argMax(field, Version)` on the
90
- * backend always resolves to the most recently posted row. Records written by
91
- * older SDKs (no `metaVersion`) already posted versions 1 and 2, so the
92
- * counter resumes at 3 for them; a fresh session starts at 1.
61
+ * Apply the host app's privacy config. Call before anything captures.
62
+ *
63
+ * Both SDKs call this at startup, and an app can init one of them without a
64
+ * `privacy` block — so the gates only ever **tighten**. Letting the second
65
+ * caller's absent option reset `requireConsent` back to false would silently
66
+ * disable a gate the first caller asked for, and the resulting
67
+ * denied→allowed transition would start capture on a user who never consented.
68
+ * To genuinely relax a gate, reload without it.
93
69
  */
94
- function nextMetaVersion() {
95
- const record = readRecord() ?? freshRecord(Date.now());
96
- const version = (record.metaVersion ?? 2) + 1;
97
- writeRecord({
98
- ...record,
99
- metaVersion: version
70
+ function configurePrivacy(options) {
71
+ const state = consentState();
72
+ const previous = hasConsent();
73
+ state.requireConsent = state.requireConsent || (options?.requireConsent ?? false);
74
+ state.respectDoNotTrack = state.respectDoNotTrack || (options?.respectDoNotTrack ?? false);
75
+ configureVisitorCookie({
76
+ crossSubdomainCookie: options?.crossSubdomainCookie,
77
+ cookieDomain: options?.cookieDomain
100
78
  });
101
- return version;
79
+ updateEffectiveConsent(previous);
102
80
  }
103
- //#endregion
104
- //#region ../browser-session/src/sink.ts
105
- const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
106
- const observedTraceIds = /* @__PURE__ */ new Set();
107
- /** Record a trace id seen during the session. Idempotent per id. */
108
- function recordTraceId(traceId) {
109
- observedTraceIds.add(traceId);
81
+ /** Record the user's consent decision. No-op unless `requireConsent` is set. */
82
+ function setConsent(nextGranted) {
83
+ const previous = hasConsent();
84
+ consentState().granted = nextGranted;
85
+ updateEffectiveConsent(previous);
110
86
  }
111
- function getObservedTraceIds() {
112
- return Array.from(observedTraceIds);
87
+ /** Whether capture may proceed at all. */
88
+ function hasConsent() {
89
+ const state = consentState();
90
+ return !state.requireConsent || state.granted;
113
91
  }
114
92
  /**
115
- * Publish the session sink on `globalThis` so other tracers in the page can
116
- * attach their trace ids to the active replay session without a direct
117
- * dependency on the publishing SDK. Reads are lazy/per-span on the consumer
118
- * side, so init ordering between SDKs does not matter.
93
+ * Subscribe to effective capture permission changes. The listener is invoked
94
+ * only when capture actually transitions between allowed and denied; changing
95
+ * the stored decision while `requireConsent` is off is deliberately silent.
119
96
  */
120
- function publishSessionSink(sessionId) {
121
- globalThis[SESSION_SINK_KEY] = {
122
- sessionId,
123
- recordTraceId
124
- };
125
- }
126
- //#endregion
127
- //#region ../browser-session/src/user-agent.ts
128
- /** Best-effort UA parse — enough to populate filterable session facets. */
129
- function parseUserAgent(ua) {
130
- return {
131
- 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",
132
- 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",
133
- deviceType: /mobile|iphone|android.*mobile/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
134
- };
135
- }
136
- //#endregion
137
- //#region ../browser-session/src/meta-row.ts
138
- /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
139
- function formatCHDateTime(date) {
140
- const pad = (n, width = 2) => String(n).padStart(width, "0");
141
- return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
97
+ function onConsentChange(listener) {
98
+ const { listeners } = consentState();
99
+ listeners.add(listener);
100
+ return () => listeners.delete(listener);
142
101
  }
143
102
  /**
144
- * Build one `/v1/sessionReplays/meta` NDJSON row. Shared by `@maple-dev/browser`
145
- * and the Effect client SDK so a session looks identical no matter which SDK
146
- * posted it. UA/URL facets come from the live browser globals; absent (tests,
147
- * exotic embedders) they fall back to empty strings.
103
+ * Earliest epoch-ms timestamp telemetry may export for the current grant.
104
+ * `Infinity` means capture is denied. Exporters use this to discard anything
105
+ * buffered before a late grant or across a revoke/re-grant cycle.
148
106
  */
149
- function buildSessionMetaRow(input) {
150
- const g = globalThis;
151
- const userAgent = g["navigator"]?.userAgent ?? "";
152
- const ua = parseUserAgent(userAgent);
153
- const now = /* @__PURE__ */ new Date();
154
- const row = {
155
- session_id: input.sessionId,
156
- start_time: formatCHDateTime(input.startedAt),
157
- status: input.status,
158
- version: input.version,
159
- user_id: input.userId ?? "",
160
- url_initial: g["window"]?.location?.href ?? "",
161
- user_agent: userAgent,
162
- browser_name: ua.browserName,
163
- os_name: ua.osName,
164
- device_type: ua.deviceType,
165
- service_name: input.serviceName,
166
- resource_attributes: {
167
- ...input.environment ? {
168
- "deployment.environment": input.environment,
169
- "deployment.environment.name": input.environment
170
- } : {},
171
- ...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : {}
172
- }
173
- };
174
- if (input.status === "ended") {
175
- row.end_time = formatCHDateTime(now);
176
- row.duration_ms = Math.max(0, now.getTime() - input.startedAt.getTime());
177
- row.click_count = input.clickCount ?? 0;
178
- row.trace_ids = input.traceIds ? Array.from(input.traceIds) : [];
179
- }
180
- return row;
181
- }
182
- //#endregion
183
- //#region ../browser-session/src/replay/transport.ts
184
- let lastWarnAt = 0;
185
- function warnDropped(what, error) {
186
- const now = Date.now();
187
- if (now - lastWarnAt < 3e4) return;
188
- lastWarnAt = now;
189
- console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
190
- }
191
- /** gzip a byte buffer using the native CompressionStream (no library). */
192
- async function gzip(bytes) {
193
- const stream = new CompressionStream("gzip");
194
- const writer = stream.writable.getWriter();
195
- writer.write(bytes);
196
- writer.close();
197
- const buffer = await new Response(stream.readable).arrayBuffer();
198
- return new Uint8Array(buffer);
107
+ function consentAllowedSince() {
108
+ return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
199
109
  }
200
- /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
201
- async function postSessionMeta(config, row, keepalive = false) {
202
- const body = `${JSON.stringify(row)}\n`;
203
- await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
204
- method: "POST",
205
- headers: {
206
- Authorization: `Bearer ${config.ingestKey}`,
207
- "content-type": "application/x-ndjson"
208
- },
209
- body,
210
- keepalive
211
- }).catch((error) => {
212
- warnDropped("metadata POST", error);
213
- });
214
- }
215
- /** POST distilled session events (NDJSON, one row per event). Best-effort. */
216
- async function postSessionEvents(config, rows, keepalive = false) {
217
- if (rows.length === 0) return;
218
- const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
219
- await fetch(`${config.endpoint}/v1/sessionEvents`, {
220
- method: "POST",
221
- headers: {
222
- Authorization: `Bearer ${config.ingestKey}`,
223
- "content-type": "application/x-ndjson"
224
- },
225
- body,
226
- keepalive
227
- }).catch((error) => {
228
- warnDropped("events POST", error);
229
- });
230
- }
231
- /** PUT a gzipped rrweb event chunk. */
232
- async function postSessionBlob(config, meta, gzipped, keepalive = false) {
233
- await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
234
- method: "POST",
235
- headers: {
236
- Authorization: `Bearer ${config.ingestKey}`,
237
- "content-type": "application/octet-stream",
238
- "x-maple-session-id": meta.sessionId,
239
- "x-maple-chunk-seq": String(meta.chunkSeq),
240
- "x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
241
- "x-maple-event-count": String(meta.eventCount),
242
- "x-maple-duration-ms": String(meta.durationMs)
243
- },
244
- body: gzipped,
245
- keepalive
246
- }).catch((error) => {
247
- warnDropped("blob PUT", error);
248
- });
249
- }
250
- //#endregion
251
- //#region ../browser-session/src/replay/capture/shared.ts
252
- /** Emit best-effort: capture must never throw into the host app's call site. */
253
- function safeEmit(emit, ev) {
254
- try {
255
- emit(ev);
256
- } catch {}
257
- }
258
- //#endregion
259
- //#region ../browser-session/src/replay/capture/console.ts
260
- const LEVELS = [
261
- "log",
262
- "info",
263
- "warn",
264
- "error",
265
- "debug"
266
- ];
267
- const MAX_MESSAGE = 2e3;
268
110
  /**
269
- * Capture `console.*` calls as session events. Wraps each method, emits a
270
- * distilled record, then forwards to the original so the host app's console
271
- * behaves normally. Never throws into the call site.
111
+ * Whether a *persistent* identifier may be stored. Separate from `hasConsent`
112
+ * because GPC/DNT suppress the cross-session id without suppressing capture.
272
113
  */
273
- function installConsoleCapture(emit) {
274
- const original = {};
275
- for (const level of LEVELS) {
276
- const orig = console[level];
277
- original[level] = orig;
278
- console[level] = (...args) => {
279
- safeEmit(emit, {
280
- type: "console",
281
- level,
282
- message: formatArgs(args)
283
- });
284
- orig.apply(console, args);
285
- };
286
- }
287
- return () => {
288
- for (const level of LEVELS) {
289
- const orig = original[level];
290
- if (orig) console[level] = orig;
291
- }
292
- };
293
- }
294
- function formatArgs(args) {
295
- const text = args.map((a) => {
296
- if (typeof a === "string") return a;
297
- if (a instanceof Error) return `${a.name}: ${a.message}`;
298
- try {
299
- return JSON.stringify(a);
300
- } catch {
301
- return String(a);
302
- }
303
- }).join(" ");
304
- return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
114
+ function mayPersistIdentifier() {
115
+ if (!hasConsent()) return false;
116
+ if (typeof navigator === "undefined") return true;
117
+ const nav = navigator;
118
+ if (nav.globalPrivacyControl === true) return false;
119
+ if (consentState().respectDoNotTrack && nav.doNotTrack === "1") return false;
120
+ return true;
305
121
  }
306
122
  //#endregion
307
- //#region ../browser-session/src/replay/capture/network.ts
123
+ //#region ../browser-session/src/identity.ts
308
124
  /**
309
- * Capture fetch + XHR requests as session events, tagged with the active trace
310
- * id so each request links to its backend trace. `ignoreUrl` skips Maple's own
311
- * ingest endpoints (otherwise capturing the session-events POST would loop).
125
+ * Trait caps. The warehouse column is `Map(String, String)` — plain keys
126
+ * precisely because they are arbitrary — so the cost of a unique key per user
127
+ * is row size and query-time map width rather than a churned dictionary. The
128
+ * cap bounds one row's damage; `warnOnIdLikeTraitKey` surfaces the pattern to
129
+ * whoever wrote it, since an id in a trait key is nearly always a mistake.
312
130
  */
313
- function installNetworkCapture(emit, ignoreUrl) {
314
- const origFetch = typeof window !== "undefined" ? window.fetch : void 0;
315
- if (origFetch) window.fetch = async (input, init) => {
316
- const url = requestUrl(input);
317
- const method = requestMethod(input, init);
318
- const traceId = activeTraceId();
319
- const start = performance.now();
320
- try {
321
- const res = await origFetch(input, init);
322
- record(url, method, res.status, start, traceId);
323
- return res;
324
- } catch (error) {
325
- record(url, method, 0, start, traceId, String(error));
326
- throw error;
131
+ const MAX_TRAITS = 24;
132
+ const MAX_TRAIT_KEY_LENGTH = 64;
133
+ const MAX_TRAIT_VALUE_LENGTH = 256;
134
+ let warnedAboutTraitKeys = false;
135
+ /** Heuristic: a trait *key* that looks like an id is almost always a mistake. */
136
+ function looksLikeId(key) {
137
+ return /^[0-9a-f]{8,}$/i.test(key) || /^(usr|user|org|cus|acct)_/i.test(key);
138
+ }
139
+ function coerceTraitValue(value) {
140
+ if (value === null || value === void 0) return void 0;
141
+ return (typeof value === "string" ? value : String(value)).slice(0, MAX_TRAIT_VALUE_LENGTH);
142
+ }
143
+ function normalizeTraits(traits) {
144
+ if (!traits) return {};
145
+ const out = {};
146
+ for (const [rawKey, rawValue] of Object.entries(traits)) {
147
+ if (Object.keys(out).length >= MAX_TRAITS) break;
148
+ const value = coerceTraitValue(rawValue);
149
+ if (value === void 0) continue;
150
+ const key = rawKey.slice(0, MAX_TRAIT_KEY_LENGTH);
151
+ if (!key) continue;
152
+ if (!warnedAboutTraitKeys && looksLikeId(key)) {
153
+ warnedAboutTraitKeys = true;
154
+ console.warn(`[maple] identity trait key "${key}" looks like an id. Trait keys share a ClickHouse dictionary — put ids in the value, or in id/groupId, not the key.`);
327
155
  }
328
- };
329
- const record = (url, method, status, start, traceId, error) => {
330
- if (ignoreUrl(url)) return;
331
- safeEmit(emit, {
332
- type: "network",
333
- net: {
334
- method,
335
- url,
336
- status,
337
- durationMs: Math.round(performance.now() - start)
338
- },
339
- traceId,
340
- ...error ? { attrs: { error } } : {}
341
- });
342
- };
343
- const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
344
- const origOpen = XHR?.prototype.open;
345
- const origSend = XHR?.prototype.send;
346
- if (XHR && origOpen && origSend) {
347
- XHR.prototype.open = function(method, url, ...rest) {
348
- this.__mapleMethod = String(method).toUpperCase();
349
- this.__mapleUrl = typeof url === "string" ? url : url.href;
350
- return origOpen.apply(this, [
351
- method,
352
- url,
353
- ...rest
354
- ]);
355
- };
356
- XHR.prototype.send = function(...args) {
357
- const meta = this;
358
- const start = performance.now();
359
- const traceId = activeTraceId();
360
- this.addEventListener("loadend", () => {
361
- record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
362
- });
363
- return origSend.apply(this, args);
364
- };
156
+ out[key] = value;
365
157
  }
366
- return () => {
367
- if (origFetch) window.fetch = origFetch;
368
- if (XHR && origOpen) XHR.prototype.open = origOpen;
369
- if (XHR && origSend) XHR.prototype.send = origSend;
370
- };
371
- }
372
- function requestUrl(input) {
373
- if (typeof input === "string") return input;
374
- if (input instanceof URL) return input.href;
375
- return input.url;
158
+ return out;
376
159
  }
377
- function requestMethod(input, init) {
378
- return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
160
+ function trimmed(value) {
161
+ const text = value?.trim();
162
+ return text ? text : void 0;
379
163
  }
380
- //#endregion
381
- //#region ../browser-session/src/replay/capture/errors.ts
382
- const MAX_STACK = 4e3;
383
- /** Capture uncaught errors + unhandled promise rejections as session events. */
384
- function installErrorCapture(emit) {
385
- const onError = (event) => {
386
- safeEmit(emit, {
387
- type: "error",
388
- level: "error",
389
- message: event.message || String(event.error ?? "Error"),
390
- errorStack: truncate(event.error?.stack),
391
- traceId: activeTraceId()
392
- });
393
- };
394
- const onRejection = (event) => {
395
- const reason = event.reason;
396
- safeEmit(emit, {
397
- type: "error",
398
- level: "error",
399
- message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
400
- errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
401
- traceId: activeTraceId()
402
- });
403
- };
404
- window.addEventListener("error", onError);
405
- window.addEventListener("unhandledrejection", onRejection);
406
- return () => {
407
- window.removeEventListener("error", onError);
408
- window.removeEventListener("unhandledrejection", onRejection);
409
- };
410
- }
411
- function truncate(stack) {
412
- if (!stack) return void 0;
413
- return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
414
- }
415
- //#endregion
416
- //#region ../browser-session/src/replay/capture/navigation.ts
417
164
  /**
418
- * Capture page views as session events: the initial load plus every SPA
419
- * navigation (history pushState/replaceState, popstate, hashchange).
165
+ * Normalize whatever a host app passed into the shape the metadata row wants.
166
+ *
167
+ * Each call *replaces* the identity rather than merging into the previous one.
168
+ * Merging would leak a signed-out user's email into the next person to use a
169
+ * shared device, and replacement is what the existing string-only `identify`
170
+ * already did.
420
171
  */
421
- function installNavigationCapture(emit) {
422
- let lastUrl = "";
423
- const emitNav = () => {
424
- const url = location.href;
425
- if (url === lastUrl) return;
426
- lastUrl = url;
427
- safeEmit(emit, {
428
- type: "navigation",
429
- url
430
- });
431
- };
432
- emitNav();
433
- const origPush = history.pushState;
434
- const origReplace = history.replaceState;
435
- history.pushState = function(...args) {
436
- const result = origPush.apply(this, args);
437
- emitNav();
438
- return result;
439
- };
440
- history.replaceState = function(...args) {
441
- const result = origReplace.apply(this, args);
442
- emitNav();
443
- return result;
444
- };
445
- window.addEventListener("popstate", emitNav);
446
- window.addEventListener("hashchange", emitNav);
447
- return () => {
448
- history.pushState = origPush;
449
- history.replaceState = origReplace;
450
- window.removeEventListener("popstate", emitNav);
451
- window.removeEventListener("hashchange", emitNav);
172
+ function normalizeIdentity(input) {
173
+ if (input === null || input === void 0) return void 0;
174
+ if (typeof input === "string") {
175
+ const id = trimmed(input);
176
+ return id ? {
177
+ id,
178
+ traits: {}
179
+ } : void 0;
180
+ }
181
+ const resolved = {
182
+ id: trimmed(input.id),
183
+ email: trimmed(input.email),
184
+ username: trimmed(input.username),
185
+ groupId: trimmed(input.groupId),
186
+ groupName: trimmed(input.groupName),
187
+ traits: normalizeTraits(input.traits)
452
188
  };
189
+ return resolved.id || resolved.email || resolved.username || resolved.groupId || resolved.groupName || Object.keys(resolved.traits).length > 0 ? resolved : void 0;
453
190
  }
454
191
  //#endregion
455
- //#region ../browser-session/src/replay/capture/interactions.ts
456
- const MAX_TEXT = 120;
192
+ //#region ../browser-session/src/metadata-session.ts
457
193
  /**
458
- * Capture clicks and input events as session events. Listens in the capture
459
- * phase so it sees interactions even when the host app calls
460
- * `stopPropagation()`. Input *values* are never recorded; only the target
461
- * element. Click target text is omitted when `maskAllText` is set.
194
+ * Metadata-only session lifecycle used whenever rrweb is disabled or unsampled.
195
+ * It is the shared lifecycle driver with no capture of its own: the distilled
196
+ * event sink runs on every page load regardless, so this owner only has to post
197
+ * rows and flush that sink on the way out.
462
198
  */
463
- function installInteractionCapture(emit, maskAllText) {
464
- const onClick = (event) => {
465
- const target = event.target;
466
- if (!(target instanceof Element)) return;
467
- safeEmit(emit, {
468
- type: "click",
469
- targetSelector: selectorOf(target),
470
- targetText: maskAllText ? void 0 : textOf(target)
471
- });
472
- };
473
- const onInput = (event) => {
474
- const target = event.target;
475
- if (!(target instanceof Element)) return;
476
- safeEmit(emit, {
477
- type: "input",
478
- targetSelector: selectorOf(target)
479
- });
480
- };
481
- document.addEventListener("click", onClick, true);
482
- document.addEventListener("input", onInput, true);
483
- return () => {
484
- document.removeEventListener("click", onClick, true);
485
- document.removeEventListener("input", onInput, true);
486
- };
487
- }
488
- /** A short, human-readable selector: tag + #id + .first-class. */
489
- function selectorOf(el) {
490
- return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
491
- }
492
- function textOf(el) {
493
- const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
494
- if (!text) return void 0;
495
- return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
199
+ function startMetadataSession(options) {
200
+ return startSessionLifecycle(options, {
201
+ recorded: false,
202
+ post: (row, keepalive) => {
203
+ postSessionMetaRow(options.endpoint, options.ingestKey, row, keepalive);
204
+ },
205
+ onSuspend: ({ flush, keepalive }) => {
206
+ if (flush && keepalive) getActiveSink()?.flush(true);
207
+ },
208
+ onSessionChange: options.onSessionChange
209
+ });
496
210
  }
497
211
  //#endregion
498
- //#region ../browser-session/src/replay/util.ts
499
- /** Approximate byte size of an event for flush-threshold accounting. Falls back
500
- * to a fixed estimate for values that can't be serialized (e.g. cycles). */
501
- function approximateSize(value) {
212
+ //#region ../browser-session/src/track.ts
213
+ /**
214
+ * Caps mirroring what the ingest gateway enforces. Applying them here too means
215
+ * an over-sized event is trimmed before it costs bandwidth, and the developer
216
+ * sees the same shape locally that the warehouse will store.
217
+ */
218
+ const MAX_NAME_LENGTH = 128;
219
+ const MAX_PROPS = 32;
220
+ const MAX_PROP_KEY_LENGTH = 64;
221
+ const MAX_PROP_VALUE_LENGTH = 1024;
222
+ const MAX_TOTAL_PROP_BYTES = 8192;
223
+ let warnedAboutName = false;
224
+ /**
225
+ * Coerce one property value to the string the warehouse column holds.
226
+ *
227
+ * `null`/`undefined`/functions/symbols are dropped rather than stringified —
228
+ * `"undefined"` as a stored value is worse than an absent key.
229
+ */
230
+ function coerce(value) {
231
+ if (value === null || value === void 0) return void 0;
232
+ switch (typeof value) {
233
+ case "string": return value.slice(0, MAX_PROP_VALUE_LENGTH);
234
+ case "number":
235
+ case "boolean":
236
+ case "bigint": return String(value);
237
+ case "function":
238
+ case "symbol": return;
239
+ }
502
240
  try {
503
- return JSON.stringify(value).length;
241
+ if (value instanceof Date) return value.toISOString();
242
+ return JSON.stringify(value)?.slice(0, MAX_PROP_VALUE_LENGTH);
504
243
  } catch {
505
- return 256;
244
+ return;
506
245
  }
507
246
  }
508
- //#endregion
509
- //#region ../browser-session/src/replay/events.ts
510
- const FLUSH_INTERVAL_MS$1 = 5e3;
511
- const FLUSH_BYTES$1 = 64 * 1024;
512
- const ZERO_TRACE_ID = "00000000000000000000000000000000";
513
- let traceIdProvider = () => void 0;
514
- /** Wire the host SDK's active-trace-id lookup into event capture. */
515
- function setActiveTraceIdProvider(provider) {
516
- traceIdProvider = provider;
517
- }
518
- /** The trace id of the active span, or undefined when none is active. */
519
- function activeTraceId() {
520
- const id = traceIdProvider();
521
- return id && id !== ZERO_TRACE_ID ? id : void 0;
522
- }
523
- /**
524
- * Capture distilled session events (console, network, errors, navigation,
525
- * interactions) and ship them to the ingest gateway as NDJSON rows. Best-effort
526
- * and decoupled from the rrweb recorder — runs on its own flush loop.
527
- */
528
- function startEventCapture(config, sessionId) {
529
- let buffer = [];
530
- let bufferBytes = 0;
531
- let seq = 0;
532
- const emit = (ev) => {
533
- buffer.push({
534
- ev,
535
- seq: seq++
536
- });
537
- bufferBytes += approximateSize(ev);
538
- if (bufferBytes >= FLUSH_BYTES$1) flush();
539
- };
540
- const flush = async (keepalive = false) => {
541
- if (buffer.length === 0) return;
542
- markActivity();
543
- const batch = buffer;
544
- buffer = [];
545
- bufferBytes = 0;
546
- await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
547
- };
548
- const ignoreUrl = (url) => url.startsWith(`${config.endpoint}/v1/`);
549
- const uninstall = [
550
- installNavigationCapture(emit),
551
- installInteractionCapture(emit, config.maskAllText),
552
- installConsoleCapture(emit),
553
- installNetworkCapture(emit, ignoreUrl),
554
- installErrorCapture(emit)
555
- ];
556
- const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS$1);
557
- return {
558
- stop: () => {
559
- clearInterval(flushTimer);
560
- for (const off of uninstall) off();
561
- },
562
- flush
563
- };
564
- }
565
- /** Map an internal event to the snake_case ingest row (org_id is added server-side). */
566
- function toRow(sessionId, ev, seq) {
567
- return {
568
- session_id: sessionId,
569
- timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
570
- seq,
571
- type: ev.type,
572
- url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
573
- trace_id: ev.traceId ?? activeTraceId() ?? "",
574
- level: ev.level ?? "",
575
- message: ev.message ?? "",
576
- target_selector: ev.targetSelector ?? "",
577
- target_text: ev.targetText ?? "",
578
- net_method: ev.net?.method ?? "",
579
- net_url: ev.net?.url ?? "",
580
- net_status: ev.net?.status ?? 0,
581
- net_duration_ms: ev.net?.durationMs ?? 0,
582
- error_stack: ev.errorStack ?? "",
583
- attributes: ev.attrs ?? {}
584
- };
585
- }
586
- //#endregion
587
- //#region ../browser-session/src/replay/record.ts
588
- const FULL_SNAPSHOT = 2;
589
- const INCREMENTAL = 3;
590
- const SOURCE_MOUSE_INTERACTION = 2;
591
- const MOUSE_CLICK = 2;
592
- const FLUSH_INTERVAL_MS = 5e3;
593
- const FLUSH_BYTES = 100 * 1024;
594
- const CHECKOUT_EVERY_MS = 3e4;
595
- function startRecording(config, sessionId) {
596
- let buffer = [];
597
- let bufferBytes = 0;
598
- let bufferHasCheckpoint = false;
599
- let clickCount = 0;
600
- const flush = async (keepalive = false) => {
601
- if (buffer.length === 0) return;
602
- markActivity();
603
- const events = buffer;
604
- const isCheckpoint = bufferHasCheckpoint;
605
- const seq = nextChunkSeq();
606
- const first = events[0].timestamp;
607
- const last = events[events.length - 1].timestamp;
608
- buffer = [];
609
- bufferBytes = 0;
610
- bufferHasCheckpoint = false;
611
- const gzipped = await gzip(new TextEncoder().encode(JSON.stringify(events)));
612
- await postSessionBlob(config, {
613
- sessionId,
614
- chunkSeq: seq,
615
- isCheckpoint,
616
- eventCount: events.length,
617
- durationMs: Math.max(0, last - first)
618
- }, gzipped, keepalive);
619
- };
620
- const stop = record({
621
- emit: (event, isCheckpoint) => {
622
- const e = event;
623
- if (isCheckpoint === true || e.type === FULL_SNAPSHOT) bufferHasCheckpoint = true;
624
- if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
625
- buffer.push(e);
626
- bufferBytes += approximateSize(e);
627
- if (bufferBytes >= FLUSH_BYTES) flush();
628
- },
629
- maskAllInputs: config.maskAllInputs,
630
- ...config.maskAllText ? { maskTextSelector: "*" } : {},
631
- checkoutEveryNms: CHECKOUT_EVERY_MS
632
- });
633
- const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
634
- return {
635
- stop: () => {
636
- clearInterval(flushTimer);
637
- stop?.();
638
- },
639
- flush,
640
- getClickCount: () => clickCount
641
- };
247
+ function coerceProps(props) {
248
+ if (!props) return {};
249
+ const out = {};
250
+ let bytes = 0;
251
+ for (const [rawKey, rawValue] of Object.entries(props)) {
252
+ if (Object.keys(out).length >= MAX_PROPS) break;
253
+ const value = coerce(rawValue);
254
+ if (value === void 0) continue;
255
+ const key = rawKey.slice(0, MAX_PROP_KEY_LENGTH);
256
+ if (!key) continue;
257
+ bytes += key.length + value.length;
258
+ if (bytes > MAX_TOTAL_PROP_BYTES) break;
259
+ out[key] = value;
260
+ }
261
+ return out;
642
262
  }
643
- //#endregion
644
- //#region ../browser-session/src/replay-session.ts
645
263
  /**
646
- * Start recording the current browser session. Publishes the session sink,
647
- * posts an `active` metadata row, and installs visibility handlers:
648
- * hidden → flush + `ended` row (with observed trace ids) + stop capture;
649
- * visible → re-resolve the session (rotating if idle-expired), republish the
650
- * sink, restart capture, post a fresh `active` row. Metadata versions are
651
- * monotonic per session, so the latest row always wins on the backend.
264
+ * Record a custom product event against the current session.
652
265
  *
653
- * Returns undefined outside a browser. Sampling is the caller's decision.
266
+ * Stored as a `session_events` row with `Type='custom'`, so it shows up inline
267
+ * in the session transcript alongside the clicks and network calls that
268
+ * surround it — not in a separate analytics silo.
269
+ *
270
+ * Safe to call before the SDK finishes initializing: events are queued (capped)
271
+ * and drained once the sink starts. Never throws.
654
272
  */
655
- function startReplaySession(options) {
656
- if (typeof window === "undefined") return void 0;
657
- const engineConfig = {
658
- endpoint: options.endpoint.replace(/\/$/, ""),
659
- ingestKey: options.ingestKey,
660
- maskAllInputs: options.maskAllInputs,
661
- maskAllText: options.maskAllText
662
- };
663
- const session = getSession();
664
- let currentSessionId = session.id;
665
- let currentStartedAt = new Date(session.startedAt);
666
- publishSessionSink(currentSessionId);
667
- let recorder;
668
- let events;
669
- let stopped = false;
670
- const postMeta = (status, clickCount, keepalive = false) => postSessionMeta(engineConfig, buildSessionMetaRow({
671
- sessionId: currentSessionId,
672
- startedAt: currentStartedAt,
673
- version: nextMetaVersion(),
674
- status,
675
- serviceName: options.serviceName,
676
- userId: options.getUserId?.(),
677
- environment: options.environment,
678
- serviceVersion: options.serviceVersion,
679
- clickCount: clickCount ?? 0,
680
- traceIds: status === "ended" ? getObservedTraceIds() : void 0
681
- }), keepalive);
682
- const start = () => {
683
- recorder = startRecording(engineConfig, currentSessionId);
684
- events = startEventCapture(engineConfig, currentSessionId);
685
- postMeta("active", null);
686
- };
687
- const suspend = () => {
688
- if (!recorder || !events) return;
689
- recorder.flush(true);
690
- events.flush(true);
691
- postMeta("ended", recorder.getClickCount(), true);
692
- recorder.stop();
693
- events.stop();
694
- recorder = void 0;
695
- events = void 0;
696
- };
697
- const resume = () => {
698
- if (stopped || recorder) return;
699
- const next = getSession();
700
- if (next.id !== currentSessionId) {
701
- currentSessionId = next.id;
702
- currentStartedAt = new Date(next.startedAt);
703
- publishSessionSink(currentSessionId);
704
- }
705
- start();
706
- };
707
- const onVisibilityChange = () => {
708
- if (document.visibilityState === "hidden") suspend();
709
- else resume();
710
- };
711
- const onPageHide = () => suspend();
712
- start();
713
- document.addEventListener("visibilitychange", onVisibilityChange);
714
- window.addEventListener("pagehide", onPageHide);
715
- return {
716
- sessionId: currentSessionId,
717
- shutdown: async () => {
718
- stopped = true;
719
- document.removeEventListener("visibilitychange", onVisibilityChange);
720
- window.removeEventListener("pagehide", onPageHide);
721
- if (recorder) await recorder.flush(true);
722
- if (events) await events.flush(true);
723
- recorder?.stop();
724
- events?.stop();
725
- recorder = void 0;
726
- events = void 0;
273
+ function track(name, props) {
274
+ if (!hasConsent()) return;
275
+ if (typeof name !== "string" || name.trim().length === 0) {
276
+ if (!warnedAboutName) {
277
+ warnedAboutName = true;
278
+ console.warn("[maple] track() needs a non-empty event name; the call was ignored.");
727
279
  }
280
+ return;
281
+ }
282
+ const ev = {
283
+ type: "custom",
284
+ message: name.trim().slice(0, MAX_NAME_LENGTH),
285
+ attrs: coerceProps(props),
286
+ timestamp: Date.now(),
287
+ url: typeof location !== "undefined" ? location.href : void 0
728
288
  };
289
+ const sink = getActiveSink();
290
+ if (sink) sink.emit(ev);
291
+ else queuePending(ev);
729
292
  }
730
293
  //#endregion
731
294
  //#region src/config.ts
732
295
  const DEFAULT_ENDPOINT = "https://ingest.maple.dev";
733
- function normalizeUserId(userId) {
734
- return userId ? userId : void 0;
296
+ /**
297
+ * Resolve the identity from either the new `user` object or the legacy
298
+ * `userId` string. `user` wins when both are set.
299
+ */
300
+ function resolveIdentity(config) {
301
+ return normalizeIdentity(config.user ?? config.userId);
735
302
  }
736
303
  function resolveConfig(config) {
737
304
  return {
@@ -741,13 +308,19 @@ function resolveConfig(config) {
741
308
  serviceNamespace: config.serviceNamespace,
742
309
  serviceVersion: config.serviceVersion,
743
310
  environment: config.environment,
744
- userId: normalizeUserId(config.userId),
311
+ identity: resolveIdentity(config),
745
312
  tracingEnabled: config.tracing?.enabled ?? true,
746
313
  tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
747
314
  replayEnabled: config.replay?.enabled ?? true,
748
315
  replaySampleRate: config.replay?.sampleRate ?? 1,
749
316
  maskAllInputs: config.privacy?.maskAllInputs ?? true,
750
- maskAllText: config.privacy?.maskAllText ?? false
317
+ maskAllText: config.privacy?.maskAllText ?? false,
318
+ persistVisitorId: config.privacy?.persistVisitorId ?? true,
319
+ crossSubdomainCookie: config.privacy?.crossSubdomainCookie ?? true,
320
+ cookieDomain: config.privacy?.cookieDomain,
321
+ requireConsent: config.privacy?.requireConsent ?? false,
322
+ captureUserEmail: config.privacy?.captureUserEmail ?? true,
323
+ respectDoNotTrack: config.privacy?.respectDoNotTrack ?? false
751
324
  };
752
325
  }
753
326
  //#endregion
@@ -757,11 +330,15 @@ function resolveConfig(config) {
757
330
  * alongside the BatchSpanProcessor, does no export of its own.
758
331
  */
759
332
  var TraceIdCollector = class {
333
+ getUserId;
760
334
  constructor(getUserId = () => void 0) {
761
335
  this.getUserId = getUserId;
762
336
  }
763
337
  onStart(span) {
338
+ if (!hasConsent()) return;
764
339
  recordTraceId(span.spanContext().traceId);
340
+ const sessionId = readSessionSink()?.sessionId;
341
+ if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
765
342
  const userId = this.getUserId();
766
343
  if (userId !== void 0) span.setAttribute("user.id", userId);
767
344
  }
@@ -774,18 +351,53 @@ var TraceIdCollector = class {
774
351
  }
775
352
  };
776
353
  /**
777
- * Set up browser OTel tracing exporting to Maple's ingest, tagging the resource
778
- * with the shared `session.id`. When `tracingInstrumentFetch` is true, fetch()
779
- * calls are auto-instrumented and their trace ids feed the session. Disable it
780
- * when an external tracer (e.g. the Effect client SDK) already instruments
781
- * requests — that tracer feeds the session via the published sink instead, and
782
- * this avoids redundant duplicate network spans. Returns a shutdown function.
354
+ * Drops buffered spans captured while consent was absent. Checking start time,
355
+ * rather than only permission at flush time, also prevents a late grant from
356
+ * releasing spans that began before that grant.
357
+ */
358
+ var ConsentSpanExporter = class {
359
+ inner;
360
+ constructor(inner) {
361
+ this.inner = inner;
362
+ }
363
+ export(spans, callback) {
364
+ const since = consentAllowedSince();
365
+ if (!hasConsent() || !Number.isFinite(since)) {
366
+ callback({ code: 0 });
367
+ return;
368
+ }
369
+ const eligible = spans.filter((span) => span.startTime[0] * 1e3 + span.startTime[1] / 1e6 >= since);
370
+ if (eligible.length === 0) {
371
+ callback({ code: 0 });
372
+ return;
373
+ }
374
+ this.inner.export(eligible, callback);
375
+ }
376
+ forceFlush() {
377
+ return this.inner.forceFlush?.() ?? Promise.resolve();
378
+ }
379
+ shutdown() {
380
+ return this.inner.shutdown();
381
+ }
382
+ };
383
+ /**
384
+ * Set up browser OTel tracing exporting to Maple's ingest. When
385
+ * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
386
+ * their trace ids feed the session. Disable it when an external tracer (e.g.
387
+ * the Effect client SDK) already instruments requests — that tracer feeds the
388
+ * session via the published sink instead, and this avoids redundant duplicate
389
+ * network spans. Returns a shutdown function.
390
+ *
391
+ * `session.id` is deliberately **not** a resource attribute: the resource is
392
+ * fixed for the provider's lifetime, but sessions rotate under it (idle
393
+ * rotation, consent revoke→re-grant), so a resource-level id would attribute
394
+ * every post-rotation span to the ended session. `TraceIdCollector` stamps the
395
+ * live id per span instead.
783
396
  */
784
- function setupTracing(config, sessionId) {
397
+ function setupTracing(config) {
785
398
  const attributes = {
786
399
  [ATTR_SERVICE_NAME]: config.serviceName,
787
- "maple.sdk.type": "browser",
788
- "session.id": sessionId
400
+ "maple.sdk.type": "browser"
789
401
  };
790
402
  if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
791
403
  if (config.serviceVersion) {
@@ -796,17 +408,20 @@ function setupTracing(config, sessionId) {
796
408
  attributes["deployment.environment"] = config.environment;
797
409
  attributes["deployment.environment.name"] = config.environment;
798
410
  }
799
- const exporter = new OTLPTraceExporter({
411
+ const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
800
412
  url: `${config.endpoint}/v1/traces`,
801
413
  headers: { Authorization: `Bearer ${config.ingestKey}` }
802
- });
414
+ }));
803
415
  const provider = new WebTracerProvider({
804
416
  resource: resourceFromAttributes(attributes),
805
- spanProcessors: [new TraceIdCollector(() => config.userId), new BatchSpanProcessor(exporter)]
417
+ spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter)]
806
418
  });
807
419
  provider.register();
808
- if (config.tracingInstrumentFetch) registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] });
809
- return () => provider.shutdown();
420
+ const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
421
+ return async () => {
422
+ unregisterInstrumentations?.();
423
+ await provider.shutdown();
424
+ };
810
425
  }
811
426
  function escapeRegExp(value) {
812
427
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -816,13 +431,9 @@ function escapeRegExp(value) {
816
431
  let active;
817
432
  let activeConfig;
818
433
  /**
819
- * Initialize Maple browser telemetry: OTel tracing + (sampled) rrweb session
820
- * replay, both tagged with one shared session id so a trace can link to its
821
- * replay and vice versa. Idempotent — repeated calls return the live handle.
822
- *
823
- * The replay lifecycle (suspend on tab-hidden, resume on visible, session
824
- * metadata rows) lives in `@maple/browser-session` and is shared with the
825
- * Effect client SDK's `replay` option.
434
+ * Initialize Maple browser telemetry. With consent gating enabled the returned
435
+ * handle remains live while denied: granting starts capture, revoking detaches
436
+ * it without flushing, and a later grant starts cleanly again.
826
437
  */
827
438
  function init(rawConfig) {
828
439
  if (active) return active;
@@ -832,28 +443,112 @@ function init(rawConfig) {
832
443
  };
833
444
  const config = resolveConfig(rawConfig);
834
445
  activeConfig = config;
835
- const session = getSession();
836
- publishSessionSink(session.id);
446
+ configurePrivacy(config);
447
+ if (!hasConsent()) clearPendingEvents();
837
448
  setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
838
- const shutdownTracing = config.tracingEnabled ? setupTracing(config, session.id) : void 0;
839
449
  const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
840
- let replay;
841
- if (recordReplay) replay = startReplaySession({
842
- endpoint: config.endpoint,
843
- ingestKey: config.ingestKey,
844
- serviceName: config.serviceName,
845
- environment: config.environment,
846
- serviceVersion: config.serviceVersion,
847
- maskAllInputs: config.maskAllInputs,
848
- maskAllText: config.maskAllText,
849
- getUserId: () => activeConfig?.userId
850
- });
450
+ let runtime;
451
+ let stopped = false;
452
+ let rotateOnNextStart = false;
453
+ let shutdownTracing;
454
+ let generation = 0;
455
+ const startRuntime = () => {
456
+ if (stopped || runtime || !hasConsent()) return;
457
+ setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
458
+ const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
459
+ rotateOnNextStart = false;
460
+ publishSessionSink(session.id);
461
+ const sink = startEventSink({
462
+ endpoint: config.endpoint,
463
+ ingestKey: config.ingestKey,
464
+ maskAllInputs: config.maskAllInputs,
465
+ maskAllText: config.maskAllText
466
+ }, session.id);
467
+ if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
468
+ const shared = {
469
+ endpoint: config.endpoint,
470
+ ingestKey: config.ingestKey,
471
+ serviceName: config.serviceName,
472
+ environment: config.environment,
473
+ serviceVersion: config.serviceVersion,
474
+ getIdentity: () => activeConfig?.identity,
475
+ captureUserEmail: config.captureUserEmail
476
+ };
477
+ const startMetadata = () => startMetadataSession({
478
+ ...shared,
479
+ getTraceIds: getObservedTraceIds,
480
+ onSessionChange: publishSessionSink
481
+ });
482
+ if (!recordReplay) {
483
+ runtime = {
484
+ initialSessionId: session.id,
485
+ sink,
486
+ metadata: startMetadata()
487
+ };
488
+ return;
489
+ }
490
+ const next = {
491
+ initialSessionId: session.id,
492
+ sink
493
+ };
494
+ runtime = next;
495
+ const ownGeneration = ++generation;
496
+ const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
497
+ next.replayPending = import("./replay-session-B_ACRogX.mjs").then(({ startReplaySession }) => {
498
+ if (stale()) return;
499
+ next.replay = startReplaySession({
500
+ ...shared,
501
+ maskAllInputs: config.maskAllInputs,
502
+ maskAllText: config.maskAllText
503
+ });
504
+ }).catch(() => {
505
+ if (stale()) return;
506
+ next.metadata = startMetadata();
507
+ });
508
+ };
509
+ const stopRuntime = async (flush) => {
510
+ generation++;
511
+ const previous = runtime;
512
+ runtime = void 0;
513
+ if (!previous) return;
514
+ const replayShutdown = previous.replay?.shutdown({ flush });
515
+ const metadataShutdown = previous.metadata?.shutdown({ flush });
516
+ const currentSessionId = previous.replay?.sessionId ?? previous.metadata?.sessionId;
517
+ const liveSink = getActiveSink();
518
+ const sink = liveSink && currentSessionId && liveSink.sessionId === currentSessionId ? liveSink : previous.sink;
519
+ if (flush) await sink.flush(true);
520
+ sink.stop();
521
+ if (sink !== previous.sink) previous.sink.stop();
522
+ clearSessionSink(currentSessionId ?? previous.initialSessionId);
523
+ await Promise.all([
524
+ replayShutdown,
525
+ metadataShutdown,
526
+ previous.replayPending
527
+ ]);
528
+ };
529
+ startRuntime();
530
+ const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => {
531
+ if (allowed) {
532
+ startRuntime();
533
+ return;
534
+ }
535
+ rotateOnNextStart = runtime !== void 0;
536
+ clearPendingEvents();
537
+ setVisitorTracking(false);
538
+ stopRuntime(false);
539
+ }) : () => {};
851
540
  const handle = {
852
- sessionId: session.id,
541
+ get sessionId() {
542
+ return runtime?.replay?.sessionId ?? runtime?.metadata?.sessionId ?? runtime?.initialSessionId ?? "";
543
+ },
853
544
  shutdown: async () => {
854
- await replay?.shutdown();
855
- replay = void 0;
545
+ if (stopped) return;
546
+ stopped = true;
547
+ stopConsentListener();
548
+ await stopRuntime(true);
856
549
  await shutdownTracing?.();
550
+ shutdownTracing = void 0;
551
+ setActiveTraceIdProvider(() => void 0);
857
552
  active = void 0;
858
553
  activeConfig = void 0;
859
554
  }
@@ -862,14 +557,24 @@ function init(rawConfig) {
862
557
  return handle;
863
558
  }
864
559
  /**
865
- * Attach, replace, or clear the user id on the active session. Idempotent and
866
- * safe to call on every render. Future browser-created spans read this value
867
- * when they start, and future session metadata rows read it when they post.
560
+ * Attach, replace, or clear the end-user identity on the active session.
561
+ * Idempotent and safe to call on every render. Future browser-created spans
562
+ * read the id when they start, and future session metadata rows read the whole
563
+ * identity when they post.
564
+ *
565
+ * Accepts a bare user id or the full object:
566
+ *
567
+ * ```ts
568
+ * MapleBrowser.identify("user_123")
569
+ * MapleBrowser.identify({ id: "user_123", email: "a@b.com", groupId: "org_1", groupName: "Acme" })
570
+ * ```
571
+ *
572
+ * Each call replaces the identity rather than merging — merging would leak a
573
+ * signed-out user's email into whoever signs in next on a shared device.
868
574
  */
869
- function identify(userId) {
870
- if (typeof window === "undefined") return;
871
- if (!activeConfig) return;
872
- activeConfig.userId = normalizeUserId(userId);
575
+ function identify(input) {
576
+ if (typeof window === "undefined" || !activeConfig) return;
577
+ activeConfig.identity = normalizeIdentity(input);
873
578
  }
874
579
  //#endregion
875
580
  //#region src/index.ts
@@ -885,11 +590,16 @@ function identify(userId) {
885
590
  * ingestKey: "maple_pk_...",
886
591
  * serviceName: "acme-web",
887
592
  * })
593
+ *
594
+ * MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
595
+ * MapleBrowser.track("checkout_completed", { plan: "pro", seats: 5 })
888
596
  * ```
889
597
  */
890
598
  const MapleBrowser = {
891
599
  init,
892
- identify
600
+ identify,
601
+ track,
602
+ setConsent
893
603
  };
894
604
  //#endregion
895
605
  export { MapleBrowser };