@maple-dev/browser 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1722 @@
1
+ import { A as setActiveTraceIdProvider, F as scrubUrl, M as configureVisitorCookie, N as setVisitorTracking, P as addUrlSanitizer, _ as rotateSession, a as recordTraceId, c as getActiveSink, d as startEventSink, f as claimReplayMode, i as readSessionSink, n as getObservedTraceIds, o as startSessionLifecycle, p as getSession, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as queuePending, v as postSessionMetaRow, w as sdkHint, x as ingestHeaders, y as isLikelyBot } from "./sink-BOR9_z4b.mjs";
2
+ import { ProxyTracerProvider, SpanKind, SpanStatusCode, TraceFlags, context, createContextKey, createTraceState, isSpanContextValid, propagation, trace } from "@opentelemetry/api";
3
+ import { BatchSpanProcessor, SamplingDecision } from "@opentelemetry/sdk-trace-base";
4
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
5
+ import { registerInstrumentations } from "@opentelemetry/instrumentation";
6
+ import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
7
+ import { XMLHttpRequestInstrumentation } from "@opentelemetry/instrumentation-xml-http-request";
8
+ import { resourceFromAttributes } from "@opentelemetry/resources";
9
+ import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
10
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
11
+ //#region ../browser-session/src/identity/consent.ts
12
+ /**
13
+ * Consent gating for session capture.
14
+ *
15
+ * The posture, deliberately:
16
+ *
17
+ * - `requireConsent` defaults to **false**. Flipping that default would
18
+ * silently stop telemetry for every existing install — an availability
19
+ * incident dressed up as a privacy win. Apps that need a consent gate opt in.
20
+ * - Global Privacy Control **is** honored by default, but only against the
21
+ * persistent visitor id, not the session itself. GPC is a legally recognized
22
+ * signal in several US states and it maps exactly onto the one cross-session
23
+ * identifier this SDK stores; the session-scoped capture that powers replay
24
+ * and error triage is unaffected.
25
+ * - `doNotTrack` is **not** honored by default. It is deprecated, removed from
26
+ * Safari, and ignored across the industry, so respecting it would mostly mean
27
+ * dropping data from users who never intended to opt out. Apps can turn it on.
28
+ */
29
+ /**
30
+ * Consent lives on `globalThis`, for the same reason the event sink does
31
+ * (`events/events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
32
+ * Effect SDK gets **two copies** of this module, and per-copy state would mean
33
+ * `setConsent(true)` on one never releases the other copy's exporters — or, the
34
+ * other way round, one copy capturing while the other believes consent is
35
+ * required. There is one user and one decision, so there is one state.
36
+ */
37
+ const CONSENT_KEY = "__MAPLE_BROWSER_CONSENT__";
38
+ function consentState() {
39
+ const owner = globalThis;
40
+ const existing = owner[CONSENT_KEY];
41
+ if (existing) return existing;
42
+ const fresh = {
43
+ allowedSince: 0,
44
+ granted: false,
45
+ listeners: /* @__PURE__ */ new Set(),
46
+ requireConsent: false,
47
+ respectDoNotTrack: false
48
+ };
49
+ owner[CONSENT_KEY] = fresh;
50
+ return fresh;
51
+ }
52
+ /** When consent was last withdrawn, on any page of this origin. Persisted: the grant time resets every load. */
53
+ const REVOKED_AT_KEY = "maple-consent-revoked-at";
54
+ function writeRevokedAt(at) {
55
+ try {
56
+ localStorage.setItem(REVOKED_AT_KEY, String(at));
57
+ } catch {}
58
+ }
59
+ /** Epoch ms of the last consent withdrawal on this origin, 0 if never. Data kept from before it must not be sent. */
60
+ function consentRevokedAt() {
61
+ try {
62
+ return Number(localStorage.getItem(REVOKED_AT_KEY)) || 0;
63
+ } catch {
64
+ return 0;
65
+ }
66
+ }
67
+ function updateEffectiveConsent(previous) {
68
+ const state = consentState();
69
+ const allowed = hasConsent();
70
+ if (allowed === previous) return;
71
+ state.allowedSince = allowed ? Date.now() : Number.POSITIVE_INFINITY;
72
+ for (const listener of state.listeners) try {
73
+ listener(allowed);
74
+ } catch {}
75
+ }
76
+ /**
77
+ * Apply the host app's privacy config. Call before anything captures.
78
+ *
79
+ * Both SDKs call this at startup, and an app can init one of them without a
80
+ * `privacy` block — so the gates only ever **tighten**. Letting the second
81
+ * caller's absent option reset `requireConsent` back to false would silently
82
+ * disable a gate the first caller asked for, and the resulting
83
+ * denied→allowed transition would start capture on a user who never consented.
84
+ * To genuinely relax a gate, reload without it.
85
+ */
86
+ function configurePrivacy(options) {
87
+ const state = consentState();
88
+ const previous = hasConsent();
89
+ state.requireConsent = state.requireConsent || (options?.requireConsent ?? false);
90
+ state.respectDoNotTrack = state.respectDoNotTrack || (options?.respectDoNotTrack ?? false);
91
+ configureVisitorCookie({
92
+ crossSubdomainCookie: options?.crossSubdomainCookie,
93
+ cookieDomain: options?.cookieDomain
94
+ });
95
+ if (options?.sanitizeUrl) addUrlSanitizer(options.sanitizeUrl);
96
+ updateEffectiveConsent(previous);
97
+ }
98
+ /** Record the user's consent decision. No-op unless `requireConsent` is set. */
99
+ function setConsent(nextGranted) {
100
+ const previous = hasConsent();
101
+ const state = consentState();
102
+ if (state.granted && !nextGranted) writeRevokedAt(Date.now());
103
+ state.granted = nextGranted;
104
+ updateEffectiveConsent(previous);
105
+ }
106
+ /** Whether capture may proceed at all. */
107
+ function hasConsent() {
108
+ const state = consentState();
109
+ return !state.requireConsent || state.granted;
110
+ }
111
+ /**
112
+ * Subscribe to effective capture permission changes. The listener is invoked
113
+ * only when capture actually transitions between allowed and denied; changing
114
+ * the stored decision while `requireConsent` is off is deliberately silent.
115
+ */
116
+ function onConsentChange(listener) {
117
+ const { listeners } = consentState();
118
+ listeners.add(listener);
119
+ return () => listeners.delete(listener);
120
+ }
121
+ /**
122
+ * Earliest epoch-ms timestamp telemetry may export for the current grant.
123
+ * `Infinity` means capture is denied. Exporters use this to discard anything
124
+ * buffered before a late grant or across a revoke/re-grant cycle.
125
+ */
126
+ function consentAllowedSince() {
127
+ return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
128
+ }
129
+ /**
130
+ * Whether a *persistent* identifier may be stored. Separate from `hasConsent`
131
+ * because GPC/DNT suppress the cross-session id without suppressing capture.
132
+ */
133
+ function mayPersistIdentifier() {
134
+ if (!hasConsent()) return false;
135
+ if (typeof navigator === "undefined") return true;
136
+ const nav = navigator;
137
+ if (nav.globalPrivacyControl === true) return false;
138
+ if (consentState().respectDoNotTrack && nav.doNotTrack === "1") return false;
139
+ return true;
140
+ }
141
+ //#endregion
142
+ //#region ../browser-session/src/identity/identity.ts
143
+ /**
144
+ * Trait caps. The warehouse column is `Map(String, String)` — plain keys
145
+ * precisely because they are arbitrary — so the cost of a unique key per user
146
+ * is row size and query-time map width rather than a churned dictionary. The
147
+ * cap bounds one row's damage; `warnOnIdLikeTraitKey` surfaces the pattern to
148
+ * whoever wrote it, since an id in a trait key is nearly always a mistake.
149
+ */
150
+ const MAX_TRAITS = 24;
151
+ const MAX_TRAIT_KEY_LENGTH = 64;
152
+ const MAX_TRAIT_VALUE_LENGTH = 256;
153
+ let warnedAboutTraitKeys = false;
154
+ /** Heuristic: a trait *key* that looks like an id is almost always a mistake. */
155
+ function looksLikeId(key) {
156
+ return /^[0-9a-f]{8,}$/i.test(key) || /^(usr|user|org|cus|acct)_/i.test(key);
157
+ }
158
+ function coerceTraitValue(value) {
159
+ if (value === null || value === void 0) return void 0;
160
+ return (typeof value === "string" ? value : String(value)).slice(0, MAX_TRAIT_VALUE_LENGTH);
161
+ }
162
+ function normalizeTraits(traits) {
163
+ if (!traits) return {};
164
+ const out = {};
165
+ for (const [rawKey, rawValue] of Object.entries(traits)) {
166
+ if (Object.keys(out).length >= MAX_TRAITS) break;
167
+ const value = coerceTraitValue(rawValue);
168
+ if (value === void 0) continue;
169
+ const key = rawKey.slice(0, MAX_TRAIT_KEY_LENGTH);
170
+ if (!key) continue;
171
+ if (!warnedAboutTraitKeys && looksLikeId(key)) {
172
+ warnedAboutTraitKeys = true;
173
+ console.warn(`[maple] identity trait key "${key}" looks like an id. A key per user widens every session row's trait map; put ids in the value, or in id/groupId, not the key.`);
174
+ }
175
+ out[key] = value;
176
+ }
177
+ return out;
178
+ }
179
+ function trimmed(value) {
180
+ const text = value?.trim();
181
+ return text ? text : void 0;
182
+ }
183
+ /**
184
+ * Normalize whatever a host app passed into the shape the metadata row wants.
185
+ *
186
+ * Each call *replaces* the identity rather than merging into the previous one.
187
+ * Merging would leak a signed-out user's email into the next person to use a
188
+ * shared device, and replacement is what the existing string-only `identify`
189
+ * already did.
190
+ */
191
+ function normalizeIdentity(input) {
192
+ if (input === null || input === void 0) return void 0;
193
+ if (typeof input === "string") {
194
+ const id = trimmed(input);
195
+ return id ? {
196
+ id,
197
+ traits: {}
198
+ } : void 0;
199
+ }
200
+ const resolved = {
201
+ id: trimmed(input.id),
202
+ email: trimmed(input.email),
203
+ username: trimmed(input.username),
204
+ groupId: trimmed(input.groupId),
205
+ groupName: trimmed(input.groupName),
206
+ traits: normalizeTraits(input.traits)
207
+ };
208
+ return resolved.id || resolved.email || resolved.username || resolved.groupId || resolved.groupName || Object.keys(resolved.traits).length > 0 ? resolved : void 0;
209
+ }
210
+ //#endregion
211
+ //#region ../browser-session/src/session/metadata-session.ts
212
+ /**
213
+ * Metadata-only session lifecycle used whenever rrweb is disabled or unsampled.
214
+ * It is the shared lifecycle driver with no capture of its own: the distilled
215
+ * event sink runs on every page load regardless, so this owner only has to post
216
+ * rows and flush that sink on the way out.
217
+ */
218
+ function startMetadataSession(options) {
219
+ return startSessionLifecycle(options, {
220
+ recorded: false,
221
+ post: (row, keepalive) => {
222
+ postSessionMetaRow(options, row, keepalive);
223
+ },
224
+ onSuspend: ({ flush, keepalive }) => {
225
+ if (flush && keepalive) getActiveSink()?.flush(true);
226
+ },
227
+ onSessionChange: options.onSessionChange
228
+ });
229
+ }
230
+ //#endregion
231
+ //#region ../browser-session/src/events/props.ts
232
+ const MAX_PROPS = 32;
233
+ const MAX_PROP_KEY_LENGTH = 64;
234
+ const MAX_PROP_VALUE_LENGTH = 1024;
235
+ const MAX_TOTAL_PROP_BYTES = 8192;
236
+ /**
237
+ * Coerce one property value to the string the warehouse column holds.
238
+ *
239
+ * `null`/`undefined`/functions/symbols are dropped rather than stringified —
240
+ * `"undefined"` as a stored value is worse than an absent key.
241
+ */
242
+ function coerce(value) {
243
+ if (value === null || value === void 0) return void 0;
244
+ switch (typeof value) {
245
+ case "string": return value.slice(0, MAX_PROP_VALUE_LENGTH);
246
+ case "number":
247
+ case "boolean":
248
+ case "bigint": return String(value);
249
+ case "function":
250
+ case "symbol": return;
251
+ }
252
+ try {
253
+ if (value instanceof Date) return value.toISOString();
254
+ return JSON.stringify(value)?.slice(0, MAX_PROP_VALUE_LENGTH);
255
+ } catch {
256
+ return;
257
+ }
258
+ }
259
+ /** Coerce and cap a `track()` property bag to the `Map(String, String)` the warehouse stores. */
260
+ function coerceTrackProps(props) {
261
+ if (!props) return {};
262
+ const out = {};
263
+ let bytes = 0;
264
+ for (const [rawKey, rawValue] of Object.entries(props)) {
265
+ if (Object.keys(out).length >= MAX_PROPS) break;
266
+ const value = coerce(rawValue);
267
+ if (value === void 0) continue;
268
+ const key = rawKey.slice(0, MAX_PROP_KEY_LENGTH);
269
+ if (!key) continue;
270
+ bytes += key.length + value.length;
271
+ if (bytes > MAX_TOTAL_PROP_BYTES) break;
272
+ out[key] = value;
273
+ }
274
+ return out;
275
+ }
276
+ //#endregion
277
+ //#region ../browser-session/src/events/track.ts
278
+ let warnedAboutName = false;
279
+ /**
280
+ * Record a custom product event against the current session.
281
+ *
282
+ * Stored as a `session_events` row with `Type='custom'`, so it shows up inline
283
+ * in the session transcript alongside the clicks and network calls that
284
+ * surround it — not in a separate analytics silo.
285
+ *
286
+ * Safe to call before the SDK finishes initializing: events are queued (capped)
287
+ * and drained once the sink starts. Never throws.
288
+ */
289
+ function track(name, props) {
290
+ if (!hasConsent()) return;
291
+ if (typeof name !== "string" || name.trim().length === 0) {
292
+ if (!warnedAboutName) {
293
+ warnedAboutName = true;
294
+ console.warn("[maple] track() needs a non-empty event name; the call was ignored.");
295
+ }
296
+ return;
297
+ }
298
+ const ev = {
299
+ type: "custom",
300
+ message: name.trim().slice(0, 128),
301
+ attrs: coerceTrackProps(props),
302
+ timestamp: Date.now(),
303
+ url: typeof location !== "undefined" ? location.href : void 0
304
+ };
305
+ const sink = getActiveSink();
306
+ if (sink) sink.emit(ev);
307
+ else queuePending(ev);
308
+ }
309
+ //#endregion
310
+ //#region ../browser-session/src/platform/region.ts
311
+ const MAPLE_REGIONS = ["us", "eu"];
312
+ const INGEST_ENDPOINTS = {
313
+ us: "https://ingest.maple.dev",
314
+ eu: "https://ingest.eu.maple.dev"
315
+ };
316
+ /**
317
+ * Narrow an untyped region (an env var, or a JS caller outside the type
318
+ * checker) to a known one. Case and surrounding whitespace are ignored;
319
+ * anything else is `undefined`.
320
+ */
321
+ function parseRegion(raw) {
322
+ if (typeof raw !== "string") return void 0;
323
+ const value = raw.trim().toLowerCase();
324
+ return MAPLE_REGIONS.find((region) => region === value);
325
+ }
326
+ let warnedRegions = /* @__PURE__ */ new Set();
327
+ /**
328
+ * The ingest base URL for a region. An unrecognized value falls back to the
329
+ * default region with a console warning rather than throwing: the ingest key
330
+ * belongs to one instance, so the other one rejects it and no data lands in
331
+ * the wrong place.
332
+ */
333
+ function ingestEndpointForRegion(region) {
334
+ if (region === void 0 || region === null || region === "") return INGEST_ENDPOINTS["us"];
335
+ const parsed = parseRegion(region);
336
+ if (parsed) return INGEST_ENDPOINTS[parsed];
337
+ const label = String(region);
338
+ if (!warnedRegions.has(label)) {
339
+ warnedRegions.add(label);
340
+ console.warn(`[maple] unknown region "${label}"; expected one of ${MAPLE_REGIONS.join(", ")}. Using "us".`);
341
+ }
342
+ return INGEST_ENDPOINTS["us"];
343
+ }
344
+ /** Loop rather than `/\/+$/`: that pattern backtracks polynomially on a long run of slashes. */
345
+ function trimTrailingSlashes(value) {
346
+ let end = value.length;
347
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
348
+ return value.slice(0, end);
349
+ }
350
+ /**
351
+ * Resolve the ingest base URL. An explicit URL always beats a region, whichever
352
+ * source each came from, so a collector or proxy endpoint is never bypassed by
353
+ * a region set elsewhere. Candidates are tried in order; the first non-empty
354
+ * one wins.
355
+ */
356
+ function resolveIngestEndpoint(options) {
357
+ for (const endpoint of options.endpoints) if (endpoint) return trimTrailingSlashes(endpoint);
358
+ return ingestEndpointForRegion(options.regions.find((value) => value !== void 0 && value !== null && value !== ""));
359
+ }
360
+ /** Maple's hosted ingest, which rejects every write that carries no ingest key. */
361
+ function isMapleIngestEndpoint(endpoint) {
362
+ let parsed;
363
+ try {
364
+ parsed = new URL(endpoint);
365
+ } catch {
366
+ return false;
367
+ }
368
+ if (trimTrailingSlashes(parsed.pathname) !== "") return false;
369
+ return MAPLE_REGIONS.some((region) => INGEST_ENDPOINTS[region] === parsed.origin);
370
+ }
371
+ let keylessWarned = /* @__PURE__ */ new Set();
372
+ /**
373
+ * Warn once per SDK about the one keyless setup that cannot work. The ingest
374
+ * key is auth only, so every SDK still sends without one (a proxy or local sink
375
+ * in front of a custom endpoint completes it), but Maple's hosted ingest 401s.
376
+ */
377
+ function warnIfKeylessMapleIngest(options) {
378
+ if (options.hasIngestKey || !isMapleIngestEndpoint(options.endpoint)) return;
379
+ if (keylessWarned.has(options.logPrefix)) return;
380
+ keylessWarned.add(options.logPrefix);
381
+ console.warn(`${options.logPrefix} sending to the public Maple ingest without an ingest key; every request will be rejected with 401. ${options.hint}`);
382
+ }
383
+ //#endregion
384
+ //#region ../sdk-core/src/errors.ts
385
+ /** Deep enough for real wrapping chains, bounded against a cause cycle or a huge aggregate. */
386
+ const MAX_LINKED = 5;
387
+ const isRecord = (value) => typeof value === "object" && value !== null;
388
+ /** Error-shaped: an `Error`, or a copy with its fields (Effect's pretty errors, a structured clone). */
389
+ const isErrorLike = (value) => value instanceof Error || isRecord(value) && typeof value.name === "string" && typeof value.message === "string" && (value.stack === void 0 || typeof value.stack === "string");
390
+ /**
391
+ * BOUNDARY: a thrown value is unparsed by definition, JavaScript can throw anything.
392
+ * Narrow it into an `Error` without ever throwing from the error path.
393
+ */
394
+ function asError(value) {
395
+ if (value instanceof Error) return value;
396
+ if (typeof value === "string") return new Error(value);
397
+ if (isRecord(value) && typeof value.message === "string") return new Error(value.message);
398
+ try {
399
+ return new Error(String(value));
400
+ } catch {
401
+ return /* @__PURE__ */ new Error("Unknown error");
402
+ }
403
+ }
404
+ /** What an error links to: its `cause`, and its `errors` list (duck-typed, so copies qualify). */
405
+ const linksOf = (value) => {
406
+ if (!isErrorLike(value)) return [];
407
+ const next = [];
408
+ try {
409
+ if ("errors" in value && Array.isArray(value.errors)) next.push(...value.errors);
410
+ if (value.cause !== void 0) next.push(value.cause);
411
+ } catch {}
412
+ return next;
413
+ };
414
+ /** A null-prototype object or a throwing `toString` must not break the error path. */
415
+ function render(value) {
416
+ if (isRecord(value) && typeof value.message === "string") return value.message;
417
+ try {
418
+ return String(value);
419
+ } catch {
420
+ return Object.prototype.toString.call(value);
421
+ }
422
+ }
423
+ function linkedErrors(error) {
424
+ const linked = [];
425
+ const seen = /* @__PURE__ */ new Set([error]);
426
+ const queue = [error];
427
+ while (queue.length > 0 && linked.length < MAX_LINKED) for (const candidate of linksOf(queue.shift())) {
428
+ if (seen.has(candidate) || linked.length >= MAX_LINKED) continue;
429
+ seen.add(candidate);
430
+ linked.push(candidate);
431
+ queue.push(candidate);
432
+ }
433
+ return linked;
434
+ }
435
+ /** V8 starts a stack with a `Name: message` header; the other engines start at the first frame. */
436
+ function framesOf(error) {
437
+ const stack = typeof error.stack === "string" ? error.stack : "";
438
+ const header = error.message ? `${error.name}: ${error.message}` : error.name;
439
+ return stack.startsWith(header) ? stack.slice(header.length).replace(/^\n/, "") : stack;
440
+ }
441
+ function describe(value) {
442
+ if (!isErrorLike(value)) return `Caused by: ${render(value)}`;
443
+ const frames = framesOf(value);
444
+ const header = `Caused by: ${value.name}: ${value.message}`;
445
+ return frames ? `${header}\n${frames}` : header;
446
+ }
447
+ /** The stack trace to record for `error`, with its linked errors appended. */
448
+ function stackWithCauses(error) {
449
+ const linked = linkedErrors(error);
450
+ if (linked.length === 0) return error.stack;
451
+ return [error.stack ?? `${error.name}: ${error.message}`, ...linked.map(describe)].join("\n");
452
+ }
453
+ //#endregion
454
+ //#region ../sdk-core/src/error-filters.ts
455
+ const EXTENSION_URL = /^(?:chrome|moz|safari(?:-web)?|ms-browser)-extension:\/\//;
456
+ const BENIGN_MESSAGES = [/^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications)/];
457
+ const URL_SCHEME = /^[a-z][\w+.-]*:\/\//i;
458
+ const DIGITS = /^\d+$/;
459
+ /**
460
+ * The script URL of one frame: `at fn (url:1:2)`, `at url:1:2` (V8) or `fn@url:1:2`
461
+ * (SpiderMonkey, JavaScriptCore). Parsed by position, not one regex: a stack is page
462
+ * data, and a backtracking pattern over it can be made to run in polynomial time.
463
+ */
464
+ function frameUrl(line) {
465
+ let frame = line.trim();
466
+ if (frame.endsWith(")")) {
467
+ const open = frame.lastIndexOf("(");
468
+ if (open === -1) return void 0;
469
+ frame = frame.slice(open + 1, -1);
470
+ } else if (frame.startsWith("at ")) frame = frame.slice(3);
471
+ else {
472
+ const at = frame.lastIndexOf("@");
473
+ if (at === -1) return void 0;
474
+ frame = frame.slice(at + 1);
475
+ }
476
+ for (let parts = 0; parts < 2; parts++) {
477
+ const colon = frame.lastIndexOf(":");
478
+ if (colon === -1 || !DIGITS.test(frame.slice(colon + 1))) {
479
+ if (parts === 0) return void 0;
480
+ break;
481
+ }
482
+ frame = frame.slice(0, colon);
483
+ }
484
+ if (!URL_SCHEME.test(frame) || /[\s()]/.test(frame)) return void 0;
485
+ return frame;
486
+ }
487
+ /** The script URL of each stack frame, top first. */
488
+ function frameUrls(stack) {
489
+ if (!stack) return [];
490
+ const urls = [];
491
+ for (const line of stack.split("\n")) {
492
+ const url = frameUrl(line);
493
+ if (url) urls.push(url);
494
+ }
495
+ return urls;
496
+ }
497
+ const matches = (value, patterns) => patterns.some((pattern) => {
498
+ if (typeof pattern === "string") return value.includes(pattern);
499
+ pattern.lastIndex = 0;
500
+ return pattern.test(value);
501
+ });
502
+ /**
503
+ * Build the filter for `options`. Without a `frameUrl`, only a thrown `Error`
504
+ * has frames of its own: an `Error` wrapped around anything else points at the
505
+ * SDK, so no URL list applies to it.
506
+ */
507
+ function makeErrorFilter(options = {}) {
508
+ return (error, hint, frameUrl) => {
509
+ const text = `${error.name}: ${error.message}`;
510
+ const topUrl = frameUrl ?? (hint.originalError instanceof Error ? frameUrls(error.stack)[0] : void 0);
511
+ if (options.defaultFilters !== false) {
512
+ if (matches(error.message, BENIGN_MESSAGES)) return false;
513
+ if (topUrl && EXTENSION_URL.test(topUrl)) return false;
514
+ }
515
+ if (options.ignore && matches(text, options.ignore)) return false;
516
+ if (topUrl && options.denyUrls && matches(topUrl, options.denyUrls)) return false;
517
+ if (topUrl && options.allowUrls?.length && !matches(topUrl, options.allowUrls)) return false;
518
+ if (!options.beforeCapture) return true;
519
+ try {
520
+ return options.beforeCapture(error, hint) !== false;
521
+ } catch {
522
+ return true;
523
+ }
524
+ };
525
+ }
526
+ //#endregion
527
+ //#region ../sdk-core/src/http-headers.ts
528
+ /** Never recorded, even when listed: they carry credentials. */
529
+ const CREDENTIAL_HEADERS = /* @__PURE__ */ new Set([
530
+ "authorization",
531
+ "proxy-authorization",
532
+ "cookie",
533
+ "set-cookie",
534
+ "x-api-key"
535
+ ]);
536
+ function resolveHeaderCapture(raw) {
537
+ const clean = (names) => (names ?? []).map((name) => name.toLowerCase()).filter((name) => !CREDENTIAL_HEADERS.has(name));
538
+ return {
539
+ request: clean(raw?.request),
540
+ response: clean(raw?.response)
541
+ };
542
+ }
543
+ //#endregion
544
+ //#region ../sdk-core/src/http-status.ts
545
+ const inStatusRanges = (status, ranges) => ranges.some((range) => typeof range === "number" ? range === status : status >= range[0] && status <= range[1]);
546
+ /** The status on a span's attributes, current or pre-1.23 semconv key. */
547
+ function responseStatus(read) {
548
+ const value = read("http.response.status_code") ?? read("http.status_code");
549
+ if (typeof value === "number") return value;
550
+ if (typeof value === "string" && /^\d{3}$/.test(value)) return Number(value);
551
+ }
552
+ /**
553
+ * The `error.type` / `error.message` pair for a listed status or a network
554
+ * failure (`TypeError`, `timeout`), e.g. `POST https://api.example.com/users/42 -> 503`.
555
+ * The query is dropped; ids in the path are redacted by the issue fingerprint.
556
+ */
557
+ function httpStatusError(read, status) {
558
+ const method = read("http.request.method") ?? read("http.method") ?? "GET";
559
+ const url = String(read("url.full") ?? read("http.url") ?? "").replace(/[?#].*$/, "");
560
+ return {
561
+ "error.type": String(status),
562
+ "error.message": `${String(method)} ${url} -> ${status}`
563
+ };
564
+ }
565
+ //#endregion
566
+ //#region ../sdk-core/src/log-record.ts
567
+ /** OTel severity numbers for the levels the SDKs emit. */
568
+ const Severity = {
569
+ DEBUG: 5,
570
+ INFO: 9,
571
+ WARN: 13,
572
+ ERROR: 17
573
+ };
574
+ /** A `Map`, not an object: a level string like `"constructor"` must not reach the prototype. */
575
+ const LEVELS = /* @__PURE__ */ new Map([
576
+ ["error", "ERROR"],
577
+ ["warn", "WARN"],
578
+ ["debug", "DEBUG"]
579
+ ]);
580
+ /** Severity for a console level: `log` and `info` are INFO. */
581
+ function severityOf(level) {
582
+ const text = (level !== void 0 ? LEVELS.get(level) : void 0) ?? "INFO";
583
+ return {
584
+ number: Severity[text],
585
+ text
586
+ };
587
+ }
588
+ //#endregion
589
+ //#region ../sdk-core/src/sampling.ts
590
+ /** FNV-1a over the session id, mapped to [0, 1). */
591
+ function sessionRoll(sessionId) {
592
+ let hash = 2166136261;
593
+ for (let i = 0; i < sessionId.length; i++) {
594
+ hash ^= sessionId.charCodeAt(i);
595
+ hash = Math.imul(hash, 16777619);
596
+ }
597
+ return (hash >>> 0) / 2 ** 32;
598
+ }
599
+ const MAX_56 = 2 ** 56;
600
+ /** 56 bits as the 14-hex-digit form `ot=th`/`ot=rv` use; `th` drops trailing zeros. */
601
+ const hex56 = (value) => value.toString(16).padStart(14, "0");
602
+ /**
603
+ * The W3C `ot=th:` rejection threshold for a sampling probability: 56 bits,
604
+ * hex, trailing zeros dropped. Ingest reads it back as the span's weight.
605
+ */
606
+ function rejectionThreshold(probability) {
607
+ return hex56(Math.round((1 - probability) * MAX_56)).replace(/0+$/, "") || "0";
608
+ }
609
+ /**
610
+ * The session's randomness as the W3C `ot=rv:` value. The decision is `rv >= th`,
611
+ * so a downstream consistent-probability sampler reaches the same answer.
612
+ */
613
+ function randomnessValue(roll) {
614
+ return Math.min(MAX_56 - 1, Math.floor((1 - roll) * MAX_56));
615
+ }
616
+ const KEEP$1 = { sampled: true };
617
+ const DROP = { sampled: false };
618
+ /**
619
+ * Whether a new root trace in `sessionId` is exported at `rate`. Without a
620
+ * session (SSR, consent withheld) the roll is per trace.
621
+ */
622
+ function sampleSession(sessionId, rate) {
623
+ if (rate >= 1) return KEEP$1;
624
+ if (rate <= 0) return DROP;
625
+ const rv = randomnessValue(sessionId ? sessionRoll(sessionId) : Math.random());
626
+ if (rv < Math.round((1 - rate) * MAX_56)) return DROP;
627
+ return {
628
+ sampled: true,
629
+ traceState: `ot=th:${rejectionThreshold(rate)};rv:${hex56(rv)}`
630
+ };
631
+ }
632
+ /** A rate outside 0 to 1 is a typo, not a policy: clamp it and say so. */
633
+ function resolveSampleRate(option, raw, fallback = 1) {
634
+ if (raw === void 0) return fallback;
635
+ if (typeof raw !== "number" || Number.isNaN(raw)) {
636
+ console.warn(`[maple] ${option} must be a number between 0 and 1; got ${String(raw)}. Using ${fallback}.`);
637
+ return fallback;
638
+ }
639
+ if (raw < 0 || raw > 1) {
640
+ const clamped = Math.min(1, Math.max(0, raw));
641
+ console.warn(`[maple] ${option} must be between 0 and 1; got ${raw}. Using ${clamped}.`);
642
+ return clamped;
643
+ }
644
+ return raw;
645
+ }
646
+ //#endregion
647
+ //#region ../sdk-core/src/options.ts
648
+ function resolveSignalOptions(options) {
649
+ return {
650
+ tracingSampleRate: resolveSampleRate("tracing.sampleRate", options.tracing?.sampleRate),
651
+ captureHeaders: resolveHeaderCapture(options.tracing?.captureHeaders),
652
+ longFrames: options.tracing?.longFrames ?? false,
653
+ slowInteractions: options.tracing?.slowInteractions ?? false,
654
+ errorFilters: options.errors ?? {},
655
+ webVitals: options.webVitals ?? true,
656
+ breadcrumbs: options.breadcrumbs ?? true,
657
+ captureConsole: options.logs?.captureConsole ?? [],
658
+ reportCsp: options.reporting?.csp ?? true,
659
+ reportBrowser: options.reporting?.browserReports ?? false,
660
+ offlineQueue: options.transport?.offline ?? false
661
+ };
662
+ }
663
+ /** Ingest keeps 1,024 bytes of a session-event attribute; this leaves room for the cut marker. */
664
+ const MAX_BODY_LENGTH = 1e3;
665
+ function resolveReplayOptions(replay) {
666
+ return {
667
+ enabled: replay?.enabled ?? true,
668
+ sampleRate: resolveSampleRate("replay.sampleRate", replay?.sampleRate),
669
+ onErrorSampleRate: resolveSampleRate("replay.onErrorSampleRate", replay?.onErrorSampleRate, 0),
670
+ canvasFps: replay?.canvasFps,
671
+ networkBodies: replay?.networkBodies?.urls.length ? {
672
+ urls: replay.networkBodies.urls,
673
+ maxLength: Math.min(MAX_BODY_LENGTH, replay.networkBodies.maxLength ?? MAX_BODY_LENGTH)
674
+ } : void 0
675
+ };
676
+ }
677
+ //#endregion
678
+ //#region ../sdk-core/src/page.ts
679
+ /** Versioned: a copy with another layout gets its own slot instead of overwriting this one. */
680
+ const KEY = "__MAPLE_SDK_PAGE_V1__";
681
+ /** This bundled copy: instances of one copy share a lease, another copy does not. */
682
+ const COPY = Symbol("maple-sdk-copy");
683
+ const isPageState = (value) => typeof value === "object" && value !== null && "version" in value && value.version === 1;
684
+ function page() {
685
+ const owner = globalThis;
686
+ const existing = owner[KEY];
687
+ if (isPageState(existing)) return existing;
688
+ const fresh = {
689
+ version: 1,
690
+ reported: /* @__PURE__ */ new WeakSet(),
691
+ errorListeners: /* @__PURE__ */ new Set(),
692
+ leases: /* @__PURE__ */ new Map()
693
+ };
694
+ owner[KEY] = fresh;
695
+ return fresh;
696
+ }
697
+ /** Whether this exact error object was already recorded, by either SDK. */
698
+ function wasReported(error) {
699
+ return typeof error === "object" && error !== null && page().reported.has(error);
700
+ }
701
+ /** Claim `error` as recorded, so a rethrow or a second handler does not record it again. */
702
+ function markReported(error) {
703
+ if (typeof error === "object" && error !== null) page().reported.add(error);
704
+ }
705
+ /** Told about every recorded error: breadcrumbs export their trail, a buffered replay keeps itself. */
706
+ function onErrorRecorded(listener) {
707
+ page().errorListeners.add(listener);
708
+ return () => page().errorListeners.delete(listener);
709
+ }
710
+ function notifyErrorRecorded(link) {
711
+ for (const listener of page().errorListeners) try {
712
+ listener(link);
713
+ } catch {}
714
+ }
715
+ /**
716
+ * Lease one page collector for this bundled copy. `undefined` means another
717
+ * copy already runs it, and starting it again would report everything twice.
718
+ */
719
+ function claimPageSignal(signal) {
720
+ const leases = page().leases;
721
+ const lease = leases.get(signal);
722
+ if (lease && lease.owner !== COPY) return void 0;
723
+ const held = lease ?? {
724
+ owner: COPY,
725
+ count: 0
726
+ };
727
+ held.count++;
728
+ leases.set(signal, held);
729
+ let released = false;
730
+ return () => {
731
+ if (released) return;
732
+ released = true;
733
+ held.count--;
734
+ if (held.count <= 0 && leases.get(signal) === held) leases.delete(signal);
735
+ };
736
+ }
737
+ //#endregion
738
+ //#region src/error-causes.ts
739
+ /**
740
+ * What to hand `span.recordException`: the error itself when nothing is linked,
741
+ * otherwise a copy carrying the longer stack. `code` is kept because OTel
742
+ * prefers it over `name` for `exception.type`.
743
+ */
744
+ function exceptionOf(error) {
745
+ const stack = stackWithCauses(error);
746
+ if (stack === error.stack) return error;
747
+ const code = "code" in error ? error.code : void 0;
748
+ const base = {
749
+ name: error.name,
750
+ message: error.message,
751
+ stack
752
+ };
753
+ return typeof code === "string" || typeof code === "number" ? {
754
+ ...base,
755
+ code
756
+ } : base;
757
+ }
758
+ //#endregion
759
+ //#region src/error-filters.ts
760
+ let filter = makeErrorFilter();
761
+ function configureErrorFilters(next) {
762
+ filter = makeErrorFilter(next);
763
+ }
764
+ const shouldCapture = (error, hint, frameUrl) => filter(error, hint, frameUrl);
765
+ //#endregion
766
+ //#region src/sampling.ts
767
+ const KEEP = createContextKey("maple.sampling.keep");
768
+ /**
769
+ * A context whose next span is always sampled, for errors. An unsampled parent
770
+ * is dropped first, so the error exports as a root rather than an orphan.
771
+ */
772
+ function keepContext(ctx) {
773
+ const parent = trace.getSpanContext(ctx);
774
+ return (parent && (parent.traceFlags & TraceFlags.SAMPLED) === 0 ? trace.deleteSpan(ctx) : ctx).setValue(KEEP, true);
775
+ }
776
+ var SessionSampler = class {
777
+ rate;
778
+ constructor(rate) {
779
+ this.rate = rate;
780
+ }
781
+ shouldSample(ctx) {
782
+ if (ctx.getValue(KEEP) === true) return { decision: SamplingDecision.RECORD_AND_SAMPLED };
783
+ const parent = trace.getSpanContext(ctx);
784
+ if (parent && isSpanContextValid(parent)) return { decision: (parent.traceFlags & TraceFlags.SAMPLED) === 0 ? SamplingDecision.NOT_RECORD : SamplingDecision.RECORD_AND_SAMPLED };
785
+ const decision = sampleSession(readSessionSink()?.sessionId, this.rate);
786
+ if (!decision.sampled) return { decision: SamplingDecision.NOT_RECORD };
787
+ return {
788
+ decision: SamplingDecision.RECORD_AND_SAMPLED,
789
+ ...decision.traceState ? { traceState: createTraceState(decision.traceState) } : void 0
790
+ };
791
+ }
792
+ toString() {
793
+ return `MapleSessionSampler{${this.rate}}`;
794
+ }
795
+ };
796
+ //#endregion
797
+ //#region src/http-headers.ts
798
+ /** `getAllResponseHeaders()` text as a lower-cased map: only the headers CORS exposes are in it. */
799
+ function responseHeaders(raw) {
800
+ const headers = /* @__PURE__ */ new Map();
801
+ for (const line of raw.split(/\r?\n/)) {
802
+ const colon = line.indexOf(":");
803
+ if (colon > 0) headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
804
+ }
805
+ return headers;
806
+ }
807
+ /** Stamp the listed headers that `read` finds. Cross-origin responses expose only CORS-safelisted headers unless the server lists more in `Access-Control-Expose-Headers`. */
808
+ function setHeaderAttributes(span, direction, names, read) {
809
+ for (const name of names) {
810
+ const value = read(name);
811
+ if (value) span.setAttribute(`http.${direction}.header.${name}`, [value]);
812
+ }
813
+ }
814
+ //#endregion
815
+ //#region src/http-status.ts
816
+ const HTTP_STATUS = /^\d{3}$/;
817
+ /** An Error set only because of the response status: `error.type` is the status code itself. */
818
+ function isStatusOnlyError(span) {
819
+ return span.kind === SpanKind.CLIENT && span.status.code === SpanStatusCode.ERROR && HTTP_STATUS.test(String(span.attributes["error.type"] ?? "")) && !span.events.some((event) => event.name === "exception");
820
+ }
821
+ function withStatus(span, status, attributes) {
822
+ return {
823
+ name: span.name,
824
+ kind: span.kind,
825
+ spanContext: () => span.spanContext(),
826
+ parentSpanContext: span.parentSpanContext,
827
+ startTime: span.startTime,
828
+ endTime: span.endTime,
829
+ status,
830
+ attributes,
831
+ links: span.links,
832
+ events: span.events,
833
+ duration: span.duration,
834
+ ended: span.ended,
835
+ resource: span.resource,
836
+ instrumentationScope: span.instrumentationScope,
837
+ droppedAttributesCount: span.droppedAttributesCount,
838
+ droppedEventsCount: span.droppedEventsCount,
839
+ droppedLinksCount: span.droppedLinksCount
840
+ };
841
+ }
842
+ var HttpStatusExporter = class {
843
+ inner;
844
+ captureStatus;
845
+ constructor(inner, captureStatus = []) {
846
+ this.inner = inner;
847
+ this.captureStatus = captureStatus;
848
+ }
849
+ apply(span) {
850
+ const read = (key) => span.attributes[key];
851
+ const status = responseStatus(read);
852
+ if (span.kind === SpanKind.CLIENT && status !== void 0 && inStatusRanges(status, this.captureStatus)) {
853
+ if (span.events.some((event) => event.name === "exception")) return span;
854
+ return withStatus(span, { code: SpanStatusCode.ERROR }, {
855
+ ...span.attributes,
856
+ ...httpStatusError(read, status)
857
+ });
858
+ }
859
+ const errorType = span.attributes["error.type"];
860
+ if (span.kind === SpanKind.CLIENT && span.status.code === SpanStatusCode.ERROR && typeof errorType === "string" && !HTTP_STATUS.test(errorType) && span.attributes["error.message"] === void 0 && !span.events.some((event) => event.name === "exception")) return withStatus(span, span.status, {
861
+ ...span.attributes,
862
+ "error.message": httpStatusError(read, errorType)["error.message"]
863
+ });
864
+ if (!isStatusOnlyError(span)) return span;
865
+ const { "error.type": _errorType, ...attributes } = span.attributes;
866
+ return withStatus(span, { code: SpanStatusCode.UNSET }, attributes);
867
+ }
868
+ export(spans, callback) {
869
+ this.inner.export(spans.map((span) => this.apply(span)), callback);
870
+ }
871
+ forceFlush() {
872
+ return this.inner.forceFlush?.() ?? Promise.resolve();
873
+ }
874
+ shutdown() {
875
+ return this.inner.shutdown();
876
+ }
877
+ };
878
+ //#endregion
879
+ //#region src/offline.ts
880
+ /** Failed batches held until the deferred chunk lands. */
881
+ const MAX_HELD = 20;
882
+ let stash;
883
+ let held = [];
884
+ function attachSpanStash(next) {
885
+ stash = next;
886
+ if (!next) return;
887
+ const pending = held;
888
+ held = [];
889
+ for (const spans of pending) next(spans);
890
+ }
891
+ var OfflineSpanExporter = class {
892
+ inner;
893
+ constructor(inner) {
894
+ this.inner = inner;
895
+ }
896
+ export(spans, callback) {
897
+ this.inner.export(spans, (result) => {
898
+ if (result.code !== 0) {
899
+ if (stash) stash(spans);
900
+ else {
901
+ held.push(spans);
902
+ if (held.length > MAX_HELD) held.shift();
903
+ }
904
+ }
905
+ callback(result);
906
+ });
907
+ }
908
+ forceFlush() {
909
+ return this.inner.forceFlush?.() ?? Promise.resolve();
910
+ }
911
+ shutdown() {
912
+ return this.inner.shutdown();
913
+ }
914
+ };
915
+ //#endregion
916
+ //#region src/version.ts
917
+ const SDK_VERSION = "0.9.0";
918
+ /** The `x-maple-sdk` value this build sends. */
919
+ const SDK_NAME = "maple-browser";
920
+ //#endregion
921
+ //#region src/tracing.ts
922
+ /** Span attributes that carry a page or request URL. */
923
+ const URL_ATTRIBUTES = ["url.full", "http.url"];
924
+ /**
925
+ * Captures every span's trace id into the session sink, and redacts URL
926
+ * attributes before anything can export them. Lightweight: runs alongside the
927
+ * BatchSpanProcessor, does no export of its own.
928
+ */
929
+ var TraceIdCollector = class {
930
+ getUserId;
931
+ constructor(getUserId = () => void 0) {
932
+ this.getUserId = getUserId;
933
+ }
934
+ onStart(span) {
935
+ for (const key of URL_ATTRIBUTES) {
936
+ const value = span.attributes[key];
937
+ if (typeof value === "string") span.setAttribute(key, scrubUrl(value));
938
+ }
939
+ if (!hasConsent()) return;
940
+ recordTraceId(span.spanContext().traceId);
941
+ const sessionId = readSessionSink()?.sessionId;
942
+ if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
943
+ const userId = this.getUserId();
944
+ if (userId !== void 0) span.setAttribute("user.id", userId);
945
+ }
946
+ onEnd(_span) {}
947
+ forceFlush() {
948
+ return Promise.resolve();
949
+ }
950
+ shutdown() {
951
+ return Promise.resolve();
952
+ }
953
+ };
954
+ /**
955
+ * Drops buffered spans captured while consent was absent. Checking start time,
956
+ * rather than only permission at flush time, also prevents a late grant from
957
+ * releasing spans that began before that grant.
958
+ */
959
+ var ConsentSpanExporter = class {
960
+ inner;
961
+ constructor(inner) {
962
+ this.inner = inner;
963
+ }
964
+ export(spans, callback) {
965
+ const since = consentAllowedSince();
966
+ if (!hasConsent() || !Number.isFinite(since)) {
967
+ callback({ code: 0 });
968
+ return;
969
+ }
970
+ const eligible = spans.filter((span) => span.startTime[0] * 1e3 + span.startTime[1] / 1e6 >= since);
971
+ if (eligible.length === 0) {
972
+ callback({ code: 0 });
973
+ return;
974
+ }
975
+ this.inner.export(eligible, callback);
976
+ }
977
+ forceFlush() {
978
+ return this.inner.forceFlush?.() ?? Promise.resolve();
979
+ }
980
+ shutdown() {
981
+ return this.inner.shutdown();
982
+ }
983
+ };
984
+ /**
985
+ * How long a span may sit in the batch queue before export.
986
+ *
987
+ * Shorter than OTel's 5s default: in a browser the queue is only as durable as
988
+ * the tab, and the unload flush below is a best-effort catch rather than a
989
+ * guarantee (a crashed or killed tab fires neither event). 2s trades a few more
990
+ * requests for a materially smaller loss window.
991
+ */
992
+ const EXPORT_INTERVAL_MS = 2e3;
993
+ /**
994
+ * The provider this SDK registered, while it is live. Everything Maple spans
995
+ * goes through it directly: the global provider may belong to the host app,
996
+ * which registered first and so kept the global.
997
+ */
998
+ let mapleProvider;
999
+ /** A tracer on Maple's provider while tracing is live, with no global fallback. */
1000
+ function liveMapleTracer(name, version) {
1001
+ return mapleProvider?.getTracer(name, version);
1002
+ }
1003
+ /** Resource attributes shared by every signal this SDK exports. */
1004
+ function resourceAttributes(config) {
1005
+ const attributes = {
1006
+ [ATTR_SERVICE_NAME]: config.serviceName,
1007
+ "maple.sdk.type": "browser"
1008
+ };
1009
+ if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
1010
+ if (config.serviceVersion) {
1011
+ attributes[ATTR_SERVICE_VERSION] = config.serviceVersion;
1012
+ if (/^[0-9a-f]{7,40}$/i.test(config.serviceVersion)) attributes["vcs.ref.head.revision"] = config.serviceVersion;
1013
+ }
1014
+ if (config.environment) {
1015
+ attributes["deployment.environment"] = config.environment;
1016
+ attributes["deployment.environment.name"] = config.environment;
1017
+ }
1018
+ return attributes;
1019
+ }
1020
+ /**
1021
+ * Set up browser OTel tracing exporting to Maple's ingest. When
1022
+ * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
1023
+ * their trace ids feed the session. Disable it when an external tracer (e.g.
1024
+ * the Effect client SDK) already instruments requests — that tracer feeds the
1025
+ * session via the published sink instead, and this avoids redundant duplicate
1026
+ * network spans. Returns a shutdown function.
1027
+ *
1028
+ * `session.id` is deliberately **not** a resource attribute: the resource is
1029
+ * fixed for the provider's lifetime, but sessions rotate under it (idle
1030
+ * rotation, consent revoke→re-grant), so a resource-level id would attribute
1031
+ * every post-rotation span to the ended session. `TraceIdCollector` stamps the
1032
+ * live id per span instead.
1033
+ */
1034
+ function setupTracing(config) {
1035
+ const otlp = new OTLPTraceExporter({
1036
+ url: `${config.endpoint}/v1/traces`,
1037
+ headers: ingestHeaders({
1038
+ ingestKey: config.ingestKey,
1039
+ sdk: sdkHint(SDK_NAME, SDK_VERSION)
1040
+ })
1041
+ });
1042
+ const exporter = new ConsentSpanExporter(new HttpStatusExporter(config.offlineQueue ? new OfflineSpanExporter(otlp) : otlp, config.errorFilters.captureHttpStatus));
1043
+ const provider = new WebTracerProvider({
1044
+ resource: resourceFromAttributes(resourceAttributes(config)),
1045
+ sampler: new SessionSampler(config.tracingSampleRate),
1046
+ spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
1047
+ });
1048
+ provider.register();
1049
+ mapleProvider = provider;
1050
+ const globalProvider = trace.getTracerProvider();
1051
+ const ownsGlobals = globalProvider === provider || globalProvider instanceof ProxyTracerProvider && globalProvider.getDelegate() === provider;
1052
+ const onExit = () => {
1053
+ provider.forceFlush().catch(() => {});
1054
+ };
1055
+ const onVisibilityChange = () => {
1056
+ if (document.visibilityState === "hidden") onExit();
1057
+ };
1058
+ const settledRequests = /* @__PURE__ */ new Map();
1059
+ const onPageHide = () => {
1060
+ for (const [span, endTime] of settledRequests) if (span.isRecording()) span.end(endTime);
1061
+ settledRequests.clear();
1062
+ onExit();
1063
+ };
1064
+ const noteSettled = (span) => {
1065
+ for (const settled of settledRequests.keys()) if (!settled.isRecording()) settledRequests.delete(settled);
1066
+ settledRequests.set(span, Date.now());
1067
+ };
1068
+ const canListen = typeof document !== "undefined" && typeof document.addEventListener === "function";
1069
+ if (canListen) {
1070
+ document.addEventListener("visibilitychange", onVisibilityChange);
1071
+ window.addEventListener("pagehide", onPageHide);
1072
+ }
1073
+ const requestOptions = {
1074
+ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)],
1075
+ propagateTraceHeaderCorsUrls: [...config.propagateTraceHeaderCorsUrls]
1076
+ };
1077
+ const headers = config.captureHeaders;
1078
+ const instrumentations = [...config.tracingInstrumentFetch ? [new FetchInstrumentation({
1079
+ ...requestOptions,
1080
+ applyCustomAttributesOnSpan: (span, request, result) => {
1081
+ noteSettled(span);
1082
+ const sent = request instanceof Request ? request.headers : new Headers(request.headers);
1083
+ setHeaderAttributes(span, "request", headers.request, (name) => sent.get(name));
1084
+ if (result instanceof Response) setHeaderAttributes(span, "response", headers.response, (name) => result.headers.get(name));
1085
+ else if (result instanceof Error && result.name !== "AbortError" && !request.signal?.aborted) {
1086
+ span.setStatus({
1087
+ code: SpanStatusCode.ERROR,
1088
+ message: result.message
1089
+ });
1090
+ span.setAttribute("error.type", result.name);
1091
+ }
1092
+ }
1093
+ })] : [], ...config.tracingInstrumentXhr ? [new XMLHttpRequestInstrumentation({
1094
+ ...requestOptions,
1095
+ applyCustomAttributesOnSpan: (span, xhr) => {
1096
+ noteSettled(span);
1097
+ if (headers.response.length === 0) return;
1098
+ const exposed = responseHeaders(xhr.getAllResponseHeaders());
1099
+ setHeaderAttributes(span, "response", headers.response, (name) => exposed.get(name));
1100
+ }
1101
+ })] : []];
1102
+ const unregisterInstrumentations = instrumentations.length > 0 ? registerInstrumentations({
1103
+ tracerProvider: provider,
1104
+ instrumentations
1105
+ }) : void 0;
1106
+ return async () => {
1107
+ if (canListen) {
1108
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1109
+ window.removeEventListener("pagehide", onPageHide);
1110
+ }
1111
+ unregisterInstrumentations?.();
1112
+ if (mapleProvider === provider) mapleProvider = void 0;
1113
+ await provider.shutdown();
1114
+ if (ownsGlobals) {
1115
+ trace.disable();
1116
+ context.disable();
1117
+ propagation.disable();
1118
+ }
1119
+ };
1120
+ }
1121
+ function escapeRegExp(value) {
1122
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1123
+ }
1124
+ //#endregion
1125
+ //#region src/errors.ts
1126
+ /**
1127
+ * Errors already reported, by identity, page-wide: a framework boundary that
1128
+ * reports an error and then rethrows it, or both Maple SDKs on one page, would
1129
+ * otherwise produce two issues for one crash.
1130
+ */
1131
+ const alreadyReported = wasReported;
1132
+ /**
1133
+ * Mark `span` as failed by `error`. The exception event goes on the first span
1134
+ * that records this error object; a later one (an outer `traced`, say) only
1135
+ * takes the Error status, so one error stays one issue. The error is claimed
1136
+ * only when the span is recording: before `init()` the tracer is a no-op, and
1137
+ * claiming it then would swallow the same error reported again once tracing is
1138
+ * live. Without consent the span is never exported, so the error is not claimed either.
1139
+ */
1140
+ function recordFailure(span, error) {
1141
+ const normalized = asError(error);
1142
+ if (!alreadyReported(error)) {
1143
+ const exported = span.isRecording() && hasConsent();
1144
+ if (exported) markReported(error);
1145
+ span.recordException(exceptionOf(normalized));
1146
+ if (exported) notifyErrorRecorded(span.spanContext());
1147
+ }
1148
+ span.setStatus({
1149
+ code: SpanStatusCode.ERROR,
1150
+ message: normalized.message
1151
+ });
1152
+ }
1153
+ /**
1154
+ * Record `error` on a one-off span, exported whatever the session's trace
1155
+ * sampling, unless the app's error filters drop it.
1156
+ */
1157
+ function recordException(error, options, source, filename, filtered = false) {
1158
+ if (!hasConsent()) return;
1159
+ if (!filtered && !shouldCapture(asError(error), {
1160
+ source,
1161
+ originalError: error
1162
+ }, filename)) return;
1163
+ const tracer = liveMapleTracer(SDK_NAME, SDK_VERSION);
1164
+ if (!tracer) return;
1165
+ const span = tracer.startSpan(options.name ?? "exception", {
1166
+ kind: SpanKind.INTERNAL,
1167
+ attributes: {
1168
+ ...typeof location !== "undefined" ? { "url.full": scrubUrl(location.href) } : void 0,
1169
+ ...options.attributes
1170
+ }
1171
+ }, keepContext(context.active()));
1172
+ recordFailure(span, error);
1173
+ span.end();
1174
+ }
1175
+ /**
1176
+ * Record an error that no span was watching. Safe before `init()`, where it
1177
+ * does nothing. Reporting the same error object twice records it once.
1178
+ */
1179
+ function captureException(error, options = {}) {
1180
+ if (alreadyReported(error)) return;
1181
+ recordException(error, options, "captureException");
1182
+ }
1183
+ /** Whether the app's error filters keep `error`, for failures recorded on an existing span. */
1184
+ function passesErrorFilters(error) {
1185
+ return shouldCapture(asError(error), {
1186
+ source: "captureException",
1187
+ originalError: error
1188
+ });
1189
+ }
1190
+ /** `captureException` for an error `passesErrorFilters` already let through: `beforeCapture` runs once. */
1191
+ function captureFilteredException(error, options = {}) {
1192
+ if (alreadyReported(error)) return;
1193
+ recordException(error, options, "captureException", void 0, true);
1194
+ }
1195
+ /**
1196
+ * Register global handlers for uncaught errors and unhandled rejections.
1197
+ * Returns a teardown that removes them.
1198
+ */
1199
+ function setupErrorCapture() {
1200
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return () => {};
1201
+ const onError = (event) => {
1202
+ const error = event.error ?? (event.message && event.filename ? new Error(event.message) : void 0);
1203
+ if (error === void 0 || alreadyReported(error)) return;
1204
+ recordException(error, {
1205
+ name: "browser.uncaught_error",
1206
+ attributes: {
1207
+ "maple.exception.source": "window.onerror",
1208
+ ...event.filename ? { "code.file.path": event.filename } : void 0,
1209
+ ...event.lineno ? { "code.line.number": event.lineno } : void 0,
1210
+ ...event.colno ? { "code.column.number": event.colno } : void 0
1211
+ }
1212
+ }, "window.onerror", event.error instanceof Error ? void 0 : event.filename || void 0);
1213
+ };
1214
+ const onUnhandledRejection = (event) => {
1215
+ if (alreadyReported(event.reason)) return;
1216
+ recordException(event.reason, {
1217
+ name: "browser.unhandled_rejection",
1218
+ attributes: { "maple.exception.source": "unhandledrejection" }
1219
+ }, "unhandledrejection");
1220
+ };
1221
+ window.addEventListener("error", onError);
1222
+ window.addEventListener("unhandledrejection", onUnhandledRejection);
1223
+ return () => {
1224
+ window.removeEventListener("error", onError);
1225
+ window.removeEventListener("unhandledrejection", onUnhandledRejection);
1226
+ };
1227
+ }
1228
+ //#endregion
1229
+ //#region src/config.ts
1230
+ /**
1231
+ * Resolve the identity from either the new `user` object or the legacy
1232
+ * `userId` string. `user` wins when both are set.
1233
+ */
1234
+ function resolveIdentity(config) {
1235
+ return normalizeIdentity(config.user ?? config.userId);
1236
+ }
1237
+ function resolveConfig(config) {
1238
+ const endpoint = resolveIngestEndpoint({
1239
+ endpoints: [config.endpoint],
1240
+ regions: [config.region]
1241
+ });
1242
+ warnIfKeylessMapleIngest({
1243
+ logPrefix: "[maple]",
1244
+ endpoint,
1245
+ hasIngestKey: Boolean(config.ingestKey),
1246
+ hint: "Pass `ingestKey`, or point `endpoint` at a proxy that adds it."
1247
+ });
1248
+ const replay = resolveReplayOptions(config.replay);
1249
+ return {
1250
+ ...resolveSignalOptions(config),
1251
+ ingestKey: config.ingestKey,
1252
+ serviceName: config.serviceName,
1253
+ endpoint,
1254
+ serviceNamespace: config.serviceNamespace,
1255
+ serviceVersion: config.serviceVersion,
1256
+ environment: config.environment,
1257
+ identity: resolveIdentity(config),
1258
+ tracingEnabled: config.tracing?.enabled ?? true,
1259
+ tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
1260
+ tracingInstrumentXhr: config.tracing?.instrumentXhr ?? true,
1261
+ tracingCaptureErrors: config.tracing?.captureErrors ?? true,
1262
+ propagateTraceHeaderCorsUrls: config.tracing?.propagateTraceHeaderCorsUrls ?? [],
1263
+ replayEnabled: replay.enabled,
1264
+ replaySampleRate: replay.sampleRate,
1265
+ replayOnErrorSampleRate: replay.onErrorSampleRate,
1266
+ canvasFps: replay.canvasFps,
1267
+ networkBodies: replay.networkBodies,
1268
+ maskAllInputs: config.privacy?.maskAllInputs ?? true,
1269
+ maskAllText: config.privacy?.maskAllText ?? false,
1270
+ persistVisitorId: config.privacy?.persistVisitorId ?? true,
1271
+ crossSubdomainCookie: config.privacy?.crossSubdomainCookie ?? true,
1272
+ cookieDomain: config.privacy?.cookieDomain,
1273
+ requireConsent: config.privacy?.requireConsent ?? false,
1274
+ captureUserEmail: config.privacy?.captureUserEmail ?? true,
1275
+ respectDoNotTrack: config.privacy?.respectDoNotTrack ?? false,
1276
+ sanitizeUrl: config.privacy?.sanitizeUrl
1277
+ };
1278
+ }
1279
+ //#endregion
1280
+ //#region src/logs.ts
1281
+ /** Held until the deferred chunk lands; beyond this, a page logging in a loop drops the oldest. */
1282
+ const MAX_QUEUED = 200;
1283
+ let queue = [];
1284
+ let sink;
1285
+ let getUserId = () => void 0;
1286
+ function emitLog(record) {
1287
+ if (!hasConsent()) return;
1288
+ const sessionId = readSessionSink()?.sessionId;
1289
+ const userId = getUserId();
1290
+ const attributes = {
1291
+ ...record.attributes,
1292
+ ...sessionId !== void 0 ? { "session.id": sessionId } : void 0,
1293
+ ...userId !== void 0 ? { "user.id": userId } : void 0
1294
+ };
1295
+ const queued = {
1296
+ ...record,
1297
+ attributes,
1298
+ timestamp: record.timestamp ?? Date.now(),
1299
+ link: record.link ?? trace.getSpanContext(context.active())
1300
+ };
1301
+ if (sink) {
1302
+ sink(queued);
1303
+ return;
1304
+ }
1305
+ queue.push(queued);
1306
+ if (queue.length > MAX_QUEUED) queue.shift();
1307
+ }
1308
+ /** Called by `init()`: where `user.id` comes from. */
1309
+ function setLogIdentity(read) {
1310
+ getUserId = read;
1311
+ }
1312
+ /** Called by the deferred chunk: drains the queue into `next`, then sends straight to it. */
1313
+ function attachLogSink(next) {
1314
+ sink = next;
1315
+ const pending = queue;
1316
+ queue = [];
1317
+ for (const record of pending) next(record);
1318
+ }
1319
+ function detachLogSink() {
1320
+ sink = void 0;
1321
+ getUserId = () => void 0;
1322
+ }
1323
+ //#endregion
1324
+ //#region src/navigation.ts
1325
+ /**
1326
+ * The navigation in flight. Only ever set in a browser: a server shares module
1327
+ * state across requests.
1328
+ */
1329
+ let navigation;
1330
+ /**
1331
+ * Whether the next navigation is a `pageload`: the first since the document
1332
+ * loaded, or since `shutdown()`. Consumed even when nothing is spanned (tracing
1333
+ * not live yet, consent pending): a later click is not the page load. Router
1334
+ * adapters must therefore be attached after `init()`.
1335
+ */
1336
+ let firstLoad = true;
1337
+ /**
1338
+ * Whether the document's own page load is still to come. Unlike `firstLoad`,
1339
+ * never reset: a `pageload` after `shutdown()` and a new `init()` is long past
1340
+ * the server render, and must not join its trace.
1341
+ */
1342
+ let documentLoad = true;
1343
+ /** Set by the deferred chunk, which spans the document's own load under `pageload`. */
1344
+ let pageloadListener;
1345
+ /** A document page load that began before the deferred chunk landed. */
1346
+ let pendingPageload;
1347
+ function onDocumentPageload(listener) {
1348
+ pageloadListener = listener;
1349
+ if (listener && pendingPageload) listener(pendingPageload.tracer, pendingPageload.span);
1350
+ pendingPageload = void 0;
1351
+ }
1352
+ /** Spans `traced` opened, which a nested `traced` may parent to instead of the navigation. */
1353
+ const tracedSpans = /* @__PURE__ */ new WeakSet();
1354
+ /**
1355
+ * Maple's tracer, while tracing is live and consent is given. A consent revoke
1356
+ * leaves the provider up but drops what it spans: an error recorded then would
1357
+ * be claimed without ever being exported.
1358
+ */
1359
+ const tracer = () => hasConsent() ? liveMapleTracer(SDK_NAME, SDK_VERSION) : void 0;
1360
+ /** The open navigation span, if it had already started at `epochMs`: a child must not begin before its parent. */
1361
+ function navigationSpanAt(epochMs) {
1362
+ return navigation && navigation.startedAt <= epochMs ? navigation.span : void 0;
1363
+ }
1364
+ /** End the open navigation as interrupted: something other than its route finishing ended it. */
1365
+ function interruptNavigation() {
1366
+ navigation?.span.setAttribute("app.navigation.interrupted", true);
1367
+ navigation?.span.end();
1368
+ navigation = void 0;
1369
+ }
1370
+ function startNavigation(path) {
1371
+ if (typeof window === "undefined") return;
1372
+ interruptNavigation();
1373
+ const kind = firstLoad ? "pageload" : "navigate";
1374
+ const joinServer = documentLoad;
1375
+ firstLoad = false;
1376
+ documentLoad = false;
1377
+ const live = tracer();
1378
+ if (!live) return;
1379
+ const parent = (joinServer ? serverContext() : void 0) ?? context.active();
1380
+ const startTime = joinServer ? Math.max(performance.timeOrigin, consentAllowedSince()) : void 0;
1381
+ const span = live.startSpan(kind, {
1382
+ startTime,
1383
+ attributes: { "url.path": scrubUrl(path) }
1384
+ }, parent);
1385
+ navigation = {
1386
+ kind,
1387
+ span,
1388
+ startedAt: startTime ?? Date.now()
1389
+ };
1390
+ if (joinServer) {
1391
+ if (pageloadListener) pageloadListener(live, span);
1392
+ else pendingPageload = {
1393
+ tracer: live,
1394
+ span
1395
+ };
1396
+ }
1397
+ window.addEventListener("pagehide", interruptNavigation, { capture: true });
1398
+ }
1399
+ function endNavigation(route) {
1400
+ if (!navigation) return;
1401
+ if (route) navigation.span.updateName(`${navigation.kind} ${scrubUrl(route)}`);
1402
+ navigation.span.end();
1403
+ navigation = void 0;
1404
+ }
1405
+ async function traced(name, fn, options = {}) {
1406
+ const live = tracer();
1407
+ if (!live) return fn();
1408
+ return live.startActiveSpan(name, {}, parentContext(), async (span) => {
1409
+ tracedSpans.add(span);
1410
+ try {
1411
+ return await fn();
1412
+ } catch (error) {
1413
+ if (isFailure(options, error) && passesErrorFilters(error)) {
1414
+ if (span.isRecording()) recordFailure(span, error);
1415
+ else captureFilteredException(error, { name });
1416
+ }
1417
+ throw error;
1418
+ } finally {
1419
+ span.end();
1420
+ }
1421
+ });
1422
+ }
1423
+ /**
1424
+ * The open navigation, unless a `traced` span is active: a `traced` inside
1425
+ * another `traced` nests under that one.
1426
+ */
1427
+ function parentContext() {
1428
+ const active = context.active();
1429
+ const activeSpan = trace.getSpan(active);
1430
+ if (!navigation || activeSpan && tracedSpans.has(activeSpan)) return active;
1431
+ return trace.setSpan(active, navigation.span);
1432
+ }
1433
+ /** `isFailure` is the app's code: if it throws, the original error still propagates. */
1434
+ function isFailure(options, error) {
1435
+ try {
1436
+ return options.isFailure?.(error) ?? true;
1437
+ } catch {
1438
+ return true;
1439
+ }
1440
+ }
1441
+ /**
1442
+ * End the open navigation so it exports with the provider's last flush, and
1443
+ * start the next `init()` at a page load. Called by `shutdown()`.
1444
+ */
1445
+ function resetNavigation() {
1446
+ interruptNavigation();
1447
+ firstLoad = true;
1448
+ pendingPageload = void 0;
1449
+ }
1450
+ /**
1451
+ * The trace the server rendered this page under: a `Server-Timing:
1452
+ * traceparent;desc="…"` entry on the document response, else a `<meta
1453
+ * name="traceparent">` tag.
1454
+ */
1455
+ function serverContext() {
1456
+ const [page] = performance.getEntriesByType("navigation");
1457
+ const spanContext = parseTraceparent(page?.serverTiming?.find((entry) => entry.name === "traceparent")?.description) ?? parseTraceparent(document.querySelector("meta[name=\"traceparent\"]")?.content);
1458
+ return spanContext && trace.setSpanContext(context.active(), spanContext);
1459
+ }
1460
+ /**
1461
+ * W3C `version-traceid-parentid-flags`. Parsed here rather than through the
1462
+ * global propagator, which the host app may own, or may not have registered.
1463
+ */
1464
+ const TRACEPARENT = /^([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})(-.*)?$/;
1465
+ function parseTraceparent(value) {
1466
+ const match = value?.trim().match(TRACEPARENT);
1467
+ if (!match || match[1] === "ff" || match[1] === "00" && match[5] !== void 0) return void 0;
1468
+ const spanContext = {
1469
+ traceId: match[2],
1470
+ spanId: match[3],
1471
+ traceFlags: Number.parseInt(match[4], 16),
1472
+ isRemote: true
1473
+ };
1474
+ return isSpanContextValid(spanContext) ? spanContext : void 0;
1475
+ }
1476
+ //#endregion
1477
+ //#region src/init.ts
1478
+ /** `x-maple-sdk` value for every request this build makes to ingest. */
1479
+ const SDK_HINT = sdkHint(SDK_NAME, SDK_VERSION);
1480
+ let active;
1481
+ let activeConfig;
1482
+ /**
1483
+ * An `identify()` made before `init()`, applied when it runs. Auth callbacks
1484
+ * routinely resolve before the SDK is initialized; dropping the call meant the
1485
+ * whole first session went anonymous. Wrapped so a pending *clear* (`undefined`)
1486
+ * is distinguishable from no call at all.
1487
+ */
1488
+ let pendingIdentity;
1489
+ /**
1490
+ * Initialize Maple browser telemetry. With consent gating enabled the returned
1491
+ * handle remains live while denied: granting starts capture, revoking detaches
1492
+ * it without flushing, and a later grant starts cleanly again.
1493
+ */
1494
+ function init(rawConfig) {
1495
+ if (active) return active;
1496
+ if (typeof window === "undefined") return {
1497
+ sessionId: "",
1498
+ shutdown: () => Promise.resolve()
1499
+ };
1500
+ const config = resolveConfig(rawConfig);
1501
+ if (pendingIdentity) config.identity = normalizeIdentity(pendingIdentity.input);
1502
+ pendingIdentity = void 0;
1503
+ activeConfig = config;
1504
+ configurePrivacy(config);
1505
+ configureErrorFilters(config.errorFilters);
1506
+ if (!hasConsent()) clearPendingEvents();
1507
+ setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
1508
+ const replayEligible = config.replayEnabled && !isLikelyBot(navigator.userAgent);
1509
+ let runtime;
1510
+ let stopped = false;
1511
+ let rotateOnNextStart = false;
1512
+ let shutdownTracing;
1513
+ let stopErrorCapture;
1514
+ let deferredPending;
1515
+ let stopDeferred;
1516
+ let generation = 0;
1517
+ const startRuntime = () => {
1518
+ if (stopped || runtime || !hasConsent()) return;
1519
+ setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
1520
+ const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
1521
+ rotateOnNextStart = false;
1522
+ const replayMode = replayEligible ? claimReplayMode(config.replaySampleRate, config.replayOnErrorSampleRate) : "off";
1523
+ publishSessionSink(session.id);
1524
+ const sink = startEventSink({
1525
+ endpoint: config.endpoint,
1526
+ ingestKey: config.ingestKey,
1527
+ sdk: SDK_HINT,
1528
+ maskAllInputs: config.maskAllInputs,
1529
+ maskAllText: config.maskAllText,
1530
+ getIdentity: () => activeConfig?.identity
1531
+ }, session.id);
1532
+ if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
1533
+ if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) stopErrorCapture = setupErrorCapture();
1534
+ if (!deferredPending) {
1535
+ setLogIdentity(() => activeConfig?.identity?.id);
1536
+ deferredPending = import("./deferred-CdtlxG0Q.mjs").then(({ startDeferred }) => {
1537
+ stopDeferred = startDeferred(config);
1538
+ }).catch(() => {});
1539
+ }
1540
+ const shared = {
1541
+ endpoint: config.endpoint,
1542
+ ingestKey: config.ingestKey,
1543
+ sdk: SDK_HINT,
1544
+ serviceName: config.serviceName,
1545
+ environment: config.environment,
1546
+ serviceVersion: config.serviceVersion,
1547
+ getIdentity: () => activeConfig?.identity,
1548
+ captureUserEmail: config.captureUserEmail
1549
+ };
1550
+ const startMetadata = () => startMetadataSession({
1551
+ ...shared,
1552
+ getTraceIds: getObservedTraceIds,
1553
+ onSessionChange: publishSessionSink
1554
+ });
1555
+ if (replayMode === "off") {
1556
+ runtime = {
1557
+ initialSessionId: session.id,
1558
+ sink,
1559
+ metadata: startMetadata()
1560
+ };
1561
+ return;
1562
+ }
1563
+ const next = {
1564
+ initialSessionId: session.id,
1565
+ sink
1566
+ };
1567
+ runtime = next;
1568
+ const ownGeneration = ++generation;
1569
+ const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
1570
+ next.replayPending = import("./replay-session-DdVYdHc_.mjs").then(({ startReplaySession }) => {
1571
+ if (stale()) return;
1572
+ next.replay = startReplaySession({
1573
+ ...shared,
1574
+ maskAllInputs: config.maskAllInputs,
1575
+ maskAllText: config.maskAllText,
1576
+ mode: replayMode,
1577
+ canvasFps: config.canvasFps,
1578
+ networkBodies: config.networkBodies
1579
+ });
1580
+ }).catch(() => {
1581
+ if (stale()) return;
1582
+ next.metadata = startMetadata();
1583
+ });
1584
+ };
1585
+ const stopRuntime = async (flush) => {
1586
+ generation++;
1587
+ const previous = runtime;
1588
+ runtime = void 0;
1589
+ if (!previous) return;
1590
+ const replayShutdown = previous.replay?.shutdown({ flush });
1591
+ const metadataShutdown = previous.metadata?.shutdown({ flush });
1592
+ const currentSessionId = previous.replay?.sessionId ?? previous.metadata?.sessionId;
1593
+ const liveSink = getActiveSink();
1594
+ const sink = liveSink && currentSessionId && liveSink.sessionId === currentSessionId ? liveSink : previous.sink;
1595
+ if (flush) await sink.flush(true);
1596
+ sink.stop();
1597
+ if (sink !== previous.sink) previous.sink.stop();
1598
+ clearSessionSink(currentSessionId ?? previous.initialSessionId);
1599
+ await Promise.all([
1600
+ replayShutdown,
1601
+ metadataShutdown,
1602
+ previous.replayPending
1603
+ ]);
1604
+ };
1605
+ const stopReplayTrigger = onErrorRecorded(() => {
1606
+ runtime?.replay?.trigger();
1607
+ });
1608
+ startRuntime();
1609
+ const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => {
1610
+ if (allowed) {
1611
+ startRuntime();
1612
+ return;
1613
+ }
1614
+ rotateOnNextStart = runtime !== void 0;
1615
+ clearPendingEvents();
1616
+ setVisitorTracking(false);
1617
+ stopRuntime(false);
1618
+ }) : () => {};
1619
+ const handle = {
1620
+ get sessionId() {
1621
+ return runtime?.replay?.sessionId ?? runtime?.metadata?.sessionId ?? runtime?.initialSessionId ?? "";
1622
+ },
1623
+ shutdown: async () => {
1624
+ if (stopped) return;
1625
+ stopped = true;
1626
+ stopConsentListener();
1627
+ stopReplayTrigger();
1628
+ await stopRuntime(true);
1629
+ stopErrorCapture?.();
1630
+ stopErrorCapture = void 0;
1631
+ configureErrorFilters(void 0);
1632
+ resetNavigation();
1633
+ await deferredPending;
1634
+ await shutdownTracing?.();
1635
+ shutdownTracing = void 0;
1636
+ await stopDeferred?.();
1637
+ stopDeferred = void 0;
1638
+ setActiveTraceIdProvider(() => void 0);
1639
+ active = void 0;
1640
+ activeConfig = void 0;
1641
+ }
1642
+ };
1643
+ active = handle;
1644
+ return handle;
1645
+ }
1646
+ /**
1647
+ * Attach, replace, or clear the end-user identity on the active session.
1648
+ * Idempotent and safe to call on every render. Future browser-created spans
1649
+ * read the id when they start, and future session metadata rows read the whole
1650
+ * identity when they post.
1651
+ *
1652
+ * Accepts a bare user id or the full object:
1653
+ *
1654
+ * ```ts
1655
+ * MapleBrowser.identify("user_123")
1656
+ * MapleBrowser.identify({ id: "user_123", email: "a@b.com", groupId: "org_1", groupName: "Acme" })
1657
+ * ```
1658
+ *
1659
+ * Each call replaces the identity rather than merging — merging would leak a
1660
+ * signed-out user's email into whoever signs in next on a shared device.
1661
+ *
1662
+ * Safe before `init()`: the latest call is held and applied when `init()` runs,
1663
+ * taking precedence over the `user` passed to it.
1664
+ */
1665
+ function identify(input) {
1666
+ if (typeof window === "undefined") return;
1667
+ if (!activeConfig) {
1668
+ pendingIdentity = { input };
1669
+ return;
1670
+ }
1671
+ activeConfig.identity = normalizeIdentity(input);
1672
+ }
1673
+ //#endregion
1674
+ //#region src/logger.ts
1675
+ const method = (level) => (message, attributes) => {
1676
+ try {
1677
+ emitLog({
1678
+ severityNumber: Severity[level],
1679
+ severityText: level,
1680
+ body: String(message),
1681
+ attributes
1682
+ });
1683
+ } catch {}
1684
+ };
1685
+ //#endregion
1686
+ //#region src/index.ts
1687
+ /**
1688
+ * Maple browser SDK. One call wires up OpenTelemetry tracing and rrweb session
1689
+ * replay, both tagged with a shared session id.
1690
+ *
1691
+ * @example
1692
+ * ```ts
1693
+ * import { MapleBrowser } from "@maple-dev/browser"
1694
+ *
1695
+ * MapleBrowser.init({
1696
+ * ingestKey: "maple_pk_...",
1697
+ * serviceName: "acme-web",
1698
+ * region: "eu", // omit for the US region
1699
+ * })
1700
+ *
1701
+ * MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
1702
+ * MapleBrowser.track("checkout_completed", { plan: "pro", seats: 5 })
1703
+ * ```
1704
+ */
1705
+ const MapleBrowser = {
1706
+ init,
1707
+ identify,
1708
+ track,
1709
+ captureException,
1710
+ setConsent,
1711
+ startNavigation,
1712
+ endNavigation,
1713
+ traced,
1714
+ logger: {
1715
+ debug: method("DEBUG"),
1716
+ info: method("INFO"),
1717
+ warn: method("WARN"),
1718
+ error: method("ERROR")
1719
+ }
1720
+ };
1721
+ //#endregion
1722
+ export { consentRevokedAt as _, detachLogSink as a, resourceAttributes as c, attachSpanStash as d, claimPageSignal as f, consentAllowedSince as g, severityOf as h, attachLogSink as i, SDK_NAME as l, Severity as m, navigationSpanAt as n, emitLog as o, onErrorRecorded as p, onDocumentPageload as r, liveMapleTracer as s, MapleBrowser as t, SDK_VERSION as u, hasConsent as v, onConsentChange as y };