@maple-dev/browser 0.3.0 → 0.8.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 { C as setActiveTraceIdProvider, T as setVisitorTracking, a as recordTraceId, b as sdkHint, c as getActiveSink, d as getSession, g as SDK_HINT_HEADER, h as postSessionMetaRow, i as readSessionSink, l as queuePending, m as rotateSession, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as startEventSink, w as configureVisitorCookie } from "./sink-D9w1kg0Q.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/identity/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/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);
199
- }
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
- });
107
+ function consentAllowedSince() {
108
+ return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
230
109
  }
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/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/session/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, 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/events/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,27 +308,42 @@ 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
327
+ //#region src/version.ts
328
+ const SDK_VERSION = "0.8.0";
329
+ /** The `x-maple-sdk` value this build sends. */
330
+ const SDK_NAME = "maple-browser";
331
+ //#endregion
754
332
  //#region src/tracing.ts
755
333
  /**
756
334
  * Captures every span's trace id into the session sink. Lightweight — runs
757
335
  * alongside the BatchSpanProcessor, does no export of its own.
758
336
  */
759
337
  var TraceIdCollector = class {
338
+ getUserId;
760
339
  constructor(getUserId = () => void 0) {
761
340
  this.getUserId = getUserId;
762
341
  }
763
342
  onStart(span) {
343
+ if (!hasConsent()) return;
764
344
  recordTraceId(span.spanContext().traceId);
345
+ const sessionId = readSessionSink()?.sessionId;
346
+ if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
765
347
  const userId = this.getUserId();
766
348
  if (userId !== void 0) span.setAttribute("user.id", userId);
767
349
  }
@@ -774,18 +356,62 @@ var TraceIdCollector = class {
774
356
  }
775
357
  };
776
358
  /**
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.
359
+ * Drops buffered spans captured while consent was absent. Checking start time,
360
+ * rather than only permission at flush time, also prevents a late grant from
361
+ * releasing spans that began before that grant.
783
362
  */
784
- function setupTracing(config, sessionId) {
363
+ var ConsentSpanExporter = class {
364
+ inner;
365
+ constructor(inner) {
366
+ this.inner = inner;
367
+ }
368
+ export(spans, callback) {
369
+ const since = consentAllowedSince();
370
+ if (!hasConsent() || !Number.isFinite(since)) {
371
+ callback({ code: 0 });
372
+ return;
373
+ }
374
+ const eligible = spans.filter((span) => span.startTime[0] * 1e3 + span.startTime[1] / 1e6 >= since);
375
+ if (eligible.length === 0) {
376
+ callback({ code: 0 });
377
+ return;
378
+ }
379
+ this.inner.export(eligible, callback);
380
+ }
381
+ forceFlush() {
382
+ return this.inner.forceFlush?.() ?? Promise.resolve();
383
+ }
384
+ shutdown() {
385
+ return this.inner.shutdown();
386
+ }
387
+ };
388
+ /**
389
+ * How long a span may sit in the batch queue before export.
390
+ *
391
+ * Shorter than OTel's 5s default: in a browser the queue is only as durable as
392
+ * the tab, and the unload flush below is a best-effort catch rather than a
393
+ * guarantee (a crashed or killed tab fires neither event). 2s trades a few more
394
+ * requests for a materially smaller loss window.
395
+ */
396
+ const EXPORT_INTERVAL_MS = 2e3;
397
+ /**
398
+ * Set up browser OTel tracing exporting to Maple's ingest. When
399
+ * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
400
+ * their trace ids feed the session. Disable it when an external tracer (e.g.
401
+ * the Effect client SDK) already instruments requests — that tracer feeds the
402
+ * session via the published sink instead, and this avoids redundant duplicate
403
+ * network spans. Returns a shutdown function.
404
+ *
405
+ * `session.id` is deliberately **not** a resource attribute: the resource is
406
+ * fixed for the provider's lifetime, but sessions rotate under it (idle
407
+ * rotation, consent revoke→re-grant), so a resource-level id would attribute
408
+ * every post-rotation span to the ended session. `TraceIdCollector` stamps the
409
+ * live id per span instead.
410
+ */
411
+ function setupTracing(config) {
785
412
  const attributes = {
786
413
  [ATTR_SERVICE_NAME]: config.serviceName,
787
- "maple.sdk.type": "browser",
788
- "session.id": sessionId
414
+ "maple.sdk.type": "browser"
789
415
  };
790
416
  if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
791
417
  if (config.serviceVersion) {
@@ -796,33 +422,52 @@ function setupTracing(config, sessionId) {
796
422
  attributes["deployment.environment"] = config.environment;
797
423
  attributes["deployment.environment.name"] = config.environment;
798
424
  }
799
- const exporter = new OTLPTraceExporter({
425
+ const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
800
426
  url: `${config.endpoint}/v1/traces`,
801
- headers: { Authorization: `Bearer ${config.ingestKey}` }
802
- });
427
+ headers: {
428
+ Authorization: `Bearer ${config.ingestKey}`,
429
+ [SDK_HINT_HEADER]: sdkHint(SDK_NAME, SDK_VERSION)
430
+ }
431
+ }));
803
432
  const provider = new WebTracerProvider({
804
433
  resource: resourceFromAttributes(attributes),
805
- spanProcessors: [new TraceIdCollector(() => config.userId), new BatchSpanProcessor(exporter)]
434
+ spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
806
435
  });
807
436
  provider.register();
808
- if (config.tracingInstrumentFetch) registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] });
809
- return () => provider.shutdown();
437
+ const onExit = () => {
438
+ provider.forceFlush().catch(() => {});
439
+ };
440
+ const onVisibilityChange = () => {
441
+ if (document.visibilityState === "hidden") onExit();
442
+ };
443
+ const canListen = typeof document !== "undefined" && typeof document.addEventListener === "function";
444
+ if (canListen) {
445
+ document.addEventListener("visibilitychange", onVisibilityChange);
446
+ window.addEventListener("pagehide", onExit);
447
+ }
448
+ const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
449
+ return async () => {
450
+ if (canListen) {
451
+ document.removeEventListener("visibilitychange", onVisibilityChange);
452
+ window.removeEventListener("pagehide", onExit);
453
+ }
454
+ unregisterInstrumentations?.();
455
+ await provider.shutdown();
456
+ };
810
457
  }
811
458
  function escapeRegExp(value) {
812
459
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
813
460
  }
814
461
  //#endregion
815
462
  //#region src/init.ts
463
+ /** `x-maple-sdk` value for every request this build makes to ingest. */
464
+ const SDK_HINT = sdkHint(SDK_NAME, SDK_VERSION);
816
465
  let active;
817
466
  let activeConfig;
818
467
  /**
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.
468
+ * Initialize Maple browser telemetry. With consent gating enabled the returned
469
+ * handle remains live while denied: granting starts capture, revoking detaches
470
+ * it without flushing, and a later grant starts cleanly again.
826
471
  */
827
472
  function init(rawConfig) {
828
473
  if (active) return active;
@@ -832,28 +477,114 @@ function init(rawConfig) {
832
477
  };
833
478
  const config = resolveConfig(rawConfig);
834
479
  activeConfig = config;
835
- const session = getSession();
836
- publishSessionSink(session.id);
480
+ configurePrivacy(config);
481
+ if (!hasConsent()) clearPendingEvents();
837
482
  setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
838
- const shutdownTracing = config.tracingEnabled ? setupTracing(config, session.id) : void 0;
839
483
  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
- });
484
+ let runtime;
485
+ let stopped = false;
486
+ let rotateOnNextStart = false;
487
+ let shutdownTracing;
488
+ let generation = 0;
489
+ const startRuntime = () => {
490
+ if (stopped || runtime || !hasConsent()) return;
491
+ setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
492
+ const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
493
+ rotateOnNextStart = false;
494
+ publishSessionSink(session.id);
495
+ const sink = startEventSink({
496
+ endpoint: config.endpoint,
497
+ ingestKey: config.ingestKey,
498
+ sdk: SDK_HINT,
499
+ maskAllInputs: config.maskAllInputs,
500
+ maskAllText: config.maskAllText
501
+ }, session.id);
502
+ if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
503
+ const shared = {
504
+ endpoint: config.endpoint,
505
+ ingestKey: config.ingestKey,
506
+ sdk: SDK_HINT,
507
+ serviceName: config.serviceName,
508
+ environment: config.environment,
509
+ serviceVersion: config.serviceVersion,
510
+ getIdentity: () => activeConfig?.identity,
511
+ captureUserEmail: config.captureUserEmail
512
+ };
513
+ const startMetadata = () => startMetadataSession({
514
+ ...shared,
515
+ getTraceIds: getObservedTraceIds,
516
+ onSessionChange: publishSessionSink
517
+ });
518
+ if (!recordReplay) {
519
+ runtime = {
520
+ initialSessionId: session.id,
521
+ sink,
522
+ metadata: startMetadata()
523
+ };
524
+ return;
525
+ }
526
+ const next = {
527
+ initialSessionId: session.id,
528
+ sink
529
+ };
530
+ runtime = next;
531
+ const ownGeneration = ++generation;
532
+ const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
533
+ next.replayPending = import("./replay-session-BYZfi3iZ.mjs").then(({ startReplaySession }) => {
534
+ if (stale()) return;
535
+ next.replay = startReplaySession({
536
+ ...shared,
537
+ maskAllInputs: config.maskAllInputs,
538
+ maskAllText: config.maskAllText
539
+ });
540
+ }).catch(() => {
541
+ if (stale()) return;
542
+ next.metadata = startMetadata();
543
+ });
544
+ };
545
+ const stopRuntime = async (flush) => {
546
+ generation++;
547
+ const previous = runtime;
548
+ runtime = void 0;
549
+ if (!previous) return;
550
+ const replayShutdown = previous.replay?.shutdown({ flush });
551
+ const metadataShutdown = previous.metadata?.shutdown({ flush });
552
+ const currentSessionId = previous.replay?.sessionId ?? previous.metadata?.sessionId;
553
+ const liveSink = getActiveSink();
554
+ const sink = liveSink && currentSessionId && liveSink.sessionId === currentSessionId ? liveSink : previous.sink;
555
+ if (flush) await sink.flush(true);
556
+ sink.stop();
557
+ if (sink !== previous.sink) previous.sink.stop();
558
+ clearSessionSink(currentSessionId ?? previous.initialSessionId);
559
+ await Promise.all([
560
+ replayShutdown,
561
+ metadataShutdown,
562
+ previous.replayPending
563
+ ]);
564
+ };
565
+ startRuntime();
566
+ const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => {
567
+ if (allowed) {
568
+ startRuntime();
569
+ return;
570
+ }
571
+ rotateOnNextStart = runtime !== void 0;
572
+ clearPendingEvents();
573
+ setVisitorTracking(false);
574
+ stopRuntime(false);
575
+ }) : () => {};
851
576
  const handle = {
852
- sessionId: session.id,
577
+ get sessionId() {
578
+ return runtime?.replay?.sessionId ?? runtime?.metadata?.sessionId ?? runtime?.initialSessionId ?? "";
579
+ },
853
580
  shutdown: async () => {
854
- await replay?.shutdown();
855
- replay = void 0;
581
+ if (stopped) return;
582
+ stopped = true;
583
+ stopConsentListener();
584
+ await stopRuntime(true);
856
585
  await shutdownTracing?.();
586
+ shutdownTracing = void 0;
587
+ setActiveTraceIdProvider(() => void 0);
857
588
  active = void 0;
858
589
  activeConfig = void 0;
859
590
  }
@@ -862,14 +593,24 @@ function init(rawConfig) {
862
593
  return handle;
863
594
  }
864
595
  /**
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.
596
+ * Attach, replace, or clear the end-user identity on the active session.
597
+ * Idempotent and safe to call on every render. Future browser-created spans
598
+ * read the id when they start, and future session metadata rows read the whole
599
+ * identity when they post.
600
+ *
601
+ * Accepts a bare user id or the full object:
602
+ *
603
+ * ```ts
604
+ * MapleBrowser.identify("user_123")
605
+ * MapleBrowser.identify({ id: "user_123", email: "a@b.com", groupId: "org_1", groupName: "Acme" })
606
+ * ```
607
+ *
608
+ * Each call replaces the identity rather than merging — merging would leak a
609
+ * signed-out user's email into whoever signs in next on a shared device.
868
610
  */
869
- function identify(userId) {
870
- if (typeof window === "undefined") return;
871
- if (!activeConfig) return;
872
- activeConfig.userId = normalizeUserId(userId);
611
+ function identify(input) {
612
+ if (typeof window === "undefined" || !activeConfig) return;
613
+ activeConfig.identity = normalizeIdentity(input);
873
614
  }
874
615
  //#endregion
875
616
  //#region src/index.ts
@@ -885,11 +626,16 @@ function identify(userId) {
885
626
  * ingestKey: "maple_pk_...",
886
627
  * serviceName: "acme-web",
887
628
  * })
629
+ *
630
+ * MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
631
+ * MapleBrowser.track("checkout_completed", { plan: "pro", seats: 5 })
888
632
  * ```
889
633
  */
890
634
  const MapleBrowser = {
891
635
  init,
892
- identify
636
+ identify,
637
+ track,
638
+ setConsent
893
639
  };
894
640
  //#endregion
895
641
  export { MapleBrowser };