@cross-deck/web 0.7.0 → 1.0.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/react.cjs CHANGED
@@ -35,11 +35,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
35
35
  this.code = payload.code;
36
36
  this.requestId = payload.requestId;
37
37
  this.status = payload.status;
38
+ this.retryAfterMs = payload.retryAfterMs;
38
39
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
39
40
  }
40
41
  };
41
42
  async function crossdeckErrorFromResponse(res) {
42
43
  const requestId = res.headers.get("x-request-id") ?? void 0;
44
+ const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
43
45
  let body;
44
46
  try {
45
47
  body = await res.json();
@@ -53,7 +55,8 @@ async function crossdeckErrorFromResponse(res) {
53
55
  code: envelope.code,
54
56
  message: envelope.message ?? `HTTP ${res.status}`,
55
57
  requestId: envelope.request_id ?? requestId,
56
- status: res.status
58
+ status: res.status,
59
+ retryAfterMs
57
60
  });
58
61
  }
59
62
  return new CrossdeckError({
@@ -61,9 +64,25 @@ async function crossdeckErrorFromResponse(res) {
61
64
  code: `http_${res.status}`,
62
65
  message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
63
66
  requestId,
64
- status: res.status
67
+ status: res.status,
68
+ retryAfterMs
65
69
  });
66
70
  }
71
+ function parseRetryAfterHeader(value) {
72
+ if (!value) return void 0;
73
+ const trimmed = value.trim();
74
+ if (!trimmed) return void 0;
75
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
76
+ const secs = Number(trimmed);
77
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
78
+ return Math.round(secs * 1e3);
79
+ }
80
+ if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
81
+ const target = Date.parse(trimmed);
82
+ if (!Number.isFinite(target)) return void 0;
83
+ const delta = target - Date.now();
84
+ return delta > 0 ? delta : 0;
85
+ }
67
86
  function typeMapForStatus(status) {
68
87
  if (status === 401) return "authentication_error";
69
88
  if (status === 403) return "permission_error";
@@ -74,8 +93,9 @@ function typeMapForStatus(status) {
74
93
 
75
94
  // src/http.ts
76
95
  var SDK_NAME = "@cross-deck/web";
77
- var SDK_VERSION = "0.6.0";
96
+ var SDK_VERSION = "1.0.0";
78
97
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
98
+ var DEFAULT_TIMEOUT_MS = 15e3;
79
99
  var HttpClient = class {
80
100
  constructor(config) {
81
101
  this.config = config;
@@ -99,25 +119,38 @@ var HttpClient = class {
99
119
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
100
120
  Accept: "application/json"
101
121
  };
122
+ if (options.idempotencyKey) {
123
+ headers["Idempotency-Key"] = options.idempotencyKey;
124
+ }
102
125
  let bodyInit;
103
126
  if (options.body !== void 0) {
104
127
  headers["Content-Type"] = "application/json";
105
128
  bodyInit = JSON.stringify(options.body);
106
129
  }
130
+ const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
131
+ const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
132
+ let timeoutHandle = null;
133
+ if (controller && effectiveTimeout > 0) {
134
+ timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
135
+ }
107
136
  let response;
108
137
  try {
109
138
  response = await fetch(url, {
110
139
  method,
111
140
  headers,
112
141
  body: bodyInit,
113
- keepalive: options.keepalive === true
142
+ keepalive: options.keepalive === true,
143
+ signal: controller?.signal
114
144
  });
115
145
  } catch (err) {
146
+ const aborted = controller?.signal?.aborted === true;
116
147
  throw new CrossdeckError({
117
148
  type: "network_error",
118
- code: "fetch_failed",
119
- message: err instanceof Error ? err.message : "fetch failed"
149
+ code: aborted ? "request_timeout" : "fetch_failed",
150
+ message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
120
151
  });
152
+ } finally {
153
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
121
154
  }
122
155
  if (!response.ok) {
123
156
  throw await crossdeckErrorFromResponse(response);
@@ -314,6 +347,7 @@ var EntitlementCache = class {
314
347
  this.all = [];
315
348
  this.lastUpdated = 0;
316
349
  this.listeners = /* @__PURE__ */ new Set();
350
+ this.listenerErrorCount = 0;
317
351
  }
318
352
  /** Sync read — true iff the entitlement key is currently active. */
319
353
  isEntitled(key) {
@@ -327,6 +361,15 @@ var EntitlementCache = class {
327
361
  get freshness() {
328
362
  return this.lastUpdated;
329
363
  }
364
+ /**
365
+ * Cumulative count of listener invocations that threw. Listener errors
366
+ * are swallowed (a buggy consumer must not crash the SDK) but the
367
+ * counter lets diagnostics() surface "you have a broken subscriber"
368
+ * without putting the developer in a debug session.
369
+ */
370
+ get listenerErrors() {
371
+ return this.listenerErrorCount;
372
+ }
330
373
  /**
331
374
  * Replace the cache with a fresh server response. The backend already
332
375
  * filters to active + env-matching, so we don't re-filter — just trust
@@ -380,11 +423,54 @@ var EntitlementCache = class {
380
423
  try {
381
424
  listener(snapshot);
382
425
  } catch {
426
+ this.listenerErrorCount += 1;
383
427
  }
384
428
  }
385
429
  }
386
430
  };
387
431
 
432
+ // src/retry-policy.ts
433
+ var DEFAULT_BASE = 1e3;
434
+ var DEFAULT_MAX = 6e4;
435
+ var DEFAULT_FACTOR = 2;
436
+ var DEFAULT_WARN = 8;
437
+ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
438
+ const base = options.baseMs ?? DEFAULT_BASE;
439
+ const max = options.maxMs ?? DEFAULT_MAX;
440
+ const factor = options.factor ?? DEFAULT_FACTOR;
441
+ const safeAttempts = Math.min(attempts, 30);
442
+ const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
443
+ const jittered = ceiling * random();
444
+ if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
445
+ return Math.min(max, retryAfterMs);
446
+ }
447
+ return Math.max(0, Math.round(jittered));
448
+ }
449
+ var RetryPolicy = class {
450
+ constructor(options = {}) {
451
+ this.options = options;
452
+ this.attempts = 0;
453
+ }
454
+ /** How many consecutive failures since the last success. */
455
+ get consecutiveFailures() {
456
+ return this.attempts;
457
+ }
458
+ /** Whether we've crossed the failuresBeforeWarn threshold. */
459
+ get isWarning() {
460
+ return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
461
+ }
462
+ /** Schedule-time delay for the NEXT retry. Increments the counter. */
463
+ nextDelay(retryAfterMs, random = Math.random) {
464
+ const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
465
+ this.attempts += 1;
466
+ return delay;
467
+ }
468
+ /** Mark a successful flush — reset the counter. */
469
+ recordSuccess() {
470
+ this.attempts = 0;
471
+ }
472
+ };
473
+
388
474
  // src/event-queue.ts
389
475
  var HARD_BUFFER_CAP = 1e3;
390
476
  var EventQueue = class {
@@ -397,6 +483,22 @@ var EventQueue = class {
397
483
  this.lastError = null;
398
484
  this.cancelTimer = null;
399
485
  this.firstFlushFired = false;
486
+ this.nextRetryAt = null;
487
+ this.retry = new RetryPolicy(cfg.retry ?? {});
488
+ this.persistent = cfg.persistentStore ?? null;
489
+ if (this.persistent) {
490
+ const restored = this.persistent.load();
491
+ if (restored.length > 0) {
492
+ if (restored.length > HARD_BUFFER_CAP) {
493
+ this.dropped += restored.length - HARD_BUFFER_CAP;
494
+ this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
495
+ } else {
496
+ this.buffer = restored;
497
+ }
498
+ this.cfg.onBufferChange?.(this.buffer.length);
499
+ this.scheduleIdleFlush();
500
+ }
501
+ }
400
502
  }
401
503
  enqueue(event) {
402
504
  this.buffer.push(event);
@@ -406,6 +508,8 @@ var EventQueue = class {
406
508
  this.dropped += overflow;
407
509
  this.cfg.onDrop?.(overflow);
408
510
  }
511
+ this.cfg.onBufferChange?.(this.buffer.length);
512
+ this.persistent?.save(this.buffer);
409
513
  if (this.buffer.length >= this.cfg.batchSize) {
410
514
  void this.flush();
411
515
  } else {
@@ -415,7 +519,7 @@ var EventQueue = class {
415
519
  /**
416
520
  * Flush the buffer to /v1/events. Resolves when the network call
417
521
  * completes (success or failure). On failure, events stay in the
418
- * buffer for the next flush attempt.
522
+ * buffer for the next scheduled retry.
419
523
  *
420
524
  * `options.keepalive` marks the underlying fetch as keepalive so the
421
525
  * browser keeps the request alive past page unload. Use this for
@@ -424,25 +528,32 @@ var EventQueue = class {
424
528
  async flush(options = {}) {
425
529
  if (this.buffer.length === 0) return null;
426
530
  this.cancelTimerIfSet();
531
+ this.nextRetryAt = null;
427
532
  const batch = this.buffer.splice(0);
533
+ const batchId = this.mintBatchId();
428
534
  this.inFlight += batch.length;
535
+ this.persistent?.save(this.buffer);
536
+ this.cfg.onBufferChange?.(this.buffer.length);
429
537
  try {
430
538
  const env = this.cfg.envelope();
431
539
  const result = await this.cfg.http.request("POST", "/events", {
432
540
  body: {
433
541
  // NorthStar §13.1 batch envelope. The backend validates these
434
- // against the API-key-resolved app and rejects mismatches loudly
435
- // (env_mismatch).
542
+ // against the API-key-resolved app and rejects mismatches
543
+ // loudly (env_mismatch).
436
544
  appId: env.appId,
437
545
  environment: env.environment,
438
546
  sdk: env.sdk,
439
547
  events: batch
440
548
  },
441
- keepalive: options.keepalive === true
549
+ keepalive: options.keepalive === true,
550
+ idempotencyKey: batchId
442
551
  });
443
552
  this.lastFlushAt = Date.now();
444
553
  this.lastError = null;
445
554
  this.inFlight -= batch.length;
555
+ this.retry.recordSuccess();
556
+ this.persistent?.save(this.buffer);
446
557
  if (!this.firstFlushFired) {
447
558
  this.firstFlushFired = true;
448
559
  this.cfg.onFirstFlushSuccess?.();
@@ -451,18 +562,33 @@ var EventQueue = class {
451
562
  } catch (err) {
452
563
  this.buffer.unshift(...batch);
453
564
  this.inFlight -= batch.length;
454
- this.lastError = err instanceof Error ? err.message : String(err);
455
- this.scheduleIdleFlush();
565
+ const message = err instanceof Error ? err.message : String(err);
566
+ this.lastError = message;
567
+ this.persistent?.save(this.buffer);
568
+ this.cfg.onBufferChange?.(this.buffer.length);
569
+ const retryAfterMs = extractRetryAfterMs(err);
570
+ const delay = this.retry.nextDelay(retryAfterMs);
571
+ this.scheduleRetry(delay);
572
+ this.cfg.onRetryScheduled?.({
573
+ delayMs: delay,
574
+ consecutiveFailures: this.retry.consecutiveFailures,
575
+ retryAfterMs,
576
+ lastError: message
577
+ });
456
578
  return null;
457
579
  }
458
580
  }
459
- /** Cancel any pending timer and clear in-memory state. */
581
+ /** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
460
582
  reset() {
461
583
  this.cancelTimerIfSet();
584
+ this.nextRetryAt = null;
462
585
  this.buffer = [];
463
586
  this.dropped = 0;
464
587
  this.inFlight = 0;
465
588
  this.lastError = null;
589
+ this.retry.recordSuccess();
590
+ this.persistent?.clear();
591
+ this.cfg.onBufferChange?.(0);
466
592
  }
467
593
  getStats() {
468
594
  return {
@@ -470,9 +596,12 @@ var EventQueue = class {
470
596
  dropped: this.dropped,
471
597
  inFlight: this.inFlight,
472
598
  lastFlushAt: this.lastFlushAt,
473
- lastError: this.lastError
599
+ lastError: this.lastError,
600
+ consecutiveFailures: this.retry.consecutiveFailures,
601
+ nextRetryAt: this.nextRetryAt
474
602
  };
475
603
  }
604
+ // ---------- internal scheduling ----------
476
605
  scheduleIdleFlush() {
477
606
  this.cancelTimerIfSet();
478
607
  const sched = this.cfg.scheduler ?? defaultScheduler;
@@ -480,13 +609,31 @@ var EventQueue = class {
480
609
  void this.flush();
481
610
  }, this.cfg.intervalMs);
482
611
  }
612
+ scheduleRetry(delayMs) {
613
+ this.cancelTimerIfSet();
614
+ this.nextRetryAt = Date.now() + delayMs;
615
+ const sched = this.cfg.scheduler ?? defaultScheduler;
616
+ this.cancelTimer = sched(() => {
617
+ void this.flush();
618
+ }, delayMs);
619
+ }
483
620
  cancelTimerIfSet() {
484
621
  if (this.cancelTimer) {
485
622
  this.cancelTimer();
486
623
  this.cancelTimer = null;
487
624
  }
488
625
  }
626
+ mintBatchId() {
627
+ return `batch_${Date.now().toString(36)}${randomChars(10)}`;
628
+ }
489
629
  };
630
+ function extractRetryAfterMs(err) {
631
+ if (err && typeof err === "object" && "retryAfterMs" in err) {
632
+ const v = err.retryAfterMs;
633
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
634
+ }
635
+ return void 0;
636
+ }
490
637
  function defaultScheduler(fn, ms) {
491
638
  const id = setTimeout(fn, ms);
492
639
  if (typeof id.unref === "function") {
@@ -498,6 +645,87 @@ function defaultScheduler(fn, ms) {
498
645
  return () => clearTimeout(id);
499
646
  }
500
647
 
648
+ // src/event-storage.ts
649
+ var PersistentEventStore = class {
650
+ constructor(options) {
651
+ this.options = options;
652
+ this.writeScheduled = false;
653
+ // Pending events captured on the most recent write request. We keep
654
+ // the latest snapshot ref so a debounced write always picks up the
655
+ // freshest buffer state.
656
+ this.pendingSnapshot = null;
657
+ this.key = `${options.prefix}queue.v1`;
658
+ }
659
+ /**
660
+ * Read the persisted queue on boot. Returns an empty array (with no
661
+ * warning) when nothing is stored, the blob is malformed, or storage
662
+ * is unavailable. Caller is responsible for treating duplicates from
663
+ * the persisted queue as the SAME events (eventId-based dedup).
664
+ */
665
+ load() {
666
+ let raw;
667
+ try {
668
+ raw = this.options.storage.getItem(this.key);
669
+ } catch {
670
+ return [];
671
+ }
672
+ if (!raw) return [];
673
+ try {
674
+ const parsed = JSON.parse(raw);
675
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
676
+ return [];
677
+ }
678
+ return parsed.events;
679
+ } catch {
680
+ return [];
681
+ }
682
+ }
683
+ /**
684
+ * Schedule a write of the current buffer. Debounced via microtask so
685
+ * a burst of enqueue() calls coalesces into one persistence write.
686
+ * Writes are best-effort: if storage throws (quota, private mode),
687
+ * we swallow and rely on the in-memory buffer.
688
+ */
689
+ save(snapshot) {
690
+ this.pendingSnapshot = snapshot.slice();
691
+ if (this.writeScheduled) return;
692
+ this.writeScheduled = true;
693
+ queueMicrotask(() => this.flushWrite());
694
+ }
695
+ /** Synchronous variant for terminal flushes (pagehide / beforeunload). */
696
+ saveSync(snapshot) {
697
+ this.pendingSnapshot = snapshot.slice();
698
+ this.flushWrite();
699
+ }
700
+ /** Wipe the persisted blob. Used by reset() (logout). */
701
+ clear() {
702
+ this.pendingSnapshot = null;
703
+ this.writeScheduled = false;
704
+ try {
705
+ this.options.storage.removeItem(this.key);
706
+ } catch {
707
+ }
708
+ }
709
+ flushWrite() {
710
+ this.writeScheduled = false;
711
+ const snapshot = this.pendingSnapshot;
712
+ this.pendingSnapshot = null;
713
+ if (snapshot === null) return;
714
+ if (snapshot.length === 0) {
715
+ try {
716
+ this.options.storage.removeItem(this.key);
717
+ } catch {
718
+ }
719
+ return;
720
+ }
721
+ const blob = { version: 1, events: snapshot };
722
+ try {
723
+ this.options.storage.setItem(this.key, JSON.stringify(blob));
724
+ } catch {
725
+ }
726
+ }
727
+ };
728
+
501
729
  // src/storage.ts
502
730
  var MemoryStorage = class {
503
731
  constructor() {
@@ -688,7 +916,9 @@ var DEFAULT_AUTO_TRACK = {
688
916
  sessions: true,
689
917
  pageViews: true,
690
918
  deviceInfo: true,
691
- clicks: true
919
+ clicks: true,
920
+ webVitals: true,
921
+ errors: true
692
922
  };
693
923
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
694
924
  var EMPTY_ACQUISITION = {
@@ -697,7 +927,13 @@ var EMPTY_ACQUISITION = {
697
927
  utm_campaign: "",
698
928
  utm_content: "",
699
929
  utm_term: "",
700
- referrer: ""
930
+ referrer: "",
931
+ gclid: "",
932
+ fbclid: "",
933
+ msclkid: "",
934
+ ttclid: "",
935
+ li_fat_id: "",
936
+ twclid: ""
701
937
  };
702
938
  var AutoTracker = class {
703
939
  constructor(cfg, track) {
@@ -705,6 +941,17 @@ var AutoTracker = class {
705
941
  this.track = track;
706
942
  this.session = null;
707
943
  this.cleanups = [];
944
+ /**
945
+ * Stable per-page-view identifier. Minted at every `page.viewed`
946
+ * emission and attached to every subsequent event until the next
947
+ * `page.viewed`. Lets dashboards correlate "user clicked X" to
948
+ * "user viewed page Y" without timestamp arithmetic — the canonical
949
+ * Mixpanel `$current_url` / Segment `pageId` pattern.
950
+ *
951
+ * Null until the first `page.viewed` fires (which happens at SDK
952
+ * install if `autoTrack.pageViews !== false`).
953
+ */
954
+ this.pageviewId = null;
708
955
  }
709
956
  install() {
710
957
  if (!isBrowserSafe()) return;
@@ -735,6 +982,10 @@ var AutoTracker = class {
735
982
  get currentSessionId() {
736
983
  return this.session?.sessionId ?? null;
737
984
  }
985
+ /** Stable per-page-view ID. Null before the first page.viewed has fired. */
986
+ get currentPageviewId() {
987
+ return this.pageviewId;
988
+ }
738
989
  /**
739
990
  * Per-session acquisition context — utm_* + referrer, captured once
740
991
  * at session start. Returns empty strings when there's no session
@@ -815,7 +1066,9 @@ var AutoTracker = class {
815
1066
  if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
816
1067
  lastFiredAt = now;
817
1068
  lastFiredUrl = url;
1069
+ this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
818
1070
  this.track("page.viewed", {
1071
+ pageviewId: this.pageviewId,
819
1072
  path: loc.pathname,
820
1073
  url,
821
1074
  search: loc.search || void 0,
@@ -1019,6 +1272,12 @@ function captureAcquisition() {
1019
1272
  result.utm_campaign = params.get("utm_campaign") ?? "";
1020
1273
  result.utm_content = params.get("utm_content") ?? "";
1021
1274
  result.utm_term = params.get("utm_term") ?? "";
1275
+ result.gclid = params.get("gclid") ?? "";
1276
+ result.fbclid = params.get("fbclid") ?? "";
1277
+ result.msclkid = params.get("msclkid") ?? "";
1278
+ result.ttclid = params.get("ttclid") ?? "";
1279
+ result.li_fat_id = params.get("li_fat_id") ?? "";
1280
+ result.twclid = params.get("twclid") ?? "";
1022
1281
  } catch {
1023
1282
  }
1024
1283
  try {
@@ -1076,6 +1335,1025 @@ function safeJson(obj) {
1076
1335
  }
1077
1336
  }
1078
1337
 
1338
+ // src/event-validation.ts
1339
+ var DEFAULT_MAX_STRING = 1024;
1340
+ var DEFAULT_MAX_BYTES = 8 * 1024;
1341
+ var DEFAULT_MAX_DEPTH = 5;
1342
+ function validateEventProperties(input, options = {}) {
1343
+ const warnings = [];
1344
+ if (!input) return { properties: {}, warnings };
1345
+ const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1346
+ const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1347
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1348
+ const seen = /* @__PURE__ */ new WeakSet();
1349
+ const visit = (value, key, depth) => {
1350
+ if (depth > maxDepth) {
1351
+ warnings.push({ kind: "depth_exceeded", key });
1352
+ return { keep: true, value: "[depth-exceeded]" };
1353
+ }
1354
+ if (value === null) return { keep: true, value: null };
1355
+ const t = typeof value;
1356
+ if (t === "string") {
1357
+ const s = value;
1358
+ if (s.length > maxStringLength) {
1359
+ warnings.push({ kind: "truncated_string", key });
1360
+ return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
1361
+ }
1362
+ return { keep: true, value: s };
1363
+ }
1364
+ if (t === "number") {
1365
+ if (!Number.isFinite(value)) {
1366
+ warnings.push({ kind: "non_serialisable", key });
1367
+ return { keep: true, value: null };
1368
+ }
1369
+ return { keep: true, value };
1370
+ }
1371
+ if (t === "boolean") return { keep: true, value };
1372
+ if (t === "bigint") {
1373
+ warnings.push({ kind: "coerced_bigint", key });
1374
+ return { keep: true, value: value.toString() };
1375
+ }
1376
+ if (t === "function") {
1377
+ warnings.push({ kind: "dropped_function", key });
1378
+ return { keep: false, value: void 0 };
1379
+ }
1380
+ if (t === "symbol") {
1381
+ warnings.push({ kind: "dropped_symbol", key });
1382
+ return { keep: false, value: void 0 };
1383
+ }
1384
+ if (t === "undefined") {
1385
+ warnings.push({ kind: "dropped_undefined", key });
1386
+ return { keep: false, value: void 0 };
1387
+ }
1388
+ if (value instanceof Date) {
1389
+ warnings.push({ kind: "coerced_date", key });
1390
+ const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
1391
+ return { keep: true, value: iso };
1392
+ }
1393
+ if (value instanceof Error) {
1394
+ warnings.push({ kind: "coerced_error", key });
1395
+ return {
1396
+ keep: true,
1397
+ value: {
1398
+ name: value.name,
1399
+ message: value.message,
1400
+ stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
1401
+ }
1402
+ };
1403
+ }
1404
+ if (value instanceof Map) {
1405
+ warnings.push({ kind: "coerced_map", key });
1406
+ const obj = {};
1407
+ for (const [k, v] of value.entries()) {
1408
+ const subKey = typeof k === "string" ? k : String(k);
1409
+ const result = visit(v, `${key}.${subKey}`, depth + 1);
1410
+ if (result.keep) obj[subKey] = result.value;
1411
+ }
1412
+ return { keep: true, value: obj };
1413
+ }
1414
+ if (value instanceof Set) {
1415
+ warnings.push({ kind: "coerced_set", key });
1416
+ const arr = [];
1417
+ let i = 0;
1418
+ for (const v of value.values()) {
1419
+ const result = visit(v, `${key}[${i}]`, depth + 1);
1420
+ if (result.keep) arr.push(result.value);
1421
+ i++;
1422
+ }
1423
+ return { keep: true, value: arr };
1424
+ }
1425
+ if (Array.isArray(value)) {
1426
+ if (seen.has(value)) {
1427
+ warnings.push({ kind: "circular_reference", key });
1428
+ return { keep: true, value: "[circular]" };
1429
+ }
1430
+ seen.add(value);
1431
+ const out = [];
1432
+ for (let i = 0; i < value.length; i++) {
1433
+ const result = visit(value[i], `${key}[${i}]`, depth + 1);
1434
+ if (result.keep) out.push(result.value);
1435
+ }
1436
+ return { keep: true, value: out };
1437
+ }
1438
+ if (t === "object") {
1439
+ const obj = value;
1440
+ if (seen.has(obj)) {
1441
+ warnings.push({ kind: "circular_reference", key });
1442
+ return { keep: true, value: "[circular]" };
1443
+ }
1444
+ seen.add(obj);
1445
+ const out = {};
1446
+ for (const k of Object.keys(obj)) {
1447
+ const result = visit(obj[k], `${key}.${k}`, depth + 1);
1448
+ if (result.keep) out[k] = result.value;
1449
+ }
1450
+ return { keep: true, value: out };
1451
+ }
1452
+ warnings.push({ kind: "non_serialisable", key });
1453
+ try {
1454
+ return { keep: true, value: String(value) };
1455
+ } catch {
1456
+ return { keep: false, value: void 0 };
1457
+ }
1458
+ };
1459
+ const cleaned = {};
1460
+ for (const k of Object.keys(input)) {
1461
+ const result = visit(input[k], k, 0);
1462
+ if (result.keep) cleaned[k] = result.value;
1463
+ }
1464
+ const serialised = safeStringify(cleaned);
1465
+ if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
1466
+ warnings.push({ kind: "size_cap_exceeded", key: "*" });
1467
+ const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
1468
+ let currentSize = byteLength(serialised);
1469
+ for (const { k } of sizes) {
1470
+ if (currentSize <= maxBatchPropertyBytes) break;
1471
+ currentSize -= sizes.find((s) => s.k === k).size;
1472
+ delete cleaned[k];
1473
+ }
1474
+ cleaned.__truncated = true;
1475
+ }
1476
+ return { properties: cleaned, warnings };
1477
+ }
1478
+ function safeStringify(v) {
1479
+ try {
1480
+ return JSON.stringify(v) ?? null;
1481
+ } catch {
1482
+ return null;
1483
+ }
1484
+ }
1485
+ function byteLength(s) {
1486
+ if (typeof TextEncoder !== "undefined") {
1487
+ return new TextEncoder().encode(s).length;
1488
+ }
1489
+ return s.length * 4;
1490
+ }
1491
+
1492
+ // src/super-properties.ts
1493
+ var KEY_SUPER = "super_props";
1494
+ var KEY_GROUPS = "groups";
1495
+ var SuperPropertyStore = class {
1496
+ constructor(storage, prefix) {
1497
+ this.storage = storage;
1498
+ this.prefix = prefix;
1499
+ this.superProps = {};
1500
+ this.groups = {};
1501
+ this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
1502
+ this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
1503
+ }
1504
+ // ---------- super properties ----------
1505
+ /**
1506
+ * Merge new keys into the super-property bag. Returns a snapshot of
1507
+ * the resulting bag. Values that are `null` are deleted (Mixpanel
1508
+ * semantics — explicit null = "stop tracking this key").
1509
+ */
1510
+ register(props) {
1511
+ for (const [k, v] of Object.entries(props)) {
1512
+ if (v === null) {
1513
+ delete this.superProps[k];
1514
+ } else if (v !== void 0) {
1515
+ this.superProps[k] = v;
1516
+ }
1517
+ }
1518
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1519
+ return { ...this.superProps };
1520
+ }
1521
+ /** Remove a single super-property key. Idempotent. */
1522
+ unregister(key) {
1523
+ if (key in this.superProps) {
1524
+ delete this.superProps[key];
1525
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1526
+ }
1527
+ }
1528
+ /** Snapshot of the current super-property bag. */
1529
+ getSuperProperties() {
1530
+ return { ...this.superProps };
1531
+ }
1532
+ // ---------- groups ----------
1533
+ /**
1534
+ * Set a group membership. Passing `id: null` clears the membership
1535
+ * for that group type — the SDK stops attaching it to events.
1536
+ */
1537
+ setGroup(type, id, traits) {
1538
+ if (id === null) {
1539
+ delete this.groups[type];
1540
+ } else {
1541
+ this.groups[type] = traits !== void 0 ? { id, traits } : { id };
1542
+ }
1543
+ writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
1544
+ }
1545
+ /**
1546
+ * Snapshot of the current groups map, keyed by group type. Returned
1547
+ * shape mirrors what the SDK attaches to every event as
1548
+ * `$groups.{type}`. The `traits` sub-object is the most-recent
1549
+ * traits payload passed to `setGroup` for that type; null when none.
1550
+ */
1551
+ getGroups() {
1552
+ return JSON.parse(JSON.stringify(this.groups));
1553
+ }
1554
+ /**
1555
+ * The flat `{ type: id }` projection used for event-attachment. Stable
1556
+ * for fast every-event merge — we don't want to JSON-clone on each
1557
+ * track() call.
1558
+ */
1559
+ getGroupIds() {
1560
+ const out = {};
1561
+ for (const [type, info] of Object.entries(this.groups)) {
1562
+ out[type] = info.id;
1563
+ }
1564
+ return out;
1565
+ }
1566
+ /** Wipe both bags. Called by Crossdeck.reset() (logout). */
1567
+ clear() {
1568
+ this.superProps = {};
1569
+ this.groups = {};
1570
+ try {
1571
+ this.storage.removeItem(this.prefix + KEY_SUPER);
1572
+ } catch {
1573
+ }
1574
+ try {
1575
+ this.storage.removeItem(this.prefix + KEY_GROUPS);
1576
+ } catch {
1577
+ }
1578
+ }
1579
+ };
1580
+ function readJson(storage, key) {
1581
+ let raw;
1582
+ try {
1583
+ raw = storage.getItem(key);
1584
+ } catch {
1585
+ return null;
1586
+ }
1587
+ if (!raw) return null;
1588
+ try {
1589
+ return JSON.parse(raw);
1590
+ } catch {
1591
+ return null;
1592
+ }
1593
+ }
1594
+ function writeJson(storage, key, value) {
1595
+ try {
1596
+ storage.setItem(key, JSON.stringify(value));
1597
+ } catch {
1598
+ }
1599
+ }
1600
+
1601
+ // src/web-vitals.ts
1602
+ var WebVitalsTracker = class {
1603
+ constructor(cfg, report) {
1604
+ this.cfg = cfg;
1605
+ this.report = report;
1606
+ this.observers = [];
1607
+ this.flushed = /* @__PURE__ */ new Set();
1608
+ this.cls = 0;
1609
+ this.clsEntries = [];
1610
+ this.inp = 0;
1611
+ this.cleanups = [];
1612
+ }
1613
+ install() {
1614
+ if (!this.cfg.enabled) return;
1615
+ if (typeof PerformanceObserver === "undefined") return;
1616
+ if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
1617
+ const doc = globalThis.document;
1618
+ try {
1619
+ const navObserver = new PerformanceObserver((list) => {
1620
+ for (const entry of list.getEntries()) {
1621
+ const e = entry;
1622
+ if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
1623
+ this.flushed.add("ttfb");
1624
+ this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
1625
+ }
1626
+ }
1627
+ });
1628
+ navObserver.observe({ type: "navigation", buffered: true });
1629
+ this.observers.push(navObserver);
1630
+ } catch {
1631
+ }
1632
+ try {
1633
+ const paintObserver = new PerformanceObserver((list) => {
1634
+ for (const entry of list.getEntries()) {
1635
+ if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
1636
+ this.flushed.add("fcp");
1637
+ this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
1638
+ }
1639
+ }
1640
+ });
1641
+ paintObserver.observe({ type: "paint", buffered: true });
1642
+ this.observers.push(paintObserver);
1643
+ } catch {
1644
+ }
1645
+ let lcpValue = 0;
1646
+ try {
1647
+ const lcpObserver = new PerformanceObserver((list) => {
1648
+ const entries = list.getEntries();
1649
+ const last = entries[entries.length - 1];
1650
+ if (last) lcpValue = last.startTime;
1651
+ });
1652
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
1653
+ this.observers.push(lcpObserver);
1654
+ } catch {
1655
+ }
1656
+ try {
1657
+ const clsObserver = new PerformanceObserver((list) => {
1658
+ for (const entry of list.getEntries()) {
1659
+ const e = entry;
1660
+ if (typeof e.value === "number" && !e.hadRecentInput) {
1661
+ this.cls += e.value;
1662
+ this.clsEntries.push(entry);
1663
+ }
1664
+ }
1665
+ });
1666
+ clsObserver.observe({ type: "layout-shift", buffered: true });
1667
+ this.observers.push(clsObserver);
1668
+ } catch {
1669
+ }
1670
+ try {
1671
+ const eventObserver = new PerformanceObserver((list) => {
1672
+ for (const entry of list.getEntries()) {
1673
+ const e = entry;
1674
+ if (e.interactionId && e.duration > this.inp) {
1675
+ this.inp = e.duration;
1676
+ }
1677
+ }
1678
+ });
1679
+ try {
1680
+ eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
1681
+ } catch {
1682
+ eventObserver.observe({ type: "first-input", buffered: true });
1683
+ }
1684
+ this.observers.push(eventObserver);
1685
+ } catch {
1686
+ }
1687
+ const flush = () => {
1688
+ if (lcpValue > 0 && !this.flushed.has("lcp")) {
1689
+ this.flushed.add("lcp");
1690
+ this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
1691
+ }
1692
+ if (this.cls > 0 && !this.flushed.has("cls")) {
1693
+ this.flushed.add("cls");
1694
+ this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
1695
+ }
1696
+ if (this.inp > 0 && !this.flushed.has("inp")) {
1697
+ this.flushed.add("inp");
1698
+ this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
1699
+ }
1700
+ };
1701
+ const onHidden = () => {
1702
+ if (doc.visibilityState === "hidden") flush();
1703
+ };
1704
+ doc.addEventListener("visibilitychange", onHidden);
1705
+ globalThis.window.addEventListener("pagehide", flush);
1706
+ this.cleanups.push(() => {
1707
+ doc.removeEventListener("visibilitychange", onHidden);
1708
+ globalThis.window.removeEventListener("pagehide", flush);
1709
+ });
1710
+ }
1711
+ uninstall() {
1712
+ for (const o of this.observers) {
1713
+ try {
1714
+ o.disconnect();
1715
+ } catch {
1716
+ }
1717
+ }
1718
+ this.observers = [];
1719
+ for (const fn of this.cleanups.splice(0)) {
1720
+ try {
1721
+ fn();
1722
+ } catch {
1723
+ }
1724
+ }
1725
+ }
1726
+ };
1727
+
1728
+ // src/consent.ts
1729
+ var ALL_GRANTED = {
1730
+ analytics: true,
1731
+ marketing: true,
1732
+ errors: true
1733
+ };
1734
+ var ConsentManager = class {
1735
+ constructor(options) {
1736
+ this.state = { ...ALL_GRANTED };
1737
+ this.dntDenied = false;
1738
+ if (options?.respectDnt && this.detectDnt()) {
1739
+ this.dntDenied = true;
1740
+ this.state = { analytics: false, marketing: false, errors: false };
1741
+ }
1742
+ }
1743
+ /**
1744
+ * Merge new dimensions onto the current state. Returns the resulting
1745
+ * snapshot. DNT-derived denies cannot be flipped back on by a `set`
1746
+ * call — once the browser says "don't track", we don't track even if
1747
+ * the developer code disagrees. That's the contract.
1748
+ */
1749
+ set(partial) {
1750
+ if (this.dntDenied) return { ...this.state };
1751
+ for (const k of Object.keys(partial)) {
1752
+ const v = partial[k];
1753
+ if (typeof v === "boolean") this.state[k] = v;
1754
+ }
1755
+ return { ...this.state };
1756
+ }
1757
+ /** Snapshot of the current state. */
1758
+ get() {
1759
+ return { ...this.state };
1760
+ }
1761
+ /** Convenience getters for hot paths. */
1762
+ get analytics() {
1763
+ return this.state.analytics;
1764
+ }
1765
+ get marketing() {
1766
+ return this.state.marketing;
1767
+ }
1768
+ get errors() {
1769
+ return this.state.errors;
1770
+ }
1771
+ /** True iff the constructor detected and applied DNT. */
1772
+ get isDntDenied() {
1773
+ return this.dntDenied;
1774
+ }
1775
+ detectDnt() {
1776
+ try {
1777
+ const nav = globalThis.navigator;
1778
+ if (!nav) return false;
1779
+ const sources = [
1780
+ nav.doNotTrack,
1781
+ nav.msDoNotTrack,
1782
+ globalThis.doNotTrack
1783
+ ];
1784
+ return sources.some((v) => v === "1" || v === "yes");
1785
+ } catch {
1786
+ return false;
1787
+ }
1788
+ }
1789
+ };
1790
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1791
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1792
+ var REPLACEMENT_EMAIL = "[email]";
1793
+ var REPLACEMENT_CARD = "[card]";
1794
+ function scrubPii(value) {
1795
+ if (!value) return value;
1796
+ let out = value;
1797
+ if (EMAIL_PATTERN.test(out)) {
1798
+ out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1799
+ }
1800
+ EMAIL_PATTERN.lastIndex = 0;
1801
+ if (CARD_PATTERN.test(out)) {
1802
+ out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1803
+ }
1804
+ CARD_PATTERN.lastIndex = 0;
1805
+ return out;
1806
+ }
1807
+ function scrubPiiFromProperties(properties) {
1808
+ const out = {};
1809
+ for (const k of Object.keys(properties)) {
1810
+ const v = properties[k];
1811
+ if (typeof v === "string") {
1812
+ out[k] = scrubPii(v);
1813
+ } else if (Array.isArray(v)) {
1814
+ out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1815
+ } else {
1816
+ out[k] = v;
1817
+ }
1818
+ }
1819
+ return out;
1820
+ }
1821
+
1822
+ // src/breadcrumbs.ts
1823
+ var BreadcrumbBuffer = class {
1824
+ constructor(maxSize = 50) {
1825
+ this.maxSize = maxSize;
1826
+ this.items = [];
1827
+ }
1828
+ add(crumb) {
1829
+ this.items.push(crumb);
1830
+ if (this.items.length > this.maxSize) {
1831
+ this.items.shift();
1832
+ }
1833
+ }
1834
+ /** Defensive copy — caller can read freely without mutating buffer state. */
1835
+ snapshot() {
1836
+ return this.items.slice();
1837
+ }
1838
+ clear() {
1839
+ this.items = [];
1840
+ }
1841
+ get size() {
1842
+ return this.items.length;
1843
+ }
1844
+ };
1845
+
1846
+ // src/stack-parser.ts
1847
+ function parseStack(stack) {
1848
+ if (!stack || typeof stack !== "string") return [];
1849
+ const lines = stack.split("\n");
1850
+ const frames = [];
1851
+ for (const line of lines) {
1852
+ const trimmed = line.trim();
1853
+ if (!trimmed) continue;
1854
+ const frame = parseLine(trimmed);
1855
+ if (frame) frames.push(frame);
1856
+ }
1857
+ return frames;
1858
+ }
1859
+ function parseLine(line) {
1860
+ let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
1861
+ if (m) {
1862
+ return buildFrame({
1863
+ function: m[1],
1864
+ filename: m[2],
1865
+ lineno: parseInt(m[3], 10),
1866
+ colno: parseInt(m[4], 10),
1867
+ raw: line
1868
+ });
1869
+ }
1870
+ m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
1871
+ if (m) {
1872
+ return buildFrame({
1873
+ function: "?",
1874
+ filename: m[1],
1875
+ lineno: parseInt(m[2], 10),
1876
+ colno: parseInt(m[3], 10),
1877
+ raw: line
1878
+ });
1879
+ }
1880
+ m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
1881
+ if (m) {
1882
+ return buildFrame({
1883
+ function: m[1] || "?",
1884
+ filename: m[2],
1885
+ lineno: parseInt(m[3], 10),
1886
+ colno: parseInt(m[4], 10),
1887
+ raw: line
1888
+ });
1889
+ }
1890
+ if (/^\w*Error/.test(line) || !line.includes(":")) {
1891
+ return null;
1892
+ }
1893
+ return {
1894
+ function: "?",
1895
+ filename: "",
1896
+ lineno: 0,
1897
+ colno: 0,
1898
+ in_app: true,
1899
+ raw: line
1900
+ };
1901
+ }
1902
+ function buildFrame(input) {
1903
+ return {
1904
+ function: input.function || "?",
1905
+ filename: input.filename,
1906
+ lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
1907
+ colno: Number.isFinite(input.colno) ? input.colno : 0,
1908
+ in_app: isInAppFrame(input.filename),
1909
+ raw: input.raw
1910
+ };
1911
+ }
1912
+ function isInAppFrame(filename) {
1913
+ if (!filename) return true;
1914
+ if (/^(?:chrome|moz|safari|webkit)-extension:\/\//.test(filename)) return false;
1915
+ if (/\bcdn\.jsdelivr\.net\b/.test(filename)) return false;
1916
+ if (/\bunpkg\.com\b/.test(filename)) return false;
1917
+ if (/\bgoogletagmanager\.com\b/.test(filename)) return false;
1918
+ if (/\bgoogle-analytics\.com\b/.test(filename)) return false;
1919
+ if (/\b@cross-deck\/web\b/.test(filename)) return false;
1920
+ if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
1921
+ return true;
1922
+ }
1923
+ function fingerprintError(message, frames) {
1924
+ const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
1925
+ const key = [
1926
+ (message || "").slice(0, 200),
1927
+ ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
1928
+ ].join("|");
1929
+ return djb2Hex(key);
1930
+ }
1931
+ function djb2Hex(input) {
1932
+ let h = 5381;
1933
+ for (let i = 0; i < input.length; i++) {
1934
+ h = (h << 5) + h + input.charCodeAt(i) | 0;
1935
+ }
1936
+ return (h >>> 0).toString(16).padStart(8, "0");
1937
+ }
1938
+
1939
+ // src/error-capture.ts
1940
+ var DEFAULT_ERROR_CAPTURE = {
1941
+ enabled: true,
1942
+ onError: true,
1943
+ onUnhandledRejection: true,
1944
+ wrapFetch: true,
1945
+ wrapXhr: true,
1946
+ captureConsole: false,
1947
+ ignoreErrors: [
1948
+ // Classic browser noise. These aren't application bugs.
1949
+ "ResizeObserver loop limit exceeded",
1950
+ "ResizeObserver loop completed with undelivered notifications",
1951
+ "Non-Error promise rejection captured",
1952
+ // Cross-origin script errors that the browser strips — no info,
1953
+ // no way to act on them, just noise.
1954
+ "Script error.",
1955
+ "Script error"
1956
+ ],
1957
+ allowUrls: [],
1958
+ denyUrls: [
1959
+ // Common third-party extensions that pollute error streams.
1960
+ /^chrome-extension:\/\//,
1961
+ /^moz-extension:\/\//,
1962
+ /^safari-extension:\/\//,
1963
+ /^webkit-extension:\/\//,
1964
+ /^safari-web-extension:\/\//
1965
+ ],
1966
+ sampleRate: 1,
1967
+ maxPerFingerprintPerMinute: 5,
1968
+ maxPerSession: 100
1969
+ };
1970
+ var ErrorTracker = class {
1971
+ constructor(opts) {
1972
+ this.opts = opts;
1973
+ this.installed = false;
1974
+ this.cleanups = [];
1975
+ this._reporting = false;
1976
+ this.sessionCount = 0;
1977
+ this.fingerprintWindow = /* @__PURE__ */ new Map();
1978
+ }
1979
+ install() {
1980
+ if (this.installed) return;
1981
+ if (!this.opts.config.enabled) return;
1982
+ if (typeof globalThis === "undefined" || !("window" in globalThis)) return;
1983
+ const w = globalThis.window;
1984
+ if (this.opts.config.onError) this.installOnErrorListener(w);
1985
+ if (this.opts.config.onUnhandledRejection) this.installRejectionListener(w);
1986
+ if (this.opts.config.wrapFetch) this.installFetchWrap(w);
1987
+ if (this.opts.config.wrapXhr) this.installXhrWrap(w);
1988
+ if (this.opts.config.captureConsole) this.installConsoleWrap();
1989
+ this.installed = true;
1990
+ }
1991
+ uninstall() {
1992
+ for (const fn of this.cleanups.splice(0)) {
1993
+ try {
1994
+ fn();
1995
+ } catch {
1996
+ }
1997
+ }
1998
+ this.installed = false;
1999
+ }
2000
+ /**
2001
+ * Manual API. Either an Error instance or any unknown value (we
2002
+ * coerce). Returns silently — never throws.
2003
+ */
2004
+ captureError(error, options) {
2005
+ if (!this.opts.isConsented()) return;
2006
+ try {
2007
+ const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
2008
+ if (options?.context) captured.context = { ...captured.context, ...options.context };
2009
+ if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
2010
+ this.maybeReport(captured);
2011
+ } catch {
2012
+ }
2013
+ }
2014
+ /**
2015
+ * Capture a non-error event as an issue. For "we hit a soft-warning
2016
+ * code path" / "deprecated API used" kinds of signals. Pairs with
2017
+ * Sentry's captureMessage().
2018
+ */
2019
+ captureMessage(message, level = "info") {
2020
+ if (!this.opts.isConsented()) return;
2021
+ try {
2022
+ const captured = {
2023
+ timestamp: Date.now(),
2024
+ kind: "error.message",
2025
+ level,
2026
+ message,
2027
+ errorType: null,
2028
+ frames: [],
2029
+ rawStack: null,
2030
+ filename: null,
2031
+ lineno: null,
2032
+ colno: null,
2033
+ fingerprint: fingerprintError(message, []),
2034
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2035
+ context: this.opts.getContext(),
2036
+ tags: this.opts.getTags()
2037
+ };
2038
+ this.maybeReport(captured);
2039
+ } catch {
2040
+ }
2041
+ }
2042
+ // ============================================================
2043
+ // Listener installation
2044
+ // ============================================================
2045
+ installOnErrorListener(w) {
2046
+ const handler = (event) => {
2047
+ if (this._reporting) return;
2048
+ if (!this.opts.isConsented()) return;
2049
+ try {
2050
+ this._reporting = true;
2051
+ const captured = this.buildFromErrorEvent(event);
2052
+ this.maybeReport(captured);
2053
+ } catch {
2054
+ } finally {
2055
+ this._reporting = false;
2056
+ }
2057
+ };
2058
+ w.addEventListener("error", handler, true);
2059
+ this.cleanups.push(() => w.removeEventListener("error", handler, true));
2060
+ }
2061
+ installRejectionListener(w) {
2062
+ const handler = (event) => {
2063
+ if (this._reporting) return;
2064
+ if (!this.opts.isConsented()) return;
2065
+ try {
2066
+ this._reporting = true;
2067
+ const captured = this.buildFromUnknown(
2068
+ event.reason,
2069
+ "error.unhandledrejection",
2070
+ "error"
2071
+ );
2072
+ this.maybeReport(captured);
2073
+ } catch {
2074
+ } finally {
2075
+ this._reporting = false;
2076
+ }
2077
+ };
2078
+ w.addEventListener("unhandledrejection", handler);
2079
+ this.cleanups.push(() => w.removeEventListener("unhandledrejection", handler));
2080
+ }
2081
+ /**
2082
+ * Wrap fetch() so failed HTTP requests get auto-captured. We do NOT
2083
+ * call this an "error" for 4xx (those are often expected — auth
2084
+ * required, validation failed). Only 5xx + network failures fire.
2085
+ */
2086
+ installFetchWrap(w) {
2087
+ const origFetch = w.fetch?.bind(w);
2088
+ if (!origFetch) return;
2089
+ const wrapped = async (...args) => {
2090
+ const input = args[0];
2091
+ const init = args[1] ?? {};
2092
+ const url = typeof input === "string" ? input : input?.url ?? "";
2093
+ const method = (init.method || "GET").toUpperCase();
2094
+ const start = Date.now();
2095
+ this.opts.breadcrumbs.add({
2096
+ timestamp: start,
2097
+ category: "http",
2098
+ message: `${method} ${url}`,
2099
+ data: { url, method }
2100
+ });
2101
+ try {
2102
+ const response = await origFetch(...args);
2103
+ if (response.status >= 500 && this.opts.isConsented()) {
2104
+ if (!url.includes("api.cross-deck.com")) {
2105
+ this.captureHttp({
2106
+ url,
2107
+ method,
2108
+ status: response.status,
2109
+ statusText: response.statusText
2110
+ });
2111
+ }
2112
+ }
2113
+ return response;
2114
+ } catch (err) {
2115
+ if (this.opts.isConsented() && !url.includes("api.cross-deck.com")) {
2116
+ this.captureHttp({
2117
+ url,
2118
+ method,
2119
+ status: 0,
2120
+ statusText: err instanceof Error ? err.message : "network error"
2121
+ });
2122
+ }
2123
+ throw err;
2124
+ }
2125
+ };
2126
+ w.fetch = wrapped;
2127
+ this.cleanups.push(() => {
2128
+ if (w.fetch === wrapped) w.fetch = origFetch;
2129
+ });
2130
+ }
2131
+ /**
2132
+ * Wrap XMLHttpRequest for legacy consumers (jQuery $.ajax under the
2133
+ * hood, older bundlers). Same capture semantics as fetch.
2134
+ */
2135
+ installXhrWrap(w) {
2136
+ const xhrCtor = w.XMLHttpRequest;
2137
+ const proto = xhrCtor?.prototype;
2138
+ if (!proto) return;
2139
+ const origOpen = proto.open;
2140
+ const origSend = proto.send;
2141
+ const tracker = this;
2142
+ proto.open = function(method, url, ...rest) {
2143
+ this._cdMethod = method;
2144
+ this._cdUrl = url;
2145
+ return origOpen.apply(this, [method, url, ...rest]);
2146
+ };
2147
+ proto.send = function(body) {
2148
+ const xhr = this;
2149
+ const onLoad = () => {
2150
+ try {
2151
+ if (xhr.status >= 500 && tracker.opts.isConsented()) {
2152
+ const url = xhr._cdUrl ?? "";
2153
+ if (!url.includes("api.cross-deck.com")) {
2154
+ tracker.captureHttp({
2155
+ url,
2156
+ method: (xhr._cdMethod ?? "GET").toUpperCase(),
2157
+ status: xhr.status,
2158
+ statusText: xhr.statusText
2159
+ });
2160
+ }
2161
+ }
2162
+ } catch {
2163
+ }
2164
+ };
2165
+ xhr.addEventListener("loadend", onLoad);
2166
+ return origSend.apply(this, [body ?? null]);
2167
+ };
2168
+ this.cleanups.push(() => {
2169
+ proto.open = origOpen;
2170
+ proto.send = origSend;
2171
+ });
2172
+ }
2173
+ installConsoleWrap() {
2174
+ const console2 = globalThis.console;
2175
+ if (!console2) return;
2176
+ const orig = console2.error.bind(console2);
2177
+ console2.error = (...args) => {
2178
+ try {
2179
+ if (this.opts.isConsented()) {
2180
+ this.captureMessage(args.map((a) => safeStringify2(a)).join(" "), "error");
2181
+ }
2182
+ } catch {
2183
+ }
2184
+ return orig(...args);
2185
+ };
2186
+ this.cleanups.push(() => {
2187
+ console2.error = orig;
2188
+ });
2189
+ }
2190
+ // ============================================================
2191
+ // Builders
2192
+ // ============================================================
2193
+ buildFromErrorEvent(event) {
2194
+ const err = event.error;
2195
+ const message = event.message || (err instanceof Error ? err.message : "Unknown error");
2196
+ const stack = err instanceof Error ? err.stack ?? null : null;
2197
+ const frames = parseStack(stack);
2198
+ return {
2199
+ timestamp: Date.now(),
2200
+ kind: "error.unhandled",
2201
+ level: "error",
2202
+ message: String(message).slice(0, 1024),
2203
+ errorType: err instanceof Error ? err.name : null,
2204
+ frames,
2205
+ rawStack: stack,
2206
+ filename: event.filename || null,
2207
+ lineno: typeof event.lineno === "number" ? event.lineno : null,
2208
+ colno: typeof event.colno === "number" ? event.colno : null,
2209
+ fingerprint: fingerprintError(message, frames),
2210
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2211
+ context: this.opts.getContext(),
2212
+ tags: this.opts.getTags()
2213
+ };
2214
+ }
2215
+ buildFromUnknown(err, kind, level) {
2216
+ if (err instanceof Error) {
2217
+ const frames = parseStack(err.stack);
2218
+ return {
2219
+ timestamp: Date.now(),
2220
+ kind,
2221
+ level,
2222
+ message: String(err.message).slice(0, 1024),
2223
+ errorType: err.name,
2224
+ frames,
2225
+ rawStack: err.stack ?? null,
2226
+ filename: null,
2227
+ lineno: null,
2228
+ colno: null,
2229
+ fingerprint: fingerprintError(err.message, frames),
2230
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2231
+ context: this.opts.getContext(),
2232
+ tags: this.opts.getTags()
2233
+ };
2234
+ }
2235
+ const message = safeStringify2(err).slice(0, 1024);
2236
+ return {
2237
+ timestamp: Date.now(),
2238
+ kind,
2239
+ level,
2240
+ message,
2241
+ errorType: null,
2242
+ frames: [],
2243
+ rawStack: null,
2244
+ filename: null,
2245
+ lineno: null,
2246
+ colno: null,
2247
+ fingerprint: fingerprintError(message, []),
2248
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2249
+ context: this.opts.getContext(),
2250
+ tags: this.opts.getTags()
2251
+ };
2252
+ }
2253
+ captureHttp(info) {
2254
+ try {
2255
+ const message = `HTTP ${info.status} ${info.method} ${info.url}`;
2256
+ const captured = {
2257
+ timestamp: Date.now(),
2258
+ kind: "error.http",
2259
+ level: "error",
2260
+ message,
2261
+ errorType: `HTTPError`,
2262
+ frames: [],
2263
+ rawStack: null,
2264
+ filename: info.url,
2265
+ lineno: null,
2266
+ colno: null,
2267
+ fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
2268
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2269
+ context: this.opts.getContext(),
2270
+ tags: this.opts.getTags(),
2271
+ http: info
2272
+ };
2273
+ this.maybeReport(captured);
2274
+ } catch {
2275
+ }
2276
+ }
2277
+ // ============================================================
2278
+ // Reporting pipeline — filter / sample / rate-limit / send
2279
+ // ============================================================
2280
+ maybeReport(err) {
2281
+ if (this.sessionCount >= this.opts.config.maxPerSession) return;
2282
+ if (this.shouldIgnore(err)) return;
2283
+ if (!this.passesUrlGate(err)) return;
2284
+ if (!this.passesSample(err)) return;
2285
+ if (!this.passesRateLimit(err)) return;
2286
+ let finalErr = err;
2287
+ if (this.opts.beforeSend) {
2288
+ try {
2289
+ finalErr = this.opts.beforeSend(err);
2290
+ } catch {
2291
+ finalErr = err;
2292
+ }
2293
+ if (!finalErr) return;
2294
+ }
2295
+ this.sessionCount += 1;
2296
+ try {
2297
+ this.opts.report(finalErr);
2298
+ } catch {
2299
+ }
2300
+ }
2301
+ shouldIgnore(err) {
2302
+ for (const pat of this.opts.config.ignoreErrors) {
2303
+ if (typeof pat === "string" && err.message.includes(pat)) return true;
2304
+ if (pat instanceof RegExp && pat.test(err.message)) return true;
2305
+ }
2306
+ return false;
2307
+ }
2308
+ passesUrlGate(err) {
2309
+ const topFrame = err.frames.find((f) => f.filename) ?? null;
2310
+ const url = topFrame?.filename ?? err.filename ?? "";
2311
+ if (!url) return true;
2312
+ for (const pat of this.opts.config.denyUrls) {
2313
+ if (typeof pat === "string" && url.includes(pat)) return false;
2314
+ if (pat instanceof RegExp && pat.test(url)) return false;
2315
+ }
2316
+ if (this.opts.config.allowUrls.length > 0) {
2317
+ for (const pat of this.opts.config.allowUrls) {
2318
+ if (typeof pat === "string" && url.includes(pat)) return true;
2319
+ if (pat instanceof RegExp && pat.test(url)) return true;
2320
+ }
2321
+ return false;
2322
+ }
2323
+ return true;
2324
+ }
2325
+ passesSample(err) {
2326
+ if (this.opts.config.sampleRate >= 1) return true;
2327
+ if (this.opts.config.sampleRate <= 0) return false;
2328
+ const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
2329
+ return hashByte / 255 < this.opts.config.sampleRate;
2330
+ }
2331
+ passesRateLimit(err) {
2332
+ const windowMs = 6e4;
2333
+ const now = Date.now();
2334
+ const max = this.opts.config.maxPerFingerprintPerMinute;
2335
+ const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
2336
+ const fresh = arr.filter((t) => now - t < windowMs);
2337
+ if (fresh.length >= max) {
2338
+ this.fingerprintWindow.set(err.fingerprint, fresh);
2339
+ return false;
2340
+ }
2341
+ fresh.push(now);
2342
+ this.fingerprintWindow.set(err.fingerprint, fresh);
2343
+ return true;
2344
+ }
2345
+ };
2346
+ function safeStringify2(v) {
2347
+ if (v == null) return String(v);
2348
+ if (typeof v === "string") return v;
2349
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
2350
+ try {
2351
+ return JSON.stringify(v);
2352
+ } catch {
2353
+ return Object.prototype.toString.call(v);
2354
+ }
2355
+ }
2356
+
1079
2357
  // src/crossdeck.ts
1080
2358
  var CrossdeckClient = class {
1081
2359
  constructor() {
@@ -1165,6 +2443,13 @@ var CrossdeckClient = class {
1165
2443
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
1166
2444
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
1167
2445
  const entitlements = new EntitlementCache();
2446
+ const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
2447
+ if (persistentEvents) {
2448
+ debug.emit(
2449
+ "sdk.queue_restored",
2450
+ "Restored persisted event queue from a prior session."
2451
+ );
2452
+ }
1168
2453
  const events = new EventQueue({
1169
2454
  http,
1170
2455
  batchSize: opts.eventFlushBatchSize,
@@ -1174,26 +2459,57 @@ var CrossdeckClient = class {
1174
2459
  environment: opts.environment,
1175
2460
  sdk: { name: SDK_NAME, version: opts.sdkVersion }
1176
2461
  }),
2462
+ persistentStore: persistentEvents ?? void 0,
1177
2463
  onFirstFlushSuccess: () => {
1178
2464
  debug.emit(
1179
2465
  "sdk.first_event_sent",
1180
2466
  "First telemetry event received. View it in Live Events.",
1181
2467
  { appId: opts.appId, environment: opts.environment }
1182
2468
  );
2469
+ },
2470
+ onRetryScheduled: (info) => {
2471
+ debug.emit(
2472
+ "sdk.flush_retry_scheduled",
2473
+ `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2474
+ { ...info }
2475
+ );
1183
2476
  }
1184
2477
  });
1185
2478
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
2479
+ const superProps = new SuperPropertyStore(
2480
+ persistIdentity ? effectiveStorage : new MemoryStorage(),
2481
+ opts.storagePrefix
2482
+ );
2483
+ const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
2484
+ if (consent.isDntDenied) {
2485
+ debug.emit(
2486
+ "sdk.consent_dnt_applied",
2487
+ "Do Not Track detected \u2014 all tracking dimensions denied at init."
2488
+ );
2489
+ }
2490
+ const breadcrumbs = new BreadcrumbBuffer(50);
1186
2491
  this.state = {
1187
2492
  http,
1188
2493
  identity,
1189
2494
  entitlements,
1190
2495
  events,
1191
2496
  autoTracker: null,
2497
+ webVitals: null,
2498
+ errors: null,
2499
+ breadcrumbs,
2500
+ errorContext: {},
2501
+ errorTags: {},
2502
+ errorBeforeSend: null,
2503
+ superProps,
2504
+ consent,
2505
+ scrubPii: options.scrubPii !== false,
1192
2506
  deviceInfo,
1193
2507
  options: opts,
1194
2508
  debug,
1195
2509
  developerUserId: null,
1196
- uninstallUnloadFlush: null
2510
+ uninstallUnloadFlush: null,
2511
+ lastServerTime: null,
2512
+ lastClientTime: null
1197
2513
  };
1198
2514
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
1199
2515
  appId: opts.appId,
@@ -1208,6 +2524,27 @@ var CrossdeckClient = class {
1208
2524
  this.state.autoTracker = tracker;
1209
2525
  tracker.install();
1210
2526
  }
2527
+ if (autoTrack.webVitals) {
2528
+ const vitals = new WebVitalsTracker(
2529
+ { enabled: true },
2530
+ (name, properties) => this.track(name, properties)
2531
+ );
2532
+ this.state.webVitals = vitals;
2533
+ vitals.install();
2534
+ }
2535
+ if (autoTrack.errors) {
2536
+ const tracker = new ErrorTracker({
2537
+ config: { ...DEFAULT_ERROR_CAPTURE, enabled: true },
2538
+ breadcrumbs,
2539
+ report: (err) => this.reportError(err),
2540
+ getContext: () => ({ ...this.state.errorContext }),
2541
+ getTags: () => ({ ...this.state.errorTags }),
2542
+ beforeSend: this.state.errorBeforeSend,
2543
+ isConsented: () => this.state.consent.errors
2544
+ });
2545
+ this.state.errors = tracker;
2546
+ tracker.install();
2547
+ }
1211
2548
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
1212
2549
  void this.flush({ keepalive: true }).catch(() => void 0);
1213
2550
  });
@@ -1231,8 +2568,19 @@ var CrossdeckClient = class {
1231
2568
  /**
1232
2569
  * Link the anonymous device to a developer-supplied user ID. Cache
1233
2570
  * the resolved Crossdeck customer for follow-up calls.
2571
+ *
2572
+ * v0.9.0+ accepts an optional `traits` bag — profile data (name,
2573
+ * plan, signupDate, role) persisted on the Crossdeck customer record
2574
+ * and queryable from dashboards. Traits are sanitised through the
2575
+ * same validator that gates `track()` properties, so a `{ avatar:
2576
+ * <File>, onSave: () => {} }` payload can't corrupt the alias call.
2577
+ *
2578
+ * Crossdeck.identify("user_847", {
2579
+ * email: "wes@pinet.co.za",
2580
+ * traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
2581
+ * });
1234
2582
  */
1235
- async identify(userId, _options) {
2583
+ async identify(userId, options) {
1236
2584
  const s = this.requireStarted();
1237
2585
  if (!userId) {
1238
2586
  throw new CrossdeckError({
@@ -1241,13 +2589,265 @@ var CrossdeckClient = class {
1241
2589
  message: "identify(userId) requires a non-empty userId."
1242
2590
  });
1243
2591
  }
2592
+ if (!s.consent.analytics) {
2593
+ s.debug.emit(
2594
+ "sdk.consent_denied",
2595
+ `identify() skipped \u2014 consent denied for analytics.`
2596
+ );
2597
+ return {
2598
+ object: "alias_result",
2599
+ crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
2600
+ linked: [],
2601
+ mergePending: false,
2602
+ env: s.options.environment
2603
+ };
2604
+ }
2605
+ const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
2606
+ const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
2607
+ if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
2608
+ for (const w of traitsValidation.warnings) {
2609
+ s.debug.emit(
2610
+ "sdk.property_coerced",
2611
+ `identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2612
+ { key: w.key, kind: w.kind }
2613
+ );
2614
+ }
2615
+ }
2616
+ const body = {
2617
+ userId,
2618
+ anonymousId: s.identity.anonymousId
2619
+ };
2620
+ if (options?.email) body.email = options.email;
2621
+ if (traits) body.traits = traits;
1244
2622
  const result = await s.http.request("POST", "/identity/alias", {
1245
- body: { userId, anonymousId: s.identity.anonymousId }
2623
+ body
1246
2624
  });
1247
2625
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
1248
2626
  s.developerUserId = userId;
1249
2627
  return result;
1250
2628
  }
2629
+ /**
2630
+ * Register super-properties — Mixpanel pattern. Once set, every
2631
+ * subsequent event of THIS SDK instance carries these keys on its
2632
+ * properties bag automatically.
2633
+ *
2634
+ * Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
2635
+ * Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
2636
+ *
2637
+ * Values that are `null` are deleted (the explicit "stop tracking
2638
+ * this key" idiom). Returns the resulting bag.
2639
+ *
2640
+ * Sanitised through `validateEventProperties` so a `{ avatar: File }`
2641
+ * payload can't poison the queue at flush time.
2642
+ */
2643
+ register(properties) {
2644
+ const s = this.requireStarted();
2645
+ const validation = validateEventProperties(properties);
2646
+ return s.superProps.register(validation.properties);
2647
+ }
2648
+ /** Remove a single super-property key. Idempotent. */
2649
+ unregister(key) {
2650
+ const s = this.requireStarted();
2651
+ s.superProps.unregister(key);
2652
+ }
2653
+ /** Snapshot of the current super-property bag. */
2654
+ getSuperProperties() {
2655
+ if (!this.state) return {};
2656
+ return this.state.superProps.getSuperProperties();
2657
+ }
2658
+ /**
2659
+ * Associate the current user with a group (org, team, account, etc.).
2660
+ * Mixpanel / Segment "Group Analytics" pattern.
2661
+ *
2662
+ * Crossdeck.group("org", "acme_inc");
2663
+ * Crossdeck.group("team", "design", { headcount: 12 });
2664
+ *
2665
+ * Once set, every subsequent event carries `$groups.<type>: id` on
2666
+ * its properties bag, enabling B2B dashboards ("how is Acme using
2667
+ * the product"). Pass `id: null` to clear a group membership.
2668
+ */
2669
+ group(type, id, traits) {
2670
+ const s = this.requireStarted();
2671
+ if (!type) {
2672
+ throw new CrossdeckError({
2673
+ type: "invalid_request_error",
2674
+ code: "missing_group_type",
2675
+ message: "group(type, id) requires a non-empty type."
2676
+ });
2677
+ }
2678
+ const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
2679
+ s.superProps.setGroup(type, id, sanitisedTraits);
2680
+ }
2681
+ /** Snapshot of the current groups map keyed by type. */
2682
+ getGroups() {
2683
+ if (!this.state) return {};
2684
+ return this.state.superProps.getGroups();
2685
+ }
2686
+ /**
2687
+ * Update consent state. Three independent dimensions:
2688
+ *
2689
+ * analytics — track() + identify() + auto-emissions
2690
+ * marketing — paid-traffic click IDs + referrer URL on events
2691
+ * errors — Web Vitals + (future) error reporting
2692
+ *
2693
+ * Each defaults to `true` (granted). Pass partial state — only the
2694
+ * keys you provide are changed.
2695
+ *
2696
+ * Crossdeck.consent({ analytics: false });
2697
+ * Crossdeck.consent({ marketing: true, errors: true });
2698
+ *
2699
+ * DNT-derived denies cannot be flipped back on; if the browser said
2700
+ * "don't track" we don't track even if the developer code disagrees.
2701
+ */
2702
+ consent(state) {
2703
+ const s = this.requireStarted();
2704
+ const next = s.consent.set(state);
2705
+ s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
2706
+ return next;
2707
+ }
2708
+ /** Snapshot of the current consent state. */
2709
+ consentStatus() {
2710
+ if (!this.state) {
2711
+ return { analytics: true, marketing: true, errors: true };
2712
+ }
2713
+ return this.state.consent.get();
2714
+ }
2715
+ // ============================================================
2716
+ // Error capture surface (v1.0.0+)
2717
+ // ============================================================
2718
+ /**
2719
+ * Manually capture an error from a try/catch block.
2720
+ *
2721
+ * try { …risky… } catch (err) {
2722
+ * Crossdeck.captureError(err, { context: { plan: "pro" } });
2723
+ * }
2724
+ *
2725
+ * The error is shipped through the same event queue as analytics
2726
+ * (durable, retried, rate-limited per fingerprint). Sends are gated
2727
+ * by `consent.errors`. Returns silently — never throws, even if the
2728
+ * SDK isn't initialised yet.
2729
+ */
2730
+ captureError(error, options) {
2731
+ if (!this.state?.errors) return;
2732
+ this.state.errors.captureError(error, options);
2733
+ }
2734
+ /**
2735
+ * Capture a non-error event you want to surface as an issue
2736
+ * ("deprecated path hit", "we entered the slow code path"). Sentry
2737
+ * captureMessage pattern. Returns silently if not initialised.
2738
+ */
2739
+ captureMessage(message, level = "info") {
2740
+ if (!this.state?.errors) return;
2741
+ this.state.errors.captureMessage(message, level);
2742
+ }
2743
+ /**
2744
+ * Attach a tag to every subsequent error report. Tags are key/value
2745
+ * strings (Sentry pattern): `setTag("flow", "checkout")` → every
2746
+ * error from this point on carries `tags.flow === "checkout"`.
2747
+ */
2748
+ setTag(key, value) {
2749
+ if (!this.state) return;
2750
+ this.state.errorTags[key] = value;
2751
+ }
2752
+ /** Bulk-set tags. Merges with existing tags. */
2753
+ setTags(tags) {
2754
+ if (!this.state) return;
2755
+ Object.assign(this.state.errorTags, tags);
2756
+ }
2757
+ /**
2758
+ * Attach a structured context blob to every subsequent error report.
2759
+ * Unlike tags (flat key/value), context is a named bag of arbitrary
2760
+ * data: `setContext("cart", { items: 3, total: 42.99 })`.
2761
+ */
2762
+ setContext(name, data) {
2763
+ if (!this.state) return;
2764
+ this.state.errorContext[name] = data;
2765
+ }
2766
+ /**
2767
+ * Add a custom breadcrumb to the rolling buffer. Useful for marking
2768
+ * domain-meaningful moments ("user opened paywall") that aren't
2769
+ * already auto-captured. The buffer caps at 50 entries; old ones
2770
+ * evict.
2771
+ */
2772
+ addBreadcrumb(crumb) {
2773
+ if (!this.state) return;
2774
+ this.state.breadcrumbs.add(crumb);
2775
+ }
2776
+ /**
2777
+ * Install a pre-send hook for errors. Return null to drop, or a
2778
+ * modified CapturedError to scrub / rewrite. Sentry's beforeSend
2779
+ * pattern — the only way to redact app-specific PII (auth tokens
2780
+ * in URLs, etc.) before the report leaves the browser.
2781
+ */
2782
+ setErrorBeforeSend(hook) {
2783
+ if (!this.state) return;
2784
+ this.state.errorBeforeSend = hook;
2785
+ }
2786
+ /**
2787
+ * Internal: turn a CapturedError into a Crossdeck event and enqueue
2788
+ * it. Goes through the same queue / persistence / consent / scrub
2789
+ * pipeline as analytics events.
2790
+ */
2791
+ reportError(err) {
2792
+ const properties = {
2793
+ // Identifiers
2794
+ fingerprint: err.fingerprint,
2795
+ level: err.level,
2796
+ // Error shape
2797
+ errorType: err.errorType,
2798
+ message: err.message,
2799
+ // Stack
2800
+ stack: err.rawStack ?? void 0,
2801
+ frames: err.frames,
2802
+ filename: err.filename ?? void 0,
2803
+ lineno: err.lineno ?? void 0,
2804
+ colno: err.colno ?? void 0,
2805
+ // Context
2806
+ tags: err.tags,
2807
+ context: err.context,
2808
+ breadcrumbs: err.breadcrumbs,
2809
+ // HTTP (only when applicable)
2810
+ http: err.http
2811
+ };
2812
+ for (const k of Object.keys(properties)) {
2813
+ if (properties[k] === void 0) delete properties[k];
2814
+ }
2815
+ this.track(err.kind, properties);
2816
+ }
2817
+ /**
2818
+ * GDPR/CCPA "right to be forgotten" — calls the backend's
2819
+ * /v1/identity/forget endpoint to schedule a server-side deletion of
2820
+ * the customer's events and profile, then wipes all local state
2821
+ * (identity, entitlements, queue, super-props, persistent stores).
2822
+ *
2823
+ * Idempotent. Safe to call when no identity has been established
2824
+ * (it just wipes the empty local state).
2825
+ *
2826
+ * After forget() resolves, the SDK is in the same shape as if the
2827
+ * developer had called `Crossdeck.reset()` — a fresh anonymousId is
2828
+ * minted and the next session is a brand new identity-graph entry.
2829
+ */
2830
+ async forget() {
2831
+ const s = this.requireStarted();
2832
+ const identityQuery = this.identityQueryParams();
2833
+ try {
2834
+ await s.http.request("POST", "/identity/forget", {
2835
+ body: {
2836
+ // Send every identity hint we hold; the server resolves the
2837
+ // canonical customer record and queues deletion. Missing
2838
+ // endpoint (older backend) gracefully degrades — local state
2839
+ // still wipes via the reset() call below.
2840
+ ...identityQuery
2841
+ }
2842
+ });
2843
+ } catch (err) {
2844
+ s.debug.emit(
2845
+ "sdk.consent_denied",
2846
+ `forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
2847
+ );
2848
+ }
2849
+ this.reset();
2850
+ }
1251
2851
  /**
1252
2852
  * Read the current customer's active entitlements from the server.
1253
2853
  * Updates the local cache so subsequent isEntitled() calls answer
@@ -1325,6 +2925,18 @@ var CrossdeckClient = class {
1325
2925
  message: "track(name) requires a non-empty name."
1326
2926
  });
1327
2927
  }
2928
+ const isError = name.startsWith("error.");
2929
+ const isWebVital = name.startsWith("webvitals.");
2930
+ const consentGateOk = isError || isWebVital ? s.consent.errors : s.consent.analytics;
2931
+ if (!consentGateOk) {
2932
+ if (s.debug.enabled) {
2933
+ s.debug.emit(
2934
+ "sdk.consent_denied",
2935
+ `Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
2936
+ );
2937
+ }
2938
+ return;
2939
+ }
1328
2940
  if (s.debug.enabled && properties) {
1329
2941
  const flagged = findSensitivePropertyKeys(properties);
1330
2942
  if (flagged.length > 0) {
@@ -1341,9 +2953,21 @@ var CrossdeckClient = class {
1341
2953
  "Using anonymous user until identify(userId) is called."
1342
2954
  );
1343
2955
  }
2956
+ const validation = validateEventProperties(properties);
2957
+ if (s.debug.enabled && validation.warnings.length > 0) {
2958
+ for (const w of validation.warnings) {
2959
+ s.debug.emit(
2960
+ "sdk.property_coerced",
2961
+ `Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2962
+ { eventName: name, key: w.key, kind: w.kind }
2963
+ );
2964
+ }
2965
+ }
1344
2966
  const enriched = { ...s.deviceInfo };
1345
2967
  const sessionId = s.autoTracker?.currentSessionId;
1346
2968
  if (sessionId) enriched.sessionId = sessionId;
2969
+ const pageviewId = s.autoTracker?.currentPageviewId;
2970
+ if (pageviewId) enriched.pageviewId = pageviewId;
1347
2971
  const acquisition = s.autoTracker?.currentAcquisition;
1348
2972
  if (acquisition) {
1349
2973
  if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
@@ -1351,17 +2975,46 @@ var CrossdeckClient = class {
1351
2975
  if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1352
2976
  if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1353
2977
  if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1354
- if (acquisition.referrer) enriched.referrer = acquisition.referrer;
2978
+ if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
2979
+ if (s.consent.marketing) {
2980
+ if (acquisition.gclid) enriched.gclid = acquisition.gclid;
2981
+ if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
2982
+ if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
2983
+ if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
2984
+ if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
2985
+ if (acquisition.twclid) enriched.twclid = acquisition.twclid;
2986
+ }
2987
+ }
2988
+ const supers = s.superProps.getSuperProperties();
2989
+ for (const k of Object.keys(supers)) {
2990
+ if (!(k in enriched)) enriched[k] = supers[k];
2991
+ }
2992
+ const groupIds = s.superProps.getGroupIds();
2993
+ if (Object.keys(groupIds).length > 0) {
2994
+ enriched.$groups = groupIds;
1355
2995
  }
1356
- if (properties) Object.assign(enriched, properties);
2996
+ Object.assign(enriched, validation.properties);
2997
+ const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
1357
2998
  const event = {
1358
2999
  eventId: this.mintEventId(),
1359
3000
  name,
1360
3001
  timestamp: Date.now(),
1361
- properties: enriched
3002
+ properties: finalProperties
1362
3003
  };
1363
3004
  Object.assign(event, this.identityHintForEvent());
1364
3005
  s.events.enqueue(event);
3006
+ if (!isError && !isWebVital) {
3007
+ const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
3008
+ s.breadcrumbs.add({
3009
+ timestamp: event.timestamp,
3010
+ category,
3011
+ message: name,
3012
+ // Strip the device-info / session bloat from the breadcrumb
3013
+ // payload — only the caller-supplied properties belong in
3014
+ // the user-readable trail.
3015
+ data: properties ? { ...properties } : void 0
3016
+ });
3017
+ }
1365
3018
  }
1366
3019
  /**
1367
3020
  * Force-flush queued events. Useful to call from page-unload handlers.
@@ -1436,7 +3089,12 @@ var CrossdeckClient = class {
1436
3089
  */
1437
3090
  async heartbeat() {
1438
3091
  const s = this.requireStarted();
1439
- return await s.http.request("GET", "/sdk/heartbeat");
3092
+ const result = await s.http.request("GET", "/sdk/heartbeat");
3093
+ if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
3094
+ s.lastServerTime = result.serverTime;
3095
+ s.lastClientTime = Date.now();
3096
+ }
3097
+ return result;
1440
3098
  }
1441
3099
  /**
1442
3100
  * Wipe persisted identity + entitlement cache. Use on logout. The
@@ -1455,6 +3113,10 @@ var CrossdeckClient = class {
1455
3113
  this.state.identity.reset();
1456
3114
  this.state.entitlements.clear();
1457
3115
  this.state.events.reset();
3116
+ this.state.superProps.clear();
3117
+ this.state.breadcrumbs.clear();
3118
+ this.state.errorContext = {};
3119
+ this.state.errorTags = {};
1458
3120
  this.state.developerUserId = null;
1459
3121
  if (this.state.autoTracker) {
1460
3122
  const tracker = new AutoTracker(
@@ -1482,17 +3144,21 @@ var CrossdeckClient = class {
1482
3144
  developerUserId: null,
1483
3145
  sdkVersion: null,
1484
3146
  baseUrl: null,
1485
- entitlements: { count: 0, lastUpdated: 0 },
3147
+ clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
3148
+ entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
1486
3149
  events: {
1487
3150
  buffered: 0,
1488
3151
  dropped: 0,
1489
3152
  inFlight: 0,
1490
3153
  lastFlushAt: 0,
1491
- lastError: null
3154
+ lastError: null,
3155
+ consecutiveFailures: 0,
3156
+ nextRetryAt: null
1492
3157
  }
1493
3158
  };
1494
3159
  }
1495
3160
  const s = this.state;
3161
+ const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
1496
3162
  return {
1497
3163
  started: true,
1498
3164
  anonymousId: s.identity.anonymousId,
@@ -1500,9 +3166,15 @@ var CrossdeckClient = class {
1500
3166
  developerUserId: s.developerUserId,
1501
3167
  sdkVersion: s.options.sdkVersion,
1502
3168
  baseUrl: s.options.baseUrl,
3169
+ clock: {
3170
+ lastServerTime: s.lastServerTime,
3171
+ lastClientTime: s.lastClientTime,
3172
+ skewMs
3173
+ },
1503
3174
  entitlements: {
1504
3175
  count: s.entitlements.list().length,
1505
- lastUpdated: s.entitlements.freshness
3176
+ lastUpdated: s.entitlements.freshness,
3177
+ listenerErrors: s.entitlements.listenerErrors
1506
3178
  },
1507
3179
  events: s.events.getStats()
1508
3180
  };
@@ -1569,6 +3241,7 @@ function inferEnvFromKey(publicKey) {
1569
3241
  }
1570
3242
  function isLocalHostname() {
1571
3243
  const w = globalThis.window;
3244
+ if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
1572
3245
  const hostname = w?.location?.hostname;
1573
3246
  if (!hostname) return false;
1574
3247
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
@@ -1581,7 +3254,14 @@ function isLocalHostname() {
1581
3254
  }
1582
3255
  function resolveAutoTrack(input) {
1583
3256
  if (input === false) {
1584
- return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
3257
+ return {
3258
+ sessions: false,
3259
+ pageViews: false,
3260
+ deviceInfo: false,
3261
+ clicks: false,
3262
+ webVitals: false,
3263
+ errors: false
3264
+ };
1585
3265
  }
1586
3266
  if (input === void 0 || input === true) {
1587
3267
  return { ...DEFAULT_AUTO_TRACK };
@@ -1590,7 +3270,9 @@ function resolveAutoTrack(input) {
1590
3270
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1591
3271
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1592
3272
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1593
- clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
3273
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
3274
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals,
3275
+ errors: input.errors ?? DEFAULT_AUTO_TRACK.errors
1594
3276
  };
1595
3277
  }
1596
3278
  function installUnloadFlush(onUnload) {