@maple-dev/browser 0.8.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 { C as setActiveTraceIdProvider, T as setVisitorTracking, a as recordTraceId, b as sdkHint, c as getActiveSink, d as getSession, g as SDK_HINT_HEADER, h as postSessionMetaRow, i as readSessionSink, l as queuePending, m as rotateSession, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as startEventSink, w as configureVisitorCookie } from "./sink-D9w1kg0Q.mjs";
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";
@@ -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. */
@@ -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
  }
@@ -209,18 +210,11 @@ function startMetadataSession(options) {
209
210
  });
210
211
  }
211
212
  //#endregion
212
- //#region ../browser-session/src/events/track.ts
213
- /**
214
- * Caps mirroring what the ingest gateway enforces. Applying them here too means
215
- * an over-sized event is trimmed before it costs bandwidth, and the developer
216
- * sees the same shape locally that the warehouse will store.
217
- */
218
- const MAX_NAME_LENGTH = 128;
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,48 +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
327
343
  //#region src/version.ts
328
- const SDK_VERSION = "0.8.0";
344
+ const SDK_VERSION = "0.9.0";
329
345
  /** The `x-maple-sdk` value this build sends. */
330
346
  const SDK_NAME = "maple-browser";
331
347
  //#endregion
332
348
  //#region src/tracing.ts
349
+ /** Span attributes that carry a page or request URL. */
350
+ const URL_ATTRIBUTES = ["url.full", "http.url"];
333
351
  /**
334
- * Captures every span's trace id into the session sink. Lightweight — runs
335
- * 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.
336
355
  */
337
356
  var TraceIdCollector = class {
338
357
  getUserId;
@@ -340,6 +359,10 @@ var TraceIdCollector = class {
340
359
  this.getUserId = getUserId;
341
360
  }
342
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
+ }
343
366
  if (!hasConsent()) return;
344
367
  recordTraceId(span.spanContext().traceId);
345
368
  const sessionId = readSessionSink()?.sessionId;
@@ -395,6 +418,16 @@ var ConsentSpanExporter = class {
395
418
  */
396
419
  const EXPORT_INTERVAL_MS = 2e3;
397
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
+ /**
398
431
  * Set up browser OTel tracing exporting to Maple's ingest. When
399
432
  * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
400
433
  * their trace ids feed the session. Disable it when an external tracer (e.g.
@@ -416,7 +449,7 @@ function setupTracing(config) {
416
449
  if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
417
450
  if (config.serviceVersion) {
418
451
  attributes[ATTR_SERVICE_VERSION] = config.serviceVersion;
419
- attributes["deployment.commit_sha"] = config.serviceVersion;
452
+ if (/^[0-9a-f]{7,40}$/i.test(config.serviceVersion)) attributes["vcs.ref.head.revision"] = config.serviceVersion;
420
453
  }
421
454
  if (config.environment) {
422
455
  attributes["deployment.environment"] = config.environment;
@@ -434,6 +467,9 @@ function setupTracing(config) {
434
467
  spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
435
468
  });
436
469
  provider.register();
470
+ mapleProvider = provider;
471
+ const globalProvider = trace.getTracerProvider();
472
+ const ownsGlobals = globalProvider === provider || globalProvider instanceof ProxyTracerProvider && globalProvider.getDelegate() === provider;
437
473
  const onExit = () => {
438
474
  provider.forceFlush().catch(() => {});
439
475
  };
@@ -445,26 +481,187 @@ function setupTracing(config) {
445
481
  document.addEventListener("visibilitychange", onVisibilityChange);
446
482
  window.addEventListener("pagehide", onExit);
447
483
  }
448
- const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
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;
449
491
  return async () => {
450
492
  if (canListen) {
451
493
  document.removeEventListener("visibilitychange", onVisibilityChange);
452
494
  window.removeEventListener("pagehide", onExit);
453
495
  }
454
496
  unregisterInstrumentations?.();
497
+ if (mapleProvider === provider) mapleProvider = void 0;
455
498
  await provider.shutdown();
499
+ if (ownsGlobals) {
500
+ trace.disable();
501
+ context.disable();
502
+ propagation.disable();
503
+ }
456
504
  };
457
505
  }
458
506
  function escapeRegExp(value) {
459
507
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
460
508
  }
461
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
462
652
  //#region src/init.ts
463
653
  /** `x-maple-sdk` value for every request this build makes to ingest. */
464
654
  const SDK_HINT = sdkHint(SDK_NAME, SDK_VERSION);
465
655
  let active;
466
656
  let activeConfig;
467
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
+ /**
468
665
  * Initialize Maple browser telemetry. With consent gating enabled the returned
469
666
  * handle remains live while denied: granting starts capture, revoking detaches
470
667
  * it without flushing, and a later grant starts cleanly again.
@@ -476,30 +673,36 @@ function init(rawConfig) {
476
673
  shutdown: () => Promise.resolve()
477
674
  };
478
675
  const config = resolveConfig(rawConfig);
676
+ if (pendingIdentity) config.identity = normalizeIdentity(pendingIdentity.input);
677
+ pendingIdentity = void 0;
479
678
  activeConfig = config;
480
679
  configurePrivacy(config);
481
680
  if (!hasConsent()) clearPendingEvents();
482
681
  setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
483
- const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
682
+ const replayEligible = config.replayEnabled && !isLikelyBot(navigator.userAgent);
484
683
  let runtime;
485
684
  let stopped = false;
486
685
  let rotateOnNextStart = false;
487
686
  let shutdownTracing;
687
+ let stopErrorCapture;
488
688
  let generation = 0;
489
689
  const startRuntime = () => {
490
690
  if (stopped || runtime || !hasConsent()) return;
491
691
  setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
492
692
  const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
493
693
  rotateOnNextStart = false;
694
+ const recordReplay = replayEligible && claimReplaySample(config.replaySampleRate);
494
695
  publishSessionSink(session.id);
495
696
  const sink = startEventSink({
496
697
  endpoint: config.endpoint,
497
698
  ingestKey: config.ingestKey,
498
699
  sdk: SDK_HINT,
499
700
  maskAllInputs: config.maskAllInputs,
500
- maskAllText: config.maskAllText
701
+ maskAllText: config.maskAllText,
702
+ getIdentity: () => activeConfig?.identity
501
703
  }, session.id);
502
704
  if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
705
+ if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) stopErrorCapture = setupErrorCapture();
503
706
  const shared = {
504
707
  endpoint: config.endpoint,
505
708
  ingestKey: config.ingestKey,
@@ -530,7 +733,7 @@ function init(rawConfig) {
530
733
  runtime = next;
531
734
  const ownGeneration = ++generation;
532
735
  const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
533
- next.replayPending = import("./replay-session-BYZfi3iZ.mjs").then(({ startReplaySession }) => {
736
+ next.replayPending = import("./replay-session-gSlAYwqV.mjs").then(({ startReplaySession }) => {
534
737
  if (stale()) return;
535
738
  next.replay = startReplaySession({
536
739
  ...shared,
@@ -582,6 +785,8 @@ function init(rawConfig) {
582
785
  stopped = true;
583
786
  stopConsentListener();
584
787
  await stopRuntime(true);
788
+ stopErrorCapture?.();
789
+ stopErrorCapture = void 0;
585
790
  await shutdownTracing?.();
586
791
  shutdownTracing = void 0;
587
792
  setActiveTraceIdProvider(() => void 0);
@@ -607,9 +812,16 @@ function init(rawConfig) {
607
812
  *
608
813
  * Each call replaces the identity rather than merging — merging would leak a
609
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.
610
818
  */
611
819
  function identify(input) {
612
- if (typeof window === "undefined" || !activeConfig) return;
820
+ if (typeof window === "undefined") return;
821
+ if (!activeConfig) {
822
+ pendingIdentity = { input };
823
+ return;
824
+ }
613
825
  activeConfig.identity = normalizeIdentity(input);
614
826
  }
615
827
  //#endregion
@@ -625,6 +837,7 @@ function identify(input) {
625
837
  * MapleBrowser.init({
626
838
  * ingestKey: "maple_pk_...",
627
839
  * serviceName: "acme-web",
840
+ * region: "eu", // omit for the US region
628
841
  * })
629
842
  *
630
843
  * MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
@@ -635,6 +848,7 @@ const MapleBrowser = {
635
848
  init,
636
849
  identify,
637
850
  track,
851
+ captureException,
638
852
  setConsent
639
853
  };
640
854
  //#endregion
@@ -1,4 +1,4 @@
1
- import { S as activeTraceId, _ as gzip, f as markActivity, n as getObservedTraceIds, o as startSessionLifecycle, p as nextChunkSeq, r as publishSessionSink, u as startEventSink, v as postSessionBlob, x as safeEmit, y as postSessionMeta } from "./sink-D9w1kg0Q.mjs";
1
+ import { C as warnDropped, E as activeTraceId, M as scrubUrl, O as withStartedTraceId, T as safeEmit, b as postSessionBlob, m as nextChunkSeq, n as getObservedTraceIds, o as startSessionLifecycle, p as markActivity, r as publishSessionSink, u as startEventSink, w as BLOCK_SELECTOR, x as postSessionMeta, y as gzip } from "./sink-DHPiMgU0.mjs";
2
2
  import { record } from "rrweb";
3
3
  //#region ../browser-session/src/replay/capture/console.ts
4
4
  const LEVELS = [
@@ -9,6 +9,8 @@ const LEVELS = [
9
9
  "debug"
10
10
  ];
11
11
  const MAX_MESSAGE = 2e3;
12
+ /** No array logged into a 2,000-character message needs more elements than this. */
13
+ const MAX_ARRAY_ELEMENTS = 100;
12
14
  /**
13
15
  * Capture `console.*` calls as session events. Wraps each method, emits a
14
16
  * distilled record, then forwards to the original so the host app's console
@@ -40,13 +42,29 @@ function formatArgs(args) {
40
42
  if (typeof a === "string") return a;
41
43
  if (a instanceof Error) return `${a.name}: ${a.message}`;
42
44
  try {
43
- return JSON.stringify(a);
45
+ return boundedStringify(a, MAX_MESSAGE) ?? String(a);
44
46
  } catch {
45
47
  return String(a);
46
48
  }
47
49
  }).join(" ");
48
50
  return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
49
51
  }
52
+ /**
53
+ * `JSON.stringify` that stops descending once roughly `budget` characters have
54
+ * been produced. The message is cut to `MAX_MESSAGE` anyway, and serializing a
55
+ * whole store object on every `console.log` first was a main-thread cost paid
56
+ * on the host app's hot path. A replacer returning `undefined` skips a value
57
+ * without visiting its children, so the walk is bounded by the budget.
58
+ */
59
+ function boundedStringify(value, budget) {
60
+ let used = 0;
61
+ return JSON.stringify(value, (key, nested) => {
62
+ if (used > budget) return void 0;
63
+ used += key.length + (typeof nested === "string" ? nested.length : 4);
64
+ if (Array.isArray(nested) && nested.length > MAX_ARRAY_ELEMENTS) return nested.slice(0, MAX_ARRAY_ELEMENTS);
65
+ return nested;
66
+ });
67
+ }
50
68
  //#endregion
51
69
  //#region ../browser-session/src/replay/capture/network.ts
52
70
  /**
@@ -59,10 +77,13 @@ function installNetworkCapture(emit, ignoreUrl) {
59
77
  if (origFetch) window.fetch = async (input, init) => {
60
78
  const url = requestUrl(input);
61
79
  const method = requestMethod(input, init);
62
- const traceId = activeTraceId();
80
+ const ambientTraceId = activeTraceId();
63
81
  const start = performance.now();
82
+ let traceId = ambientTraceId;
64
83
  try {
65
- const res = await origFetch(input, init);
84
+ const call = withStartedTraceId(() => origFetch(input, init));
85
+ traceId = call.traceId ?? ambientTraceId;
86
+ const res = await call.result;
66
87
  record(url, method, res.status, start, traceId);
67
88
  return res;
68
89
  } catch (error) {
@@ -100,11 +121,13 @@ function installNetworkCapture(emit, ignoreUrl) {
100
121
  XHR.prototype.send = function(...args) {
101
122
  const meta = this;
102
123
  const start = performance.now();
103
- const traceId = activeTraceId();
124
+ let traceId = activeTraceId();
104
125
  this.addEventListener("loadend", () => {
105
126
  record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
106
127
  });
107
- return origSend.apply(this, args);
128
+ const call = withStartedTraceId(() => origSend.apply(this, args));
129
+ traceId = call.traceId ?? traceId;
130
+ return call.result;
108
131
  };
109
132
  }
110
133
  return () => {
@@ -146,6 +169,7 @@ function startEventCapture(config, sessionId) {
146
169
  //#endregion
147
170
  //#region ../browser-session/src/replay/record.ts
148
171
  const FULL_SNAPSHOT = 2;
172
+ const META = 4;
149
173
  const INCREMENTAL = 3;
150
174
  const SOURCE_MOUSE_INTERACTION = 2;
151
175
  const MOUSE_CLICK = 2;
@@ -188,7 +212,13 @@ function startRecording(config, sessionId) {
188
212
  const durationMs = Math.max(0, lastTimestamp - firstTimestamp);
189
213
  const seq = nextChunkSeq();
190
214
  resetBuffer();
191
- const gzipped = await gzip(new TextEncoder().encode(body));
215
+ let gzipped;
216
+ try {
217
+ gzipped = await gzip(new TextEncoder().encode(body));
218
+ } catch (error) {
219
+ warnDropped("chunk compression", error);
220
+ return;
221
+ }
192
222
  if (await postSessionBlob(config, {
193
223
  sessionId,
194
224
  chunkSeq: seq,
@@ -207,6 +237,7 @@ function startRecording(config, sessionId) {
207
237
  const active = markActivity();
208
238
  if (active && active.id !== sessionId) return;
209
239
  const e = event;
240
+ if (e.type === META && e.data && typeof e.data.href === "string") e.data.href = scrubUrl(e.data.href);
210
241
  const isFullSnapshot = isCheckpoint === true || e.type === FULL_SNAPSHOT;
211
242
  if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
212
243
  let json;
@@ -231,6 +262,7 @@ function startRecording(config, sessionId) {
231
262
  if (bufferBytes >= FLUSH_BYTES) flush();
232
263
  },
233
264
  maskAllInputs: config.maskAllInputs,
265
+ blockSelector: BLOCK_SELECTOR,
234
266
  ...config.maskAllText ? { maskTextSelector: "*" } : void 0,
235
267
  checkoutEveryNms: CHECKOUT_EVERY_MS
236
268
  });
@@ -283,7 +315,8 @@ function startReplaySession(options) {
283
315
  ingestKey: options.ingestKey,
284
316
  sdk: options.sdk,
285
317
  maskAllInputs: options.maskAllInputs,
286
- maskAllText: options.maskAllText
318
+ maskAllText: options.maskAllText,
319
+ getIdentity: options.getIdentity
287
320
  };
288
321
  let recorder;
289
322
  let events;