@maple-dev/browser 0.9.0 → 0.10.1

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,855 +1,2 @@
1
- import { A as setVisitorTracking, D as setActiveTraceIdProvider, M as scrubUrl, S as sdkHint, _ as isLikelyBot, a as recordTraceId, c as getActiveSink, d as claimReplaySample, f as getSession, g as postSessionMetaRow, h as rotateSession, i as readSessionSink, j as addUrlSanitizer, k as configureVisitorCookie, l as queuePending, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as startEventSink, v as SDK_HINT_HEADER } from "./sink-DHPiMgU0.mjs";
2
- import { ProxyTracerProvider, SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
3
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
4
- import { registerInstrumentations } from "@opentelemetry/instrumentation";
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
- import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
10
- //#region ../browser-session/src/identity/consent.ts
11
- /**
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.
27
- */
28
- /**
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.
35
- */
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 {}
59
- }
60
- /**
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.
69
- */
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
78
- });
79
- if (options?.sanitizeUrl) addUrlSanitizer(options.sanitizeUrl);
80
- updateEffectiveConsent(previous);
81
- }
82
- /** Record the user's consent decision. No-op unless `requireConsent` is set. */
83
- function setConsent(nextGranted) {
84
- const previous = hasConsent();
85
- consentState().granted = nextGranted;
86
- updateEffectiveConsent(previous);
87
- }
88
- /** Whether capture may proceed at all. */
89
- function hasConsent() {
90
- const state = consentState();
91
- return !state.requireConsent || state.granted;
92
- }
93
- /**
94
- * Subscribe to effective capture permission changes. The listener is invoked
95
- * only when capture actually transitions between allowed and denied; changing
96
- * the stored decision while `requireConsent` is off is deliberately silent.
97
- */
98
- function onConsentChange(listener) {
99
- const { listeners } = consentState();
100
- listeners.add(listener);
101
- return () => listeners.delete(listener);
102
- }
103
- /**
104
- * Earliest epoch-ms timestamp telemetry may export for the current grant.
105
- * `Infinity` means capture is denied. Exporters use this to discard anything
106
- * buffered before a late grant or across a revoke/re-grant cycle.
107
- */
108
- function consentAllowedSince() {
109
- return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
110
- }
111
- /**
112
- * Whether a *persistent* identifier may be stored. Separate from `hasConsent`
113
- * because GPC/DNT suppress the cross-session id without suppressing capture.
114
- */
115
- function mayPersistIdentifier() {
116
- if (!hasConsent()) return false;
117
- if (typeof navigator === "undefined") return true;
118
- const nav = navigator;
119
- if (nav.globalPrivacyControl === true) return false;
120
- if (consentState().respectDoNotTrack && nav.doNotTrack === "1") return false;
121
- return true;
122
- }
123
- //#endregion
124
- //#region ../browser-session/src/identity/identity.ts
125
- /**
126
- * Trait caps. The warehouse column is `Map(String, String)` — plain keys
127
- * precisely because they are arbitrary — so the cost of a unique key per user
128
- * is row size and query-time map width rather than a churned dictionary. The
129
- * cap bounds one row's damage; `warnOnIdLikeTraitKey` surfaces the pattern to
130
- * whoever wrote it, since an id in a trait key is nearly always a mistake.
131
- */
132
- const MAX_TRAITS = 24;
133
- const MAX_TRAIT_KEY_LENGTH = 64;
134
- const MAX_TRAIT_VALUE_LENGTH = 256;
135
- let warnedAboutTraitKeys = false;
136
- /** Heuristic: a trait *key* that looks like an id is almost always a mistake. */
137
- function looksLikeId(key) {
138
- return /^[0-9a-f]{8,}$/i.test(key) || /^(usr|user|org|cus|acct)_/i.test(key);
139
- }
140
- function coerceTraitValue(value) {
141
- if (value === null || value === void 0) return void 0;
142
- return (typeof value === "string" ? value : String(value)).slice(0, MAX_TRAIT_VALUE_LENGTH);
143
- }
144
- function normalizeTraits(traits) {
145
- if (!traits) return {};
146
- const out = {};
147
- for (const [rawKey, rawValue] of Object.entries(traits)) {
148
- if (Object.keys(out).length >= MAX_TRAITS) break;
149
- const value = coerceTraitValue(rawValue);
150
- if (value === void 0) continue;
151
- const key = rawKey.slice(0, MAX_TRAIT_KEY_LENGTH);
152
- if (!key) continue;
153
- if (!warnedAboutTraitKeys && looksLikeId(key)) {
154
- warnedAboutTraitKeys = true;
155
- 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.`);
156
- }
157
- out[key] = value;
158
- }
159
- return out;
160
- }
161
- function trimmed(value) {
162
- const text = value?.trim();
163
- return text ? text : void 0;
164
- }
165
- /**
166
- * Normalize whatever a host app passed into the shape the metadata row wants.
167
- *
168
- * Each call *replaces* the identity rather than merging into the previous one.
169
- * Merging would leak a signed-out user's email into the next person to use a
170
- * shared device, and replacement is what the existing string-only `identify`
171
- * already did.
172
- */
173
- function normalizeIdentity(input) {
174
- if (input === null || input === void 0) return void 0;
175
- if (typeof input === "string") {
176
- const id = trimmed(input);
177
- return id ? {
178
- id,
179
- traits: {}
180
- } : void 0;
181
- }
182
- const resolved = {
183
- id: trimmed(input.id),
184
- email: trimmed(input.email),
185
- username: trimmed(input.username),
186
- groupId: trimmed(input.groupId),
187
- groupName: trimmed(input.groupName),
188
- traits: normalizeTraits(input.traits)
189
- };
190
- return resolved.id || resolved.email || resolved.username || resolved.groupId || resolved.groupName || Object.keys(resolved.traits).length > 0 ? resolved : void 0;
191
- }
192
- //#endregion
193
- //#region ../browser-session/src/session/metadata-session.ts
194
- /**
195
- * Metadata-only session lifecycle used whenever rrweb is disabled or unsampled.
196
- * It is the shared lifecycle driver with no capture of its own: the distilled
197
- * event sink runs on every page load regardless, so this owner only has to post
198
- * rows and flush that sink on the way out.
199
- */
200
- function startMetadataSession(options) {
201
- return startSessionLifecycle(options, {
202
- recorded: false,
203
- post: (row, keepalive) => {
204
- postSessionMetaRow(options, row, keepalive);
205
- },
206
- onSuspend: ({ flush, keepalive }) => {
207
- if (flush && keepalive) getActiveSink()?.flush(true);
208
- },
209
- onSessionChange: options.onSessionChange
210
- });
211
- }
212
- //#endregion
213
- //#region ../browser-session/src/events/props.ts
214
- const MAX_PROPS = 32;
215
- const MAX_PROP_KEY_LENGTH = 64;
216
- const MAX_PROP_VALUE_LENGTH = 1024;
217
- const MAX_TOTAL_PROP_BYTES = 8192;
218
- /**
219
- * Coerce one property value to the string the warehouse column holds.
220
- *
221
- * `null`/`undefined`/functions/symbols are dropped rather than stringified —
222
- * `"undefined"` as a stored value is worse than an absent key.
223
- */
224
- function coerce(value) {
225
- if (value === null || value === void 0) return void 0;
226
- switch (typeof value) {
227
- case "string": return value.slice(0, MAX_PROP_VALUE_LENGTH);
228
- case "number":
229
- case "boolean":
230
- case "bigint": return String(value);
231
- case "function":
232
- case "symbol": return;
233
- }
234
- try {
235
- if (value instanceof Date) return value.toISOString();
236
- return JSON.stringify(value)?.slice(0, MAX_PROP_VALUE_LENGTH);
237
- } catch {
238
- return;
239
- }
240
- }
241
- /** Coerce and cap a `track()` property bag to the `Map(String, String)` the warehouse stores. */
242
- function coerceTrackProps(props) {
243
- if (!props) return {};
244
- const out = {};
245
- let bytes = 0;
246
- for (const [rawKey, rawValue] of Object.entries(props)) {
247
- if (Object.keys(out).length >= MAX_PROPS) break;
248
- const value = coerce(rawValue);
249
- if (value === void 0) continue;
250
- const key = rawKey.slice(0, MAX_PROP_KEY_LENGTH);
251
- if (!key) continue;
252
- bytes += key.length + value.length;
253
- if (bytes > MAX_TOTAL_PROP_BYTES) break;
254
- out[key] = value;
255
- }
256
- return out;
257
- }
258
- //#endregion
259
- //#region ../browser-session/src/events/track.ts
260
- let warnedAboutName = false;
261
- /**
262
- * Record a custom product event against the current session.
263
- *
264
- * Stored as a `session_events` row with `Type='custom'`, so it shows up inline
265
- * in the session transcript alongside the clicks and network calls that
266
- * surround it — not in a separate analytics silo.
267
- *
268
- * Safe to call before the SDK finishes initializing: events are queued (capped)
269
- * and drained once the sink starts. Never throws.
270
- */
271
- function track(name, props) {
272
- if (!hasConsent()) return;
273
- if (typeof name !== "string" || name.trim().length === 0) {
274
- if (!warnedAboutName) {
275
- warnedAboutName = true;
276
- console.warn("[maple] track() needs a non-empty event name; the call was ignored.");
277
- }
278
- return;
279
- }
280
- const ev = {
281
- type: "custom",
282
- message: name.trim().slice(0, 128),
283
- attrs: coerceTrackProps(props),
284
- timestamp: Date.now(),
285
- url: typeof location !== "undefined" ? location.href : void 0
286
- };
287
- const sink = getActiveSink();
288
- if (sink) sink.emit(ev);
289
- else queuePending(ev);
290
- }
291
- //#endregion
292
- //#region ../browser-session/src/platform/region.ts
293
- const MAPLE_REGIONS = ["us", "eu"];
294
- const INGEST_ENDPOINTS = {
295
- us: "https://ingest.maple.dev",
296
- eu: "https://ingest.eu.maple.dev"
297
- };
298
- /**
299
- * Narrow an untyped region (an env var, or a JS caller outside the type
300
- * checker) to a known one. Case and surrounding whitespace are ignored;
301
- * anything else is `undefined`.
302
- */
303
- function parseRegion(raw) {
304
- if (typeof raw !== "string") return void 0;
305
- const value = raw.trim().toLowerCase();
306
- return MAPLE_REGIONS.find((region) => region === value);
307
- }
308
- let warnedRegions = /* @__PURE__ */ new Set();
309
- /**
310
- * The ingest base URL for a region. An unrecognized value falls back to the
311
- * default region with a console warning rather than throwing: the ingest key
312
- * belongs to one instance, so the other one rejects it and no data lands in
313
- * the wrong place.
314
- */
315
- function ingestEndpointForRegion(region) {
316
- if (region === void 0 || region === null || region === "") return INGEST_ENDPOINTS["us"];
317
- const parsed = parseRegion(region);
318
- if (parsed) return INGEST_ENDPOINTS[parsed];
319
- const label = String(region);
320
- if (!warnedRegions.has(label)) {
321
- warnedRegions.add(label);
322
- console.warn(`[maple] unknown region "${label}"; expected one of ${MAPLE_REGIONS.join(", ")}. Using "us".`);
323
- }
324
- return INGEST_ENDPOINTS["us"];
325
- }
326
- /** Loop rather than `/\/+$/`: that pattern backtracks polynomially on a long run of slashes. */
327
- function trimTrailingSlashes(value) {
328
- let end = value.length;
329
- while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
330
- return value.slice(0, end);
331
- }
332
- /**
333
- * Resolve the ingest base URL. An explicit URL always beats a region, whichever
334
- * source each came from, so a collector or proxy endpoint is never bypassed by
335
- * a region set elsewhere. Candidates are tried in order; the first non-empty
336
- * one wins.
337
- */
338
- function resolveIngestEndpoint(options) {
339
- for (const endpoint of options.endpoints) if (endpoint) return trimTrailingSlashes(endpoint);
340
- return ingestEndpointForRegion(options.regions.find((value) => value !== void 0 && value !== null && value !== ""));
341
- }
342
- //#endregion
343
- //#region src/version.ts
344
- const SDK_VERSION = "0.9.0";
345
- /** The `x-maple-sdk` value this build sends. */
346
- const SDK_NAME = "maple-browser";
347
- //#endregion
348
- //#region src/tracing.ts
349
- /** Span attributes that carry a page or request URL. */
350
- const URL_ATTRIBUTES = ["url.full", "http.url"];
351
- /**
352
- * Captures every span's trace id into the session sink, and redacts URL
353
- * attributes before anything can export them. Lightweight: runs alongside the
354
- * BatchSpanProcessor, does no export of its own.
355
- */
356
- var TraceIdCollector = class {
357
- getUserId;
358
- constructor(getUserId = () => void 0) {
359
- this.getUserId = getUserId;
360
- }
361
- onStart(span) {
362
- for (const key of URL_ATTRIBUTES) {
363
- const value = span.attributes[key];
364
- if (typeof value === "string") span.setAttribute(key, scrubUrl(value));
365
- }
366
- if (!hasConsent()) return;
367
- recordTraceId(span.spanContext().traceId);
368
- const sessionId = readSessionSink()?.sessionId;
369
- if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
370
- const userId = this.getUserId();
371
- if (userId !== void 0) span.setAttribute("user.id", userId);
372
- }
373
- onEnd(_span) {}
374
- forceFlush() {
375
- return Promise.resolve();
376
- }
377
- shutdown() {
378
- return Promise.resolve();
379
- }
380
- };
381
- /**
382
- * Drops buffered spans captured while consent was absent. Checking start time,
383
- * rather than only permission at flush time, also prevents a late grant from
384
- * releasing spans that began before that grant.
385
- */
386
- var ConsentSpanExporter = class {
387
- inner;
388
- constructor(inner) {
389
- this.inner = inner;
390
- }
391
- export(spans, callback) {
392
- const since = consentAllowedSince();
393
- if (!hasConsent() || !Number.isFinite(since)) {
394
- callback({ code: 0 });
395
- return;
396
- }
397
- const eligible = spans.filter((span) => span.startTime[0] * 1e3 + span.startTime[1] / 1e6 >= since);
398
- if (eligible.length === 0) {
399
- callback({ code: 0 });
400
- return;
401
- }
402
- this.inner.export(eligible, callback);
403
- }
404
- forceFlush() {
405
- return this.inner.forceFlush?.() ?? Promise.resolve();
406
- }
407
- shutdown() {
408
- return this.inner.shutdown();
409
- }
410
- };
411
- /**
412
- * How long a span may sit in the batch queue before export.
413
- *
414
- * Shorter than OTel's 5s default: in a browser the queue is only as durable as
415
- * the tab, and the unload flush below is a best-effort catch rather than a
416
- * guarantee (a crashed or killed tab fires neither event). 2s trades a few more
417
- * requests for a materially smaller loss window.
418
- */
419
- const EXPORT_INTERVAL_MS = 2e3;
420
- /**
421
- * The provider this SDK registered, while it is live. `captureException` spans
422
- * through it directly: the global provider may belong to the host app, which
423
- * registered first and so kept the global.
424
- */
425
- let mapleProvider;
426
- /** A tracer on Maple's provider when tracing is live, otherwise the global one. */
427
- function mapleTracer(name, version) {
428
- return (mapleProvider ?? trace.getTracerProvider()).getTracer(name, version);
429
- }
430
- /**
431
- * Set up browser OTel tracing exporting to Maple's ingest. When
432
- * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
433
- * their trace ids feed the session. Disable it when an external tracer (e.g.
434
- * the Effect client SDK) already instruments requests — that tracer feeds the
435
- * session via the published sink instead, and this avoids redundant duplicate
436
- * network spans. Returns a shutdown function.
437
- *
438
- * `session.id` is deliberately **not** a resource attribute: the resource is
439
- * fixed for the provider's lifetime, but sessions rotate under it (idle
440
- * rotation, consent revoke→re-grant), so a resource-level id would attribute
441
- * every post-rotation span to the ended session. `TraceIdCollector` stamps the
442
- * live id per span instead.
443
- */
444
- function setupTracing(config) {
445
- const attributes = {
446
- [ATTR_SERVICE_NAME]: config.serviceName,
447
- "maple.sdk.type": "browser"
448
- };
449
- if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
450
- if (config.serviceVersion) {
451
- attributes[ATTR_SERVICE_VERSION] = config.serviceVersion;
452
- if (/^[0-9a-f]{7,40}$/i.test(config.serviceVersion)) attributes["vcs.ref.head.revision"] = config.serviceVersion;
453
- }
454
- if (config.environment) {
455
- attributes["deployment.environment"] = config.environment;
456
- attributes["deployment.environment.name"] = config.environment;
457
- }
458
- const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
459
- url: `${config.endpoint}/v1/traces`,
460
- headers: {
461
- Authorization: `Bearer ${config.ingestKey}`,
462
- [SDK_HINT_HEADER]: sdkHint(SDK_NAME, SDK_VERSION)
463
- }
464
- }));
465
- const provider = new WebTracerProvider({
466
- resource: resourceFromAttributes(attributes),
467
- spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
468
- });
469
- provider.register();
470
- mapleProvider = provider;
471
- const globalProvider = trace.getTracerProvider();
472
- const ownsGlobals = globalProvider === provider || globalProvider instanceof ProxyTracerProvider && globalProvider.getDelegate() === provider;
473
- const onExit = () => {
474
- provider.forceFlush().catch(() => {});
475
- };
476
- const onVisibilityChange = () => {
477
- if (document.visibilityState === "hidden") onExit();
478
- };
479
- const canListen = typeof document !== "undefined" && typeof document.addEventListener === "function";
480
- if (canListen) {
481
- document.addEventListener("visibilitychange", onVisibilityChange);
482
- window.addEventListener("pagehide", onExit);
483
- }
484
- const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({
485
- tracerProvider: provider,
486
- instrumentations: [new FetchInstrumentation({
487
- ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)],
488
- propagateTraceHeaderCorsUrls: [...config.propagateTraceHeaderCorsUrls]
489
- })]
490
- }) : void 0;
491
- return async () => {
492
- if (canListen) {
493
- document.removeEventListener("visibilitychange", onVisibilityChange);
494
- window.removeEventListener("pagehide", onExit);
495
- }
496
- unregisterInstrumentations?.();
497
- if (mapleProvider === provider) mapleProvider = void 0;
498
- await provider.shutdown();
499
- if (ownsGlobals) {
500
- trace.disable();
501
- context.disable();
502
- propagation.disable();
503
- }
504
- };
505
- }
506
- function escapeRegExp(value) {
507
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
508
- }
509
- //#endregion
510
- //#region src/errors.ts
511
- const asError = (value) => {
512
- if (value instanceof Error) return value;
513
- if (typeof value === "string") return new Error(value);
514
- if (typeof value === "object" && value !== null) {
515
- const message = value.message;
516
- if (typeof message === "string") return new Error(message);
517
- }
518
- try {
519
- return new Error(String(value));
520
- } catch {
521
- return /* @__PURE__ */ new Error("Unknown error");
522
- }
523
- };
524
- /**
525
- * Errors already reported, by identity. Module-level so the global handlers and
526
- * `captureException` share it: a framework boundary that reports an error and
527
- * then rethrows it would otherwise produce two issues for one crash.
528
- */
529
- let reported = /* @__PURE__ */ new WeakSet();
530
- /** Whether this exact error object was already recorded. */
531
- const alreadyReported = (error) => typeof error === "object" && error !== null && reported.has(error);
532
- /**
533
- * Record `error` on a one-off span. The error is claimed only when the span is
534
- * recording: before `init()` the tracer is a no-op, and claiming it then would
535
- * swallow the same error reported again once tracing is live.
536
- */
537
- function recordException(error, options) {
538
- const normalized = asError(error);
539
- const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan(options.name ?? "exception", {
540
- kind: SpanKind.INTERNAL,
541
- attributes: {
542
- ...typeof location !== "undefined" ? { "url.full": scrubUrl(location.href) } : void 0,
543
- ...options.attributes
544
- }
545
- });
546
- if (span.isRecording() && typeof error === "object" && error !== null) reported.add(error);
547
- span.recordException(normalized);
548
- span.setStatus({
549
- code: SpanStatusCode.ERROR,
550
- message: normalized.message
551
- });
552
- span.end();
553
- }
554
- /**
555
- * Record an error that no span was watching. Safe before `init()`: without a
556
- * registered provider the OTel API hands back a no-op tracer and this does
557
- * nothing. Reporting the same error object twice records it once.
558
- */
559
- function captureException(error, options = {}) {
560
- if (alreadyReported(error)) return;
561
- recordException(error, options);
562
- }
563
- /**
564
- * Register global handlers for uncaught errors and unhandled rejections.
565
- * Returns a teardown that removes them.
566
- */
567
- function setupErrorCapture() {
568
- if (typeof window === "undefined" || typeof window.addEventListener !== "function") return () => {};
569
- const onError = (event) => {
570
- const error = event.error ?? (event.message && event.filename ? new Error(event.message) : void 0);
571
- if (error === void 0 || alreadyReported(error)) return;
572
- recordException(error, {
573
- name: "browser.uncaught_error",
574
- attributes: {
575
- "maple.exception.source": "window.onerror",
576
- ...event.filename ? { "code.file.path": event.filename } : void 0,
577
- ...event.lineno ? { "code.line.number": event.lineno } : void 0
578
- }
579
- });
580
- };
581
- const onUnhandledRejection = (event) => {
582
- if (alreadyReported(event.reason)) return;
583
- recordException(event.reason, {
584
- name: "browser.unhandled_rejection",
585
- attributes: { "maple.exception.source": "unhandledrejection" }
586
- });
587
- };
588
- window.addEventListener("error", onError);
589
- window.addEventListener("unhandledrejection", onUnhandledRejection);
590
- return () => {
591
- window.removeEventListener("error", onError);
592
- window.removeEventListener("unhandledrejection", onUnhandledRejection);
593
- };
594
- }
595
- //#endregion
596
- //#region src/config.ts
597
- /**
598
- * Resolve the identity from either the new `user` object or the legacy
599
- * `userId` string. `user` wins when both are set.
600
- */
601
- function resolveIdentity(config) {
602
- return normalizeIdentity(config.user ?? config.userId);
603
- }
604
- /**
605
- * A sample rate outside 0–1 (or not a number) is a typo, not a policy. Clamp it
606
- * and say so, rather than recording everyone or no one without a word.
607
- */
608
- function resolveSampleRate(raw) {
609
- if (raw === void 0) return 1;
610
- if (typeof raw !== "number" || Number.isNaN(raw)) {
611
- console.warn(`[maple] replay.sampleRate must be a number between 0 and 1; got ${String(raw)}. Using 1.`);
612
- return 1;
613
- }
614
- if (raw < 0 || raw > 1) {
615
- const clamped = Math.min(1, Math.max(0, raw));
616
- console.warn(`[maple] replay.sampleRate must be between 0 and 1; got ${raw}. Using ${clamped}.`);
617
- return clamped;
618
- }
619
- return raw;
620
- }
621
- function resolveConfig(config) {
622
- if (!config.ingestKey) console.warn("[maple] MapleBrowser.init() has no ingestKey; ingest will reject every request.");
623
- return {
624
- ingestKey: config.ingestKey,
625
- serviceName: config.serviceName,
626
- endpoint: resolveIngestEndpoint({
627
- endpoints: [config.endpoint],
628
- regions: [config.region]
629
- }),
630
- serviceNamespace: config.serviceNamespace,
631
- serviceVersion: config.serviceVersion,
632
- environment: config.environment,
633
- identity: resolveIdentity(config),
634
- tracingEnabled: config.tracing?.enabled ?? true,
635
- tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
636
- tracingCaptureErrors: config.tracing?.captureErrors ?? true,
637
- propagateTraceHeaderCorsUrls: config.tracing?.propagateTraceHeaderCorsUrls ?? [],
638
- replayEnabled: config.replay?.enabled ?? true,
639
- replaySampleRate: resolveSampleRate(config.replay?.sampleRate),
640
- maskAllInputs: config.privacy?.maskAllInputs ?? true,
641
- maskAllText: config.privacy?.maskAllText ?? false,
642
- persistVisitorId: config.privacy?.persistVisitorId ?? true,
643
- crossSubdomainCookie: config.privacy?.crossSubdomainCookie ?? true,
644
- cookieDomain: config.privacy?.cookieDomain,
645
- requireConsent: config.privacy?.requireConsent ?? false,
646
- captureUserEmail: config.privacy?.captureUserEmail ?? true,
647
- respectDoNotTrack: config.privacy?.respectDoNotTrack ?? false,
648
- sanitizeUrl: config.privacy?.sanitizeUrl
649
- };
650
- }
651
- //#endregion
652
- //#region src/init.ts
653
- /** `x-maple-sdk` value for every request this build makes to ingest. */
654
- const SDK_HINT = sdkHint(SDK_NAME, SDK_VERSION);
655
- let active;
656
- let activeConfig;
657
- /**
658
- * An `identify()` made before `init()`, applied when it runs. Auth callbacks
659
- * routinely resolve before the SDK is initialized; dropping the call meant the
660
- * whole first session went anonymous. Wrapped so a pending *clear* (`undefined`)
661
- * is distinguishable from no call at all.
662
- */
663
- let pendingIdentity;
664
- /**
665
- * Initialize Maple browser telemetry. With consent gating enabled the returned
666
- * handle remains live while denied: granting starts capture, revoking detaches
667
- * it without flushing, and a later grant starts cleanly again.
668
- */
669
- function init(rawConfig) {
670
- if (active) return active;
671
- if (typeof window === "undefined") return {
672
- sessionId: "",
673
- shutdown: () => Promise.resolve()
674
- };
675
- const config = resolveConfig(rawConfig);
676
- if (pendingIdentity) config.identity = normalizeIdentity(pendingIdentity.input);
677
- pendingIdentity = void 0;
678
- activeConfig = config;
679
- configurePrivacy(config);
680
- if (!hasConsent()) clearPendingEvents();
681
- setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
682
- const replayEligible = config.replayEnabled && !isLikelyBot(navigator.userAgent);
683
- let runtime;
684
- let stopped = false;
685
- let rotateOnNextStart = false;
686
- let shutdownTracing;
687
- let stopErrorCapture;
688
- let generation = 0;
689
- const startRuntime = () => {
690
- if (stopped || runtime || !hasConsent()) return;
691
- setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
692
- const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
693
- rotateOnNextStart = false;
694
- const recordReplay = replayEligible && claimReplaySample(config.replaySampleRate);
695
- publishSessionSink(session.id);
696
- const sink = startEventSink({
697
- endpoint: config.endpoint,
698
- ingestKey: config.ingestKey,
699
- sdk: SDK_HINT,
700
- maskAllInputs: config.maskAllInputs,
701
- maskAllText: config.maskAllText,
702
- getIdentity: () => activeConfig?.identity
703
- }, session.id);
704
- if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
705
- if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) stopErrorCapture = setupErrorCapture();
706
- const shared = {
707
- endpoint: config.endpoint,
708
- ingestKey: config.ingestKey,
709
- sdk: SDK_HINT,
710
- serviceName: config.serviceName,
711
- environment: config.environment,
712
- serviceVersion: config.serviceVersion,
713
- getIdentity: () => activeConfig?.identity,
714
- captureUserEmail: config.captureUserEmail
715
- };
716
- const startMetadata = () => startMetadataSession({
717
- ...shared,
718
- getTraceIds: getObservedTraceIds,
719
- onSessionChange: publishSessionSink
720
- });
721
- if (!recordReplay) {
722
- runtime = {
723
- initialSessionId: session.id,
724
- sink,
725
- metadata: startMetadata()
726
- };
727
- return;
728
- }
729
- const next = {
730
- initialSessionId: session.id,
731
- sink
732
- };
733
- runtime = next;
734
- const ownGeneration = ++generation;
735
- const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
736
- next.replayPending = import("./replay-session-gSlAYwqV.mjs").then(({ startReplaySession }) => {
737
- if (stale()) return;
738
- next.replay = startReplaySession({
739
- ...shared,
740
- maskAllInputs: config.maskAllInputs,
741
- maskAllText: config.maskAllText
742
- });
743
- }).catch(() => {
744
- if (stale()) return;
745
- next.metadata = startMetadata();
746
- });
747
- };
748
- const stopRuntime = async (flush) => {
749
- generation++;
750
- const previous = runtime;
751
- runtime = void 0;
752
- if (!previous) return;
753
- const replayShutdown = previous.replay?.shutdown({ flush });
754
- const metadataShutdown = previous.metadata?.shutdown({ flush });
755
- const currentSessionId = previous.replay?.sessionId ?? previous.metadata?.sessionId;
756
- const liveSink = getActiveSink();
757
- const sink = liveSink && currentSessionId && liveSink.sessionId === currentSessionId ? liveSink : previous.sink;
758
- if (flush) await sink.flush(true);
759
- sink.stop();
760
- if (sink !== previous.sink) previous.sink.stop();
761
- clearSessionSink(currentSessionId ?? previous.initialSessionId);
762
- await Promise.all([
763
- replayShutdown,
764
- metadataShutdown,
765
- previous.replayPending
766
- ]);
767
- };
768
- startRuntime();
769
- const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => {
770
- if (allowed) {
771
- startRuntime();
772
- return;
773
- }
774
- rotateOnNextStart = runtime !== void 0;
775
- clearPendingEvents();
776
- setVisitorTracking(false);
777
- stopRuntime(false);
778
- }) : () => {};
779
- const handle = {
780
- get sessionId() {
781
- return runtime?.replay?.sessionId ?? runtime?.metadata?.sessionId ?? runtime?.initialSessionId ?? "";
782
- },
783
- shutdown: async () => {
784
- if (stopped) return;
785
- stopped = true;
786
- stopConsentListener();
787
- await stopRuntime(true);
788
- stopErrorCapture?.();
789
- stopErrorCapture = void 0;
790
- await shutdownTracing?.();
791
- shutdownTracing = void 0;
792
- setActiveTraceIdProvider(() => void 0);
793
- active = void 0;
794
- activeConfig = void 0;
795
- }
796
- };
797
- active = handle;
798
- return handle;
799
- }
800
- /**
801
- * Attach, replace, or clear the end-user identity on the active session.
802
- * Idempotent and safe to call on every render. Future browser-created spans
803
- * read the id when they start, and future session metadata rows read the whole
804
- * identity when they post.
805
- *
806
- * Accepts a bare user id or the full object:
807
- *
808
- * ```ts
809
- * MapleBrowser.identify("user_123")
810
- * MapleBrowser.identify({ id: "user_123", email: "a@b.com", groupId: "org_1", groupName: "Acme" })
811
- * ```
812
- *
813
- * Each call replaces the identity rather than merging — merging would leak a
814
- * signed-out user's email into whoever signs in next on a shared device.
815
- *
816
- * Safe before `init()`: the latest call is held and applied when `init()` runs,
817
- * taking precedence over the `user` passed to it.
818
- */
819
- function identify(input) {
820
- if (typeof window === "undefined") return;
821
- if (!activeConfig) {
822
- pendingIdentity = { input };
823
- return;
824
- }
825
- activeConfig.identity = normalizeIdentity(input);
826
- }
827
- //#endregion
828
- //#region src/index.ts
829
- /**
830
- * Maple browser SDK. One call wires up OpenTelemetry tracing and rrweb session
831
- * replay, both tagged with a shared session id.
832
- *
833
- * @example
834
- * ```ts
835
- * import { MapleBrowser } from "@maple-dev/browser"
836
- *
837
- * MapleBrowser.init({
838
- * ingestKey: "maple_pk_...",
839
- * serviceName: "acme-web",
840
- * region: "eu", // omit for the US region
841
- * })
842
- *
843
- * MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
844
- * MapleBrowser.track("checkout_completed", { plan: "pro", seats: 5 })
845
- * ```
846
- */
847
- const MapleBrowser = {
848
- init,
849
- identify,
850
- track,
851
- captureException,
852
- setConsent
853
- };
854
- //#endregion
1
+ import { t as MapleBrowser } from "./src-D4e1o0x4.mjs";
855
2
  export { MapleBrowser };