@maple-dev/browser 0.4.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { S as setVisitorTracking, a as recordTraceId, b as postSessionMetaRow, c as clearPendingEvents, d as setActiveTraceIdProvider, f as startEventSink, g as rotateSession, i as readSessionSink, l as getActiveSink, n as getObservedTraceIds, o as startSessionLifecycle, p as getSession, r as publishSessionSink, t as clearSessionSink, u as queuePending, x as configureVisitorCookie } from "./sink-DHTfvFgl.mjs";
2
- import { trace } from "@opentelemetry/api";
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
3
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
4
4
  import { registerInstrumentations } from "@opentelemetry/instrumentation";
5
5
  import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
@@ -7,7 +7,7 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
7
7
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
8
8
  import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
9
9
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
10
- //#region ../browser-session/src/consent.ts
10
+ //#region ../browser-session/src/identity/consent.ts
11
11
  /**
12
12
  * Consent gating for session capture.
13
13
  *
@@ -27,7 +27,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic
27
27
  */
28
28
  /**
29
29
  * Consent lives on `globalThis`, for the same reason the event sink does
30
- * (`events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
30
+ * (`events/events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
31
31
  * Effect SDK gets **two copies** of this module, and per-copy state would mean
32
32
  * `setConsent(true)` on one never releases the other copy's exporters — or, the
33
33
  * other way round, one copy capturing while the other believes consent is
@@ -76,6 +76,7 @@ function configurePrivacy(options) {
76
76
  crossSubdomainCookie: options?.crossSubdomainCookie,
77
77
  cookieDomain: options?.cookieDomain
78
78
  });
79
+ if (options?.sanitizeUrl) addUrlSanitizer(options.sanitizeUrl);
79
80
  updateEffectiveConsent(previous);
80
81
  }
81
82
  /** Record the user's consent decision. No-op unless `requireConsent` is set. */
@@ -120,7 +121,7 @@ function mayPersistIdentifier() {
120
121
  return true;
121
122
  }
122
123
  //#endregion
123
- //#region ../browser-session/src/identity.ts
124
+ //#region ../browser-session/src/identity/identity.ts
124
125
  /**
125
126
  * Trait caps. The warehouse column is `Map(String, String)` — plain keys
126
127
  * precisely because they are arbitrary — so the cost of a unique key per user
@@ -151,7 +152,7 @@ function normalizeTraits(traits) {
151
152
  if (!key) continue;
152
153
  if (!warnedAboutTraitKeys && looksLikeId(key)) {
153
154
  warnedAboutTraitKeys = true;
154
- console.warn(`[maple] identity trait key "${key}" looks like an id. Trait keys share a ClickHouse dictionary — put ids in the value, or in id/groupId, not the key.`);
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.`);
155
156
  }
156
157
  out[key] = value;
157
158
  }
@@ -189,7 +190,7 @@ function normalizeIdentity(input) {
189
190
  return resolved.id || resolved.email || resolved.username || resolved.groupId || resolved.groupName || Object.keys(resolved.traits).length > 0 ? resolved : void 0;
190
191
  }
191
192
  //#endregion
192
- //#region ../browser-session/src/metadata-session.ts
193
+ //#region ../browser-session/src/session/metadata-session.ts
193
194
  /**
194
195
  * Metadata-only session lifecycle used whenever rrweb is disabled or unsampled.
195
196
  * It is the shared lifecycle driver with no capture of its own: the distilled
@@ -200,7 +201,7 @@ function startMetadataSession(options) {
200
201
  return startSessionLifecycle(options, {
201
202
  recorded: false,
202
203
  post: (row, keepalive) => {
203
- postSessionMetaRow(options.endpoint, options.ingestKey, row, keepalive);
204
+ postSessionMetaRow(options, row, keepalive);
204
205
  },
205
206
  onSuspend: ({ flush, keepalive }) => {
206
207
  if (flush && keepalive) getActiveSink()?.flush(true);
@@ -209,18 +210,11 @@ function startMetadataSession(options) {
209
210
  });
210
211
  }
211
212
  //#endregion
212
- //#region ../browser-session/src/track.ts
213
- /**
214
- * Caps mirroring what the ingest gateway enforces. Applying them here too means
215
- * an over-sized event is trimmed before it costs bandwidth, and the developer
216
- * sees the same shape locally that the warehouse will store.
217
- */
218
- const MAX_NAME_LENGTH = 128;
213
+ //#region ../browser-session/src/events/props.ts
219
214
  const MAX_PROPS = 32;
220
215
  const MAX_PROP_KEY_LENGTH = 64;
221
216
  const MAX_PROP_VALUE_LENGTH = 1024;
222
217
  const MAX_TOTAL_PROP_BYTES = 8192;
223
- let warnedAboutName = false;
224
218
  /**
225
219
  * Coerce one property value to the string the warehouse column holds.
226
220
  *
@@ -244,7 +238,8 @@ function coerce(value) {
244
238
  return;
245
239
  }
246
240
  }
247
- function coerceProps(props) {
241
+ /** Coerce and cap a `track()` property bag to the `Map(String, String)` the warehouse stores. */
242
+ function coerceTrackProps(props) {
248
243
  if (!props) return {};
249
244
  const out = {};
250
245
  let bytes = 0;
@@ -260,6 +255,9 @@ function coerceProps(props) {
260
255
  }
261
256
  return out;
262
257
  }
258
+ //#endregion
259
+ //#region ../browser-session/src/events/track.ts
260
+ let warnedAboutName = false;
263
261
  /**
264
262
  * Record a custom product event against the current session.
265
263
  *
@@ -281,8 +279,8 @@ function track(name, props) {
281
279
  }
282
280
  const ev = {
283
281
  type: "custom",
284
- message: name.trim().slice(0, MAX_NAME_LENGTH),
285
- attrs: coerceProps(props),
282
+ message: name.trim().slice(0, 128),
283
+ attrs: coerceTrackProps(props),
286
284
  timestamp: Date.now(),
287
285
  url: typeof location !== "undefined" ? location.href : void 0
288
286
  };
@@ -291,43 +289,69 @@ function track(name, props) {
291
289
  else queuePending(ev);
292
290
  }
293
291
  //#endregion
294
- //#region src/config.ts
295
- const DEFAULT_ENDPOINT = "https://ingest.maple.dev";
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
+ };
296
298
  /**
297
- * Resolve the identity from either the new `user` object or the legacy
298
- * `userId` string. `user` wins when both are set.
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`.
299
302
  */
300
- function resolveIdentity(config) {
301
- return normalizeIdentity(config.user ?? config.userId);
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);
302
307
  }
303
- function resolveConfig(config) {
304
- return {
305
- ingestKey: config.ingestKey,
306
- serviceName: config.serviceName,
307
- endpoint: (config.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
308
- serviceNamespace: config.serviceNamespace,
309
- serviceVersion: config.serviceVersion,
310
- environment: config.environment,
311
- identity: resolveIdentity(config),
312
- tracingEnabled: config.tracing?.enabled ?? true,
313
- tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
314
- replayEnabled: config.replay?.enabled ?? true,
315
- replaySampleRate: config.replay?.sampleRate ?? 1,
316
- maskAllInputs: config.privacy?.maskAllInputs ?? true,
317
- maskAllText: config.privacy?.maskAllText ?? false,
318
- persistVisitorId: config.privacy?.persistVisitorId ?? true,
319
- crossSubdomainCookie: config.privacy?.crossSubdomainCookie ?? true,
320
- cookieDomain: config.privacy?.cookieDomain,
321
- requireConsent: config.privacy?.requireConsent ?? false,
322
- captureUserEmail: config.privacy?.captureUserEmail ?? true,
323
- respectDoNotTrack: config.privacy?.respectDoNotTrack ?? false
324
- };
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 !== ""));
325
341
  }
326
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
327
348
  //#region src/tracing.ts
349
+ /** Span attributes that carry a page or request URL. */
350
+ const URL_ATTRIBUTES = ["url.full", "http.url"];
328
351
  /**
329
- * Captures every span's trace id into the session sink. Lightweight — runs
330
- * alongside the BatchSpanProcessor, does no export of its own.
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.
331
355
  */
332
356
  var TraceIdCollector = class {
333
357
  getUserId;
@@ -335,6 +359,10 @@ var TraceIdCollector = class {
335
359
  this.getUserId = getUserId;
336
360
  }
337
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
+ }
338
366
  if (!hasConsent()) return;
339
367
  recordTraceId(span.spanContext().traceId);
340
368
  const sessionId = readSessionSink()?.sessionId;
@@ -381,6 +409,25 @@ var ConsentSpanExporter = class {
381
409
  }
382
410
  };
383
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
+ /**
384
431
  * Set up browser OTel tracing exporting to Maple's ingest. When
385
432
  * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
386
433
  * their trace ids feed the session. Disable it when an external tracer (e.g.
@@ -402,7 +449,7 @@ function setupTracing(config) {
402
449
  if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
403
450
  if (config.serviceVersion) {
404
451
  attributes[ATTR_SERVICE_VERSION] = config.serviceVersion;
405
- attributes["deployment.commit_sha"] = config.serviceVersion;
452
+ if (/^[0-9a-f]{7,40}$/i.test(config.serviceVersion)) attributes["vcs.ref.head.revision"] = config.serviceVersion;
406
453
  }
407
454
  if (config.environment) {
408
455
  attributes["deployment.environment"] = config.environment;
@@ -410,27 +457,211 @@ function setupTracing(config) {
410
457
  }
411
458
  const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
412
459
  url: `${config.endpoint}/v1/traces`,
413
- headers: { Authorization: `Bearer ${config.ingestKey}` }
460
+ headers: {
461
+ Authorization: `Bearer ${config.ingestKey}`,
462
+ [SDK_HINT_HEADER]: sdkHint(SDK_NAME, SDK_VERSION)
463
+ }
414
464
  }));
415
465
  const provider = new WebTracerProvider({
416
466
  resource: resourceFromAttributes(attributes),
417
- spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter)]
467
+ spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
418
468
  });
419
469
  provider.register();
420
- const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
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;
421
491
  return async () => {
492
+ if (canListen) {
493
+ document.removeEventListener("visibilitychange", onVisibilityChange);
494
+ window.removeEventListener("pagehide", onExit);
495
+ }
422
496
  unregisterInstrumentations?.();
497
+ if (mapleProvider === provider) mapleProvider = void 0;
423
498
  await provider.shutdown();
499
+ if (ownsGlobals) {
500
+ trace.disable();
501
+ context.disable();
502
+ propagation.disable();
503
+ }
424
504
  };
425
505
  }
426
506
  function escapeRegExp(value) {
427
507
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
428
508
  }
429
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
430
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);
431
655
  let active;
432
656
  let activeConfig;
433
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
+ /**
434
665
  * Initialize Maple browser telemetry. With consent gating enabled the returned
435
666
  * handle remains live while denied: granting starts capture, revoking detaches
436
667
  * it without flushing, and a later grant starts cleanly again.
@@ -442,32 +673,40 @@ function init(rawConfig) {
442
673
  shutdown: () => Promise.resolve()
443
674
  };
444
675
  const config = resolveConfig(rawConfig);
676
+ if (pendingIdentity) config.identity = normalizeIdentity(pendingIdentity.input);
677
+ pendingIdentity = void 0;
445
678
  activeConfig = config;
446
679
  configurePrivacy(config);
447
680
  if (!hasConsent()) clearPendingEvents();
448
681
  setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
449
- const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
682
+ const replayEligible = config.replayEnabled && !isLikelyBot(navigator.userAgent);
450
683
  let runtime;
451
684
  let stopped = false;
452
685
  let rotateOnNextStart = false;
453
686
  let shutdownTracing;
687
+ let stopErrorCapture;
454
688
  let generation = 0;
455
689
  const startRuntime = () => {
456
690
  if (stopped || runtime || !hasConsent()) return;
457
691
  setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
458
692
  const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
459
693
  rotateOnNextStart = false;
694
+ const recordReplay = replayEligible && claimReplaySample(config.replaySampleRate);
460
695
  publishSessionSink(session.id);
461
696
  const sink = startEventSink({
462
697
  endpoint: config.endpoint,
463
698
  ingestKey: config.ingestKey,
699
+ sdk: SDK_HINT,
464
700
  maskAllInputs: config.maskAllInputs,
465
- maskAllText: config.maskAllText
701
+ maskAllText: config.maskAllText,
702
+ getIdentity: () => activeConfig?.identity
466
703
  }, session.id);
467
704
  if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
705
+ if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) stopErrorCapture = setupErrorCapture();
468
706
  const shared = {
469
707
  endpoint: config.endpoint,
470
708
  ingestKey: config.ingestKey,
709
+ sdk: SDK_HINT,
471
710
  serviceName: config.serviceName,
472
711
  environment: config.environment,
473
712
  serviceVersion: config.serviceVersion,
@@ -494,7 +733,7 @@ function init(rawConfig) {
494
733
  runtime = next;
495
734
  const ownGeneration = ++generation;
496
735
  const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
497
- next.replayPending = import("./replay-session-B_ACRogX.mjs").then(({ startReplaySession }) => {
736
+ next.replayPending = import("./replay-session-gSlAYwqV.mjs").then(({ startReplaySession }) => {
498
737
  if (stale()) return;
499
738
  next.replay = startReplaySession({
500
739
  ...shared,
@@ -546,6 +785,8 @@ function init(rawConfig) {
546
785
  stopped = true;
547
786
  stopConsentListener();
548
787
  await stopRuntime(true);
788
+ stopErrorCapture?.();
789
+ stopErrorCapture = void 0;
549
790
  await shutdownTracing?.();
550
791
  shutdownTracing = void 0;
551
792
  setActiveTraceIdProvider(() => void 0);
@@ -571,9 +812,16 @@ function init(rawConfig) {
571
812
  *
572
813
  * Each call replaces the identity rather than merging — merging would leak a
573
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.
574
818
  */
575
819
  function identify(input) {
576
- if (typeof window === "undefined" || !activeConfig) return;
820
+ if (typeof window === "undefined") return;
821
+ if (!activeConfig) {
822
+ pendingIdentity = { input };
823
+ return;
824
+ }
577
825
  activeConfig.identity = normalizeIdentity(input);
578
826
  }
579
827
  //#endregion
@@ -589,6 +837,7 @@ function identify(input) {
589
837
  * MapleBrowser.init({
590
838
  * ingestKey: "maple_pk_...",
591
839
  * serviceName: "acme-web",
840
+ * region: "eu", // omit for the US region
592
841
  * })
593
842
  *
594
843
  * MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
@@ -599,6 +848,7 @@ const MapleBrowser = {
599
848
  init,
600
849
  identify,
601
850
  track,
851
+ captureException,
602
852
  setConsent
603
853
  };
604
854
  //#endregion