@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/index.mjs CHANGED
@@ -7,11 +7,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
7
7
  this.code = payload.code;
8
8
  this.requestId = payload.requestId;
9
9
  this.status = payload.status;
10
+ this.retryAfterMs = payload.retryAfterMs;
10
11
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
11
12
  }
12
13
  };
13
14
  async function crossdeckErrorFromResponse(res) {
14
15
  const requestId = res.headers.get("x-request-id") ?? void 0;
16
+ const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
15
17
  let body;
16
18
  try {
17
19
  body = await res.json();
@@ -25,7 +27,8 @@ async function crossdeckErrorFromResponse(res) {
25
27
  code: envelope.code,
26
28
  message: envelope.message ?? `HTTP ${res.status}`,
27
29
  requestId: envelope.request_id ?? requestId,
28
- status: res.status
30
+ status: res.status,
31
+ retryAfterMs
29
32
  });
30
33
  }
31
34
  return new CrossdeckError({
@@ -33,9 +36,25 @@ async function crossdeckErrorFromResponse(res) {
33
36
  code: `http_${res.status}`,
34
37
  message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
35
38
  requestId,
36
- status: res.status
39
+ status: res.status,
40
+ retryAfterMs
37
41
  });
38
42
  }
43
+ function parseRetryAfterHeader(value) {
44
+ if (!value) return void 0;
45
+ const trimmed = value.trim();
46
+ if (!trimmed) return void 0;
47
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
48
+ const secs = Number(trimmed);
49
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
50
+ return Math.round(secs * 1e3);
51
+ }
52
+ if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
53
+ const target = Date.parse(trimmed);
54
+ if (!Number.isFinite(target)) return void 0;
55
+ const delta = target - Date.now();
56
+ return delta > 0 ? delta : 0;
57
+ }
39
58
  function typeMapForStatus(status) {
40
59
  if (status === 401) return "authentication_error";
41
60
  if (status === 403) return "permission_error";
@@ -46,8 +65,9 @@ function typeMapForStatus(status) {
46
65
 
47
66
  // src/http.ts
48
67
  var SDK_NAME = "@cross-deck/web";
49
- var SDK_VERSION = "0.6.0";
68
+ var SDK_VERSION = "1.0.0";
50
69
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
70
+ var DEFAULT_TIMEOUT_MS = 15e3;
51
71
  var HttpClient = class {
52
72
  constructor(config) {
53
73
  this.config = config;
@@ -71,25 +91,38 @@ var HttpClient = class {
71
91
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
72
92
  Accept: "application/json"
73
93
  };
94
+ if (options.idempotencyKey) {
95
+ headers["Idempotency-Key"] = options.idempotencyKey;
96
+ }
74
97
  let bodyInit;
75
98
  if (options.body !== void 0) {
76
99
  headers["Content-Type"] = "application/json";
77
100
  bodyInit = JSON.stringify(options.body);
78
101
  }
102
+ const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
103
+ const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
104
+ let timeoutHandle = null;
105
+ if (controller && effectiveTimeout > 0) {
106
+ timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
107
+ }
79
108
  let response;
80
109
  try {
81
110
  response = await fetch(url, {
82
111
  method,
83
112
  headers,
84
113
  body: bodyInit,
85
- keepalive: options.keepalive === true
114
+ keepalive: options.keepalive === true,
115
+ signal: controller?.signal
86
116
  });
87
117
  } catch (err) {
118
+ const aborted = controller?.signal?.aborted === true;
88
119
  throw new CrossdeckError({
89
120
  type: "network_error",
90
- code: "fetch_failed",
91
- message: err instanceof Error ? err.message : "fetch failed"
121
+ code: aborted ? "request_timeout" : "fetch_failed",
122
+ message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
92
123
  });
124
+ } finally {
125
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
93
126
  }
94
127
  if (!response.ok) {
95
128
  throw await crossdeckErrorFromResponse(response);
@@ -286,6 +319,7 @@ var EntitlementCache = class {
286
319
  this.all = [];
287
320
  this.lastUpdated = 0;
288
321
  this.listeners = /* @__PURE__ */ new Set();
322
+ this.listenerErrorCount = 0;
289
323
  }
290
324
  /** Sync read — true iff the entitlement key is currently active. */
291
325
  isEntitled(key) {
@@ -299,6 +333,15 @@ var EntitlementCache = class {
299
333
  get freshness() {
300
334
  return this.lastUpdated;
301
335
  }
336
+ /**
337
+ * Cumulative count of listener invocations that threw. Listener errors
338
+ * are swallowed (a buggy consumer must not crash the SDK) but the
339
+ * counter lets diagnostics() surface "you have a broken subscriber"
340
+ * without putting the developer in a debug session.
341
+ */
342
+ get listenerErrors() {
343
+ return this.listenerErrorCount;
344
+ }
302
345
  /**
303
346
  * Replace the cache with a fresh server response. The backend already
304
347
  * filters to active + env-matching, so we don't re-filter — just trust
@@ -352,11 +395,54 @@ var EntitlementCache = class {
352
395
  try {
353
396
  listener(snapshot);
354
397
  } catch {
398
+ this.listenerErrorCount += 1;
355
399
  }
356
400
  }
357
401
  }
358
402
  };
359
403
 
404
+ // src/retry-policy.ts
405
+ var DEFAULT_BASE = 1e3;
406
+ var DEFAULT_MAX = 6e4;
407
+ var DEFAULT_FACTOR = 2;
408
+ var DEFAULT_WARN = 8;
409
+ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
410
+ const base = options.baseMs ?? DEFAULT_BASE;
411
+ const max = options.maxMs ?? DEFAULT_MAX;
412
+ const factor = options.factor ?? DEFAULT_FACTOR;
413
+ const safeAttempts = Math.min(attempts, 30);
414
+ const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
415
+ const jittered = ceiling * random();
416
+ if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
417
+ return Math.min(max, retryAfterMs);
418
+ }
419
+ return Math.max(0, Math.round(jittered));
420
+ }
421
+ var RetryPolicy = class {
422
+ constructor(options = {}) {
423
+ this.options = options;
424
+ this.attempts = 0;
425
+ }
426
+ /** How many consecutive failures since the last success. */
427
+ get consecutiveFailures() {
428
+ return this.attempts;
429
+ }
430
+ /** Whether we've crossed the failuresBeforeWarn threshold. */
431
+ get isWarning() {
432
+ return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
433
+ }
434
+ /** Schedule-time delay for the NEXT retry. Increments the counter. */
435
+ nextDelay(retryAfterMs, random = Math.random) {
436
+ const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
437
+ this.attempts += 1;
438
+ return delay;
439
+ }
440
+ /** Mark a successful flush — reset the counter. */
441
+ recordSuccess() {
442
+ this.attempts = 0;
443
+ }
444
+ };
445
+
360
446
  // src/event-queue.ts
361
447
  var HARD_BUFFER_CAP = 1e3;
362
448
  var EventQueue = class {
@@ -369,6 +455,22 @@ var EventQueue = class {
369
455
  this.lastError = null;
370
456
  this.cancelTimer = null;
371
457
  this.firstFlushFired = false;
458
+ this.nextRetryAt = null;
459
+ this.retry = new RetryPolicy(cfg.retry ?? {});
460
+ this.persistent = cfg.persistentStore ?? null;
461
+ if (this.persistent) {
462
+ const restored = this.persistent.load();
463
+ if (restored.length > 0) {
464
+ if (restored.length > HARD_BUFFER_CAP) {
465
+ this.dropped += restored.length - HARD_BUFFER_CAP;
466
+ this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
467
+ } else {
468
+ this.buffer = restored;
469
+ }
470
+ this.cfg.onBufferChange?.(this.buffer.length);
471
+ this.scheduleIdleFlush();
472
+ }
473
+ }
372
474
  }
373
475
  enqueue(event) {
374
476
  this.buffer.push(event);
@@ -378,6 +480,8 @@ var EventQueue = class {
378
480
  this.dropped += overflow;
379
481
  this.cfg.onDrop?.(overflow);
380
482
  }
483
+ this.cfg.onBufferChange?.(this.buffer.length);
484
+ this.persistent?.save(this.buffer);
381
485
  if (this.buffer.length >= this.cfg.batchSize) {
382
486
  void this.flush();
383
487
  } else {
@@ -387,7 +491,7 @@ var EventQueue = class {
387
491
  /**
388
492
  * Flush the buffer to /v1/events. Resolves when the network call
389
493
  * completes (success or failure). On failure, events stay in the
390
- * buffer for the next flush attempt.
494
+ * buffer for the next scheduled retry.
391
495
  *
392
496
  * `options.keepalive` marks the underlying fetch as keepalive so the
393
497
  * browser keeps the request alive past page unload. Use this for
@@ -396,25 +500,32 @@ var EventQueue = class {
396
500
  async flush(options = {}) {
397
501
  if (this.buffer.length === 0) return null;
398
502
  this.cancelTimerIfSet();
503
+ this.nextRetryAt = null;
399
504
  const batch = this.buffer.splice(0);
505
+ const batchId = this.mintBatchId();
400
506
  this.inFlight += batch.length;
507
+ this.persistent?.save(this.buffer);
508
+ this.cfg.onBufferChange?.(this.buffer.length);
401
509
  try {
402
510
  const env = this.cfg.envelope();
403
511
  const result = await this.cfg.http.request("POST", "/events", {
404
512
  body: {
405
513
  // NorthStar §13.1 batch envelope. The backend validates these
406
- // against the API-key-resolved app and rejects mismatches loudly
407
- // (env_mismatch).
514
+ // against the API-key-resolved app and rejects mismatches
515
+ // loudly (env_mismatch).
408
516
  appId: env.appId,
409
517
  environment: env.environment,
410
518
  sdk: env.sdk,
411
519
  events: batch
412
520
  },
413
- keepalive: options.keepalive === true
521
+ keepalive: options.keepalive === true,
522
+ idempotencyKey: batchId
414
523
  });
415
524
  this.lastFlushAt = Date.now();
416
525
  this.lastError = null;
417
526
  this.inFlight -= batch.length;
527
+ this.retry.recordSuccess();
528
+ this.persistent?.save(this.buffer);
418
529
  if (!this.firstFlushFired) {
419
530
  this.firstFlushFired = true;
420
531
  this.cfg.onFirstFlushSuccess?.();
@@ -423,18 +534,33 @@ var EventQueue = class {
423
534
  } catch (err) {
424
535
  this.buffer.unshift(...batch);
425
536
  this.inFlight -= batch.length;
426
- this.lastError = err instanceof Error ? err.message : String(err);
427
- this.scheduleIdleFlush();
537
+ const message = err instanceof Error ? err.message : String(err);
538
+ this.lastError = message;
539
+ this.persistent?.save(this.buffer);
540
+ this.cfg.onBufferChange?.(this.buffer.length);
541
+ const retryAfterMs = extractRetryAfterMs(err);
542
+ const delay = this.retry.nextDelay(retryAfterMs);
543
+ this.scheduleRetry(delay);
544
+ this.cfg.onRetryScheduled?.({
545
+ delayMs: delay,
546
+ consecutiveFailures: this.retry.consecutiveFailures,
547
+ retryAfterMs,
548
+ lastError: message
549
+ });
428
550
  return null;
429
551
  }
430
552
  }
431
- /** Cancel any pending timer and clear in-memory state. */
553
+ /** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
432
554
  reset() {
433
555
  this.cancelTimerIfSet();
556
+ this.nextRetryAt = null;
434
557
  this.buffer = [];
435
558
  this.dropped = 0;
436
559
  this.inFlight = 0;
437
560
  this.lastError = null;
561
+ this.retry.recordSuccess();
562
+ this.persistent?.clear();
563
+ this.cfg.onBufferChange?.(0);
438
564
  }
439
565
  getStats() {
440
566
  return {
@@ -442,9 +568,12 @@ var EventQueue = class {
442
568
  dropped: this.dropped,
443
569
  inFlight: this.inFlight,
444
570
  lastFlushAt: this.lastFlushAt,
445
- lastError: this.lastError
571
+ lastError: this.lastError,
572
+ consecutiveFailures: this.retry.consecutiveFailures,
573
+ nextRetryAt: this.nextRetryAt
446
574
  };
447
575
  }
576
+ // ---------- internal scheduling ----------
448
577
  scheduleIdleFlush() {
449
578
  this.cancelTimerIfSet();
450
579
  const sched = this.cfg.scheduler ?? defaultScheduler;
@@ -452,13 +581,31 @@ var EventQueue = class {
452
581
  void this.flush();
453
582
  }, this.cfg.intervalMs);
454
583
  }
584
+ scheduleRetry(delayMs) {
585
+ this.cancelTimerIfSet();
586
+ this.nextRetryAt = Date.now() + delayMs;
587
+ const sched = this.cfg.scheduler ?? defaultScheduler;
588
+ this.cancelTimer = sched(() => {
589
+ void this.flush();
590
+ }, delayMs);
591
+ }
455
592
  cancelTimerIfSet() {
456
593
  if (this.cancelTimer) {
457
594
  this.cancelTimer();
458
595
  this.cancelTimer = null;
459
596
  }
460
597
  }
598
+ mintBatchId() {
599
+ return `batch_${Date.now().toString(36)}${randomChars(10)}`;
600
+ }
461
601
  };
602
+ function extractRetryAfterMs(err) {
603
+ if (err && typeof err === "object" && "retryAfterMs" in err) {
604
+ const v = err.retryAfterMs;
605
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
606
+ }
607
+ return void 0;
608
+ }
462
609
  function defaultScheduler(fn, ms) {
463
610
  const id = setTimeout(fn, ms);
464
611
  if (typeof id.unref === "function") {
@@ -470,6 +617,87 @@ function defaultScheduler(fn, ms) {
470
617
  return () => clearTimeout(id);
471
618
  }
472
619
 
620
+ // src/event-storage.ts
621
+ var PersistentEventStore = class {
622
+ constructor(options) {
623
+ this.options = options;
624
+ this.writeScheduled = false;
625
+ // Pending events captured on the most recent write request. We keep
626
+ // the latest snapshot ref so a debounced write always picks up the
627
+ // freshest buffer state.
628
+ this.pendingSnapshot = null;
629
+ this.key = `${options.prefix}queue.v1`;
630
+ }
631
+ /**
632
+ * Read the persisted queue on boot. Returns an empty array (with no
633
+ * warning) when nothing is stored, the blob is malformed, or storage
634
+ * is unavailable. Caller is responsible for treating duplicates from
635
+ * the persisted queue as the SAME events (eventId-based dedup).
636
+ */
637
+ load() {
638
+ let raw;
639
+ try {
640
+ raw = this.options.storage.getItem(this.key);
641
+ } catch {
642
+ return [];
643
+ }
644
+ if (!raw) return [];
645
+ try {
646
+ const parsed = JSON.parse(raw);
647
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
648
+ return [];
649
+ }
650
+ return parsed.events;
651
+ } catch {
652
+ return [];
653
+ }
654
+ }
655
+ /**
656
+ * Schedule a write of the current buffer. Debounced via microtask so
657
+ * a burst of enqueue() calls coalesces into one persistence write.
658
+ * Writes are best-effort: if storage throws (quota, private mode),
659
+ * we swallow and rely on the in-memory buffer.
660
+ */
661
+ save(snapshot) {
662
+ this.pendingSnapshot = snapshot.slice();
663
+ if (this.writeScheduled) return;
664
+ this.writeScheduled = true;
665
+ queueMicrotask(() => this.flushWrite());
666
+ }
667
+ /** Synchronous variant for terminal flushes (pagehide / beforeunload). */
668
+ saveSync(snapshot) {
669
+ this.pendingSnapshot = snapshot.slice();
670
+ this.flushWrite();
671
+ }
672
+ /** Wipe the persisted blob. Used by reset() (logout). */
673
+ clear() {
674
+ this.pendingSnapshot = null;
675
+ this.writeScheduled = false;
676
+ try {
677
+ this.options.storage.removeItem(this.key);
678
+ } catch {
679
+ }
680
+ }
681
+ flushWrite() {
682
+ this.writeScheduled = false;
683
+ const snapshot = this.pendingSnapshot;
684
+ this.pendingSnapshot = null;
685
+ if (snapshot === null) return;
686
+ if (snapshot.length === 0) {
687
+ try {
688
+ this.options.storage.removeItem(this.key);
689
+ } catch {
690
+ }
691
+ return;
692
+ }
693
+ const blob = { version: 1, events: snapshot };
694
+ try {
695
+ this.options.storage.setItem(this.key, JSON.stringify(blob));
696
+ } catch {
697
+ }
698
+ }
699
+ };
700
+
473
701
  // src/storage.ts
474
702
  var MemoryStorage = class {
475
703
  constructor() {
@@ -660,7 +888,9 @@ var DEFAULT_AUTO_TRACK = {
660
888
  sessions: true,
661
889
  pageViews: true,
662
890
  deviceInfo: true,
663
- clicks: true
891
+ clicks: true,
892
+ webVitals: true,
893
+ errors: true
664
894
  };
665
895
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
666
896
  var EMPTY_ACQUISITION = {
@@ -669,7 +899,13 @@ var EMPTY_ACQUISITION = {
669
899
  utm_campaign: "",
670
900
  utm_content: "",
671
901
  utm_term: "",
672
- referrer: ""
902
+ referrer: "",
903
+ gclid: "",
904
+ fbclid: "",
905
+ msclkid: "",
906
+ ttclid: "",
907
+ li_fat_id: "",
908
+ twclid: ""
673
909
  };
674
910
  var AutoTracker = class {
675
911
  constructor(cfg, track) {
@@ -677,6 +913,17 @@ var AutoTracker = class {
677
913
  this.track = track;
678
914
  this.session = null;
679
915
  this.cleanups = [];
916
+ /**
917
+ * Stable per-page-view identifier. Minted at every `page.viewed`
918
+ * emission and attached to every subsequent event until the next
919
+ * `page.viewed`. Lets dashboards correlate "user clicked X" to
920
+ * "user viewed page Y" without timestamp arithmetic — the canonical
921
+ * Mixpanel `$current_url` / Segment `pageId` pattern.
922
+ *
923
+ * Null until the first `page.viewed` fires (which happens at SDK
924
+ * install if `autoTrack.pageViews !== false`).
925
+ */
926
+ this.pageviewId = null;
680
927
  }
681
928
  install() {
682
929
  if (!isBrowserSafe()) return;
@@ -707,6 +954,10 @@ var AutoTracker = class {
707
954
  get currentSessionId() {
708
955
  return this.session?.sessionId ?? null;
709
956
  }
957
+ /** Stable per-page-view ID. Null before the first page.viewed has fired. */
958
+ get currentPageviewId() {
959
+ return this.pageviewId;
960
+ }
710
961
  /**
711
962
  * Per-session acquisition context — utm_* + referrer, captured once
712
963
  * at session start. Returns empty strings when there's no session
@@ -787,7 +1038,9 @@ var AutoTracker = class {
787
1038
  if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
788
1039
  lastFiredAt = now;
789
1040
  lastFiredUrl = url;
1041
+ this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
790
1042
  this.track("page.viewed", {
1043
+ pageviewId: this.pageviewId,
791
1044
  path: loc.pathname,
792
1045
  url,
793
1046
  search: loc.search || void 0,
@@ -991,6 +1244,12 @@ function captureAcquisition() {
991
1244
  result.utm_campaign = params.get("utm_campaign") ?? "";
992
1245
  result.utm_content = params.get("utm_content") ?? "";
993
1246
  result.utm_term = params.get("utm_term") ?? "";
1247
+ result.gclid = params.get("gclid") ?? "";
1248
+ result.fbclid = params.get("fbclid") ?? "";
1249
+ result.msclkid = params.get("msclkid") ?? "";
1250
+ result.ttclid = params.get("ttclid") ?? "";
1251
+ result.li_fat_id = params.get("li_fat_id") ?? "";
1252
+ result.twclid = params.get("twclid") ?? "";
994
1253
  } catch {
995
1254
  }
996
1255
  try {
@@ -1048,6 +1307,1025 @@ function safeJson(obj) {
1048
1307
  }
1049
1308
  }
1050
1309
 
1310
+ // src/event-validation.ts
1311
+ var DEFAULT_MAX_STRING = 1024;
1312
+ var DEFAULT_MAX_BYTES = 8 * 1024;
1313
+ var DEFAULT_MAX_DEPTH = 5;
1314
+ function validateEventProperties(input, options = {}) {
1315
+ const warnings = [];
1316
+ if (!input) return { properties: {}, warnings };
1317
+ const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1318
+ const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1319
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1320
+ const seen = /* @__PURE__ */ new WeakSet();
1321
+ const visit = (value, key, depth) => {
1322
+ if (depth > maxDepth) {
1323
+ warnings.push({ kind: "depth_exceeded", key });
1324
+ return { keep: true, value: "[depth-exceeded]" };
1325
+ }
1326
+ if (value === null) return { keep: true, value: null };
1327
+ const t = typeof value;
1328
+ if (t === "string") {
1329
+ const s = value;
1330
+ if (s.length > maxStringLength) {
1331
+ warnings.push({ kind: "truncated_string", key });
1332
+ return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
1333
+ }
1334
+ return { keep: true, value: s };
1335
+ }
1336
+ if (t === "number") {
1337
+ if (!Number.isFinite(value)) {
1338
+ warnings.push({ kind: "non_serialisable", key });
1339
+ return { keep: true, value: null };
1340
+ }
1341
+ return { keep: true, value };
1342
+ }
1343
+ if (t === "boolean") return { keep: true, value };
1344
+ if (t === "bigint") {
1345
+ warnings.push({ kind: "coerced_bigint", key });
1346
+ return { keep: true, value: value.toString() };
1347
+ }
1348
+ if (t === "function") {
1349
+ warnings.push({ kind: "dropped_function", key });
1350
+ return { keep: false, value: void 0 };
1351
+ }
1352
+ if (t === "symbol") {
1353
+ warnings.push({ kind: "dropped_symbol", key });
1354
+ return { keep: false, value: void 0 };
1355
+ }
1356
+ if (t === "undefined") {
1357
+ warnings.push({ kind: "dropped_undefined", key });
1358
+ return { keep: false, value: void 0 };
1359
+ }
1360
+ if (value instanceof Date) {
1361
+ warnings.push({ kind: "coerced_date", key });
1362
+ const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
1363
+ return { keep: true, value: iso };
1364
+ }
1365
+ if (value instanceof Error) {
1366
+ warnings.push({ kind: "coerced_error", key });
1367
+ return {
1368
+ keep: true,
1369
+ value: {
1370
+ name: value.name,
1371
+ message: value.message,
1372
+ stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
1373
+ }
1374
+ };
1375
+ }
1376
+ if (value instanceof Map) {
1377
+ warnings.push({ kind: "coerced_map", key });
1378
+ const obj = {};
1379
+ for (const [k, v] of value.entries()) {
1380
+ const subKey = typeof k === "string" ? k : String(k);
1381
+ const result = visit(v, `${key}.${subKey}`, depth + 1);
1382
+ if (result.keep) obj[subKey] = result.value;
1383
+ }
1384
+ return { keep: true, value: obj };
1385
+ }
1386
+ if (value instanceof Set) {
1387
+ warnings.push({ kind: "coerced_set", key });
1388
+ const arr = [];
1389
+ let i = 0;
1390
+ for (const v of value.values()) {
1391
+ const result = visit(v, `${key}[${i}]`, depth + 1);
1392
+ if (result.keep) arr.push(result.value);
1393
+ i++;
1394
+ }
1395
+ return { keep: true, value: arr };
1396
+ }
1397
+ if (Array.isArray(value)) {
1398
+ if (seen.has(value)) {
1399
+ warnings.push({ kind: "circular_reference", key });
1400
+ return { keep: true, value: "[circular]" };
1401
+ }
1402
+ seen.add(value);
1403
+ const out = [];
1404
+ for (let i = 0; i < value.length; i++) {
1405
+ const result = visit(value[i], `${key}[${i}]`, depth + 1);
1406
+ if (result.keep) out.push(result.value);
1407
+ }
1408
+ return { keep: true, value: out };
1409
+ }
1410
+ if (t === "object") {
1411
+ const obj = value;
1412
+ if (seen.has(obj)) {
1413
+ warnings.push({ kind: "circular_reference", key });
1414
+ return { keep: true, value: "[circular]" };
1415
+ }
1416
+ seen.add(obj);
1417
+ const out = {};
1418
+ for (const k of Object.keys(obj)) {
1419
+ const result = visit(obj[k], `${key}.${k}`, depth + 1);
1420
+ if (result.keep) out[k] = result.value;
1421
+ }
1422
+ return { keep: true, value: out };
1423
+ }
1424
+ warnings.push({ kind: "non_serialisable", key });
1425
+ try {
1426
+ return { keep: true, value: String(value) };
1427
+ } catch {
1428
+ return { keep: false, value: void 0 };
1429
+ }
1430
+ };
1431
+ const cleaned = {};
1432
+ for (const k of Object.keys(input)) {
1433
+ const result = visit(input[k], k, 0);
1434
+ if (result.keep) cleaned[k] = result.value;
1435
+ }
1436
+ const serialised = safeStringify(cleaned);
1437
+ if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
1438
+ warnings.push({ kind: "size_cap_exceeded", key: "*" });
1439
+ const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
1440
+ let currentSize = byteLength(serialised);
1441
+ for (const { k } of sizes) {
1442
+ if (currentSize <= maxBatchPropertyBytes) break;
1443
+ currentSize -= sizes.find((s) => s.k === k).size;
1444
+ delete cleaned[k];
1445
+ }
1446
+ cleaned.__truncated = true;
1447
+ }
1448
+ return { properties: cleaned, warnings };
1449
+ }
1450
+ function safeStringify(v) {
1451
+ try {
1452
+ return JSON.stringify(v) ?? null;
1453
+ } catch {
1454
+ return null;
1455
+ }
1456
+ }
1457
+ function byteLength(s) {
1458
+ if (typeof TextEncoder !== "undefined") {
1459
+ return new TextEncoder().encode(s).length;
1460
+ }
1461
+ return s.length * 4;
1462
+ }
1463
+
1464
+ // src/super-properties.ts
1465
+ var KEY_SUPER = "super_props";
1466
+ var KEY_GROUPS = "groups";
1467
+ var SuperPropertyStore = class {
1468
+ constructor(storage, prefix) {
1469
+ this.storage = storage;
1470
+ this.prefix = prefix;
1471
+ this.superProps = {};
1472
+ this.groups = {};
1473
+ this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
1474
+ this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
1475
+ }
1476
+ // ---------- super properties ----------
1477
+ /**
1478
+ * Merge new keys into the super-property bag. Returns a snapshot of
1479
+ * the resulting bag. Values that are `null` are deleted (Mixpanel
1480
+ * semantics — explicit null = "stop tracking this key").
1481
+ */
1482
+ register(props) {
1483
+ for (const [k, v] of Object.entries(props)) {
1484
+ if (v === null) {
1485
+ delete this.superProps[k];
1486
+ } else if (v !== void 0) {
1487
+ this.superProps[k] = v;
1488
+ }
1489
+ }
1490
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1491
+ return { ...this.superProps };
1492
+ }
1493
+ /** Remove a single super-property key. Idempotent. */
1494
+ unregister(key) {
1495
+ if (key in this.superProps) {
1496
+ delete this.superProps[key];
1497
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1498
+ }
1499
+ }
1500
+ /** Snapshot of the current super-property bag. */
1501
+ getSuperProperties() {
1502
+ return { ...this.superProps };
1503
+ }
1504
+ // ---------- groups ----------
1505
+ /**
1506
+ * Set a group membership. Passing `id: null` clears the membership
1507
+ * for that group type — the SDK stops attaching it to events.
1508
+ */
1509
+ setGroup(type, id, traits) {
1510
+ if (id === null) {
1511
+ delete this.groups[type];
1512
+ } else {
1513
+ this.groups[type] = traits !== void 0 ? { id, traits } : { id };
1514
+ }
1515
+ writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
1516
+ }
1517
+ /**
1518
+ * Snapshot of the current groups map, keyed by group type. Returned
1519
+ * shape mirrors what the SDK attaches to every event as
1520
+ * `$groups.{type}`. The `traits` sub-object is the most-recent
1521
+ * traits payload passed to `setGroup` for that type; null when none.
1522
+ */
1523
+ getGroups() {
1524
+ return JSON.parse(JSON.stringify(this.groups));
1525
+ }
1526
+ /**
1527
+ * The flat `{ type: id }` projection used for event-attachment. Stable
1528
+ * for fast every-event merge — we don't want to JSON-clone on each
1529
+ * track() call.
1530
+ */
1531
+ getGroupIds() {
1532
+ const out = {};
1533
+ for (const [type, info] of Object.entries(this.groups)) {
1534
+ out[type] = info.id;
1535
+ }
1536
+ return out;
1537
+ }
1538
+ /** Wipe both bags. Called by Crossdeck.reset() (logout). */
1539
+ clear() {
1540
+ this.superProps = {};
1541
+ this.groups = {};
1542
+ try {
1543
+ this.storage.removeItem(this.prefix + KEY_SUPER);
1544
+ } catch {
1545
+ }
1546
+ try {
1547
+ this.storage.removeItem(this.prefix + KEY_GROUPS);
1548
+ } catch {
1549
+ }
1550
+ }
1551
+ };
1552
+ function readJson(storage, key) {
1553
+ let raw;
1554
+ try {
1555
+ raw = storage.getItem(key);
1556
+ } catch {
1557
+ return null;
1558
+ }
1559
+ if (!raw) return null;
1560
+ try {
1561
+ return JSON.parse(raw);
1562
+ } catch {
1563
+ return null;
1564
+ }
1565
+ }
1566
+ function writeJson(storage, key, value) {
1567
+ try {
1568
+ storage.setItem(key, JSON.stringify(value));
1569
+ } catch {
1570
+ }
1571
+ }
1572
+
1573
+ // src/web-vitals.ts
1574
+ var WebVitalsTracker = class {
1575
+ constructor(cfg, report) {
1576
+ this.cfg = cfg;
1577
+ this.report = report;
1578
+ this.observers = [];
1579
+ this.flushed = /* @__PURE__ */ new Set();
1580
+ this.cls = 0;
1581
+ this.clsEntries = [];
1582
+ this.inp = 0;
1583
+ this.cleanups = [];
1584
+ }
1585
+ install() {
1586
+ if (!this.cfg.enabled) return;
1587
+ if (typeof PerformanceObserver === "undefined") return;
1588
+ if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
1589
+ const doc = globalThis.document;
1590
+ try {
1591
+ const navObserver = new PerformanceObserver((list) => {
1592
+ for (const entry of list.getEntries()) {
1593
+ const e = entry;
1594
+ if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
1595
+ this.flushed.add("ttfb");
1596
+ this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
1597
+ }
1598
+ }
1599
+ });
1600
+ navObserver.observe({ type: "navigation", buffered: true });
1601
+ this.observers.push(navObserver);
1602
+ } catch {
1603
+ }
1604
+ try {
1605
+ const paintObserver = new PerformanceObserver((list) => {
1606
+ for (const entry of list.getEntries()) {
1607
+ if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
1608
+ this.flushed.add("fcp");
1609
+ this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
1610
+ }
1611
+ }
1612
+ });
1613
+ paintObserver.observe({ type: "paint", buffered: true });
1614
+ this.observers.push(paintObserver);
1615
+ } catch {
1616
+ }
1617
+ let lcpValue = 0;
1618
+ try {
1619
+ const lcpObserver = new PerformanceObserver((list) => {
1620
+ const entries = list.getEntries();
1621
+ const last = entries[entries.length - 1];
1622
+ if (last) lcpValue = last.startTime;
1623
+ });
1624
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
1625
+ this.observers.push(lcpObserver);
1626
+ } catch {
1627
+ }
1628
+ try {
1629
+ const clsObserver = new PerformanceObserver((list) => {
1630
+ for (const entry of list.getEntries()) {
1631
+ const e = entry;
1632
+ if (typeof e.value === "number" && !e.hadRecentInput) {
1633
+ this.cls += e.value;
1634
+ this.clsEntries.push(entry);
1635
+ }
1636
+ }
1637
+ });
1638
+ clsObserver.observe({ type: "layout-shift", buffered: true });
1639
+ this.observers.push(clsObserver);
1640
+ } catch {
1641
+ }
1642
+ try {
1643
+ const eventObserver = new PerformanceObserver((list) => {
1644
+ for (const entry of list.getEntries()) {
1645
+ const e = entry;
1646
+ if (e.interactionId && e.duration > this.inp) {
1647
+ this.inp = e.duration;
1648
+ }
1649
+ }
1650
+ });
1651
+ try {
1652
+ eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
1653
+ } catch {
1654
+ eventObserver.observe({ type: "first-input", buffered: true });
1655
+ }
1656
+ this.observers.push(eventObserver);
1657
+ } catch {
1658
+ }
1659
+ const flush = () => {
1660
+ if (lcpValue > 0 && !this.flushed.has("lcp")) {
1661
+ this.flushed.add("lcp");
1662
+ this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
1663
+ }
1664
+ if (this.cls > 0 && !this.flushed.has("cls")) {
1665
+ this.flushed.add("cls");
1666
+ this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
1667
+ }
1668
+ if (this.inp > 0 && !this.flushed.has("inp")) {
1669
+ this.flushed.add("inp");
1670
+ this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
1671
+ }
1672
+ };
1673
+ const onHidden = () => {
1674
+ if (doc.visibilityState === "hidden") flush();
1675
+ };
1676
+ doc.addEventListener("visibilitychange", onHidden);
1677
+ globalThis.window.addEventListener("pagehide", flush);
1678
+ this.cleanups.push(() => {
1679
+ doc.removeEventListener("visibilitychange", onHidden);
1680
+ globalThis.window.removeEventListener("pagehide", flush);
1681
+ });
1682
+ }
1683
+ uninstall() {
1684
+ for (const o of this.observers) {
1685
+ try {
1686
+ o.disconnect();
1687
+ } catch {
1688
+ }
1689
+ }
1690
+ this.observers = [];
1691
+ for (const fn of this.cleanups.splice(0)) {
1692
+ try {
1693
+ fn();
1694
+ } catch {
1695
+ }
1696
+ }
1697
+ }
1698
+ };
1699
+
1700
+ // src/consent.ts
1701
+ var ALL_GRANTED = {
1702
+ analytics: true,
1703
+ marketing: true,
1704
+ errors: true
1705
+ };
1706
+ var ConsentManager = class {
1707
+ constructor(options) {
1708
+ this.state = { ...ALL_GRANTED };
1709
+ this.dntDenied = false;
1710
+ if (options?.respectDnt && this.detectDnt()) {
1711
+ this.dntDenied = true;
1712
+ this.state = { analytics: false, marketing: false, errors: false };
1713
+ }
1714
+ }
1715
+ /**
1716
+ * Merge new dimensions onto the current state. Returns the resulting
1717
+ * snapshot. DNT-derived denies cannot be flipped back on by a `set`
1718
+ * call — once the browser says "don't track", we don't track even if
1719
+ * the developer code disagrees. That's the contract.
1720
+ */
1721
+ set(partial) {
1722
+ if (this.dntDenied) return { ...this.state };
1723
+ for (const k of Object.keys(partial)) {
1724
+ const v = partial[k];
1725
+ if (typeof v === "boolean") this.state[k] = v;
1726
+ }
1727
+ return { ...this.state };
1728
+ }
1729
+ /** Snapshot of the current state. */
1730
+ get() {
1731
+ return { ...this.state };
1732
+ }
1733
+ /** Convenience getters for hot paths. */
1734
+ get analytics() {
1735
+ return this.state.analytics;
1736
+ }
1737
+ get marketing() {
1738
+ return this.state.marketing;
1739
+ }
1740
+ get errors() {
1741
+ return this.state.errors;
1742
+ }
1743
+ /** True iff the constructor detected and applied DNT. */
1744
+ get isDntDenied() {
1745
+ return this.dntDenied;
1746
+ }
1747
+ detectDnt() {
1748
+ try {
1749
+ const nav = globalThis.navigator;
1750
+ if (!nav) return false;
1751
+ const sources = [
1752
+ nav.doNotTrack,
1753
+ nav.msDoNotTrack,
1754
+ globalThis.doNotTrack
1755
+ ];
1756
+ return sources.some((v) => v === "1" || v === "yes");
1757
+ } catch {
1758
+ return false;
1759
+ }
1760
+ }
1761
+ };
1762
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1763
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1764
+ var REPLACEMENT_EMAIL = "[email]";
1765
+ var REPLACEMENT_CARD = "[card]";
1766
+ function scrubPii(value) {
1767
+ if (!value) return value;
1768
+ let out = value;
1769
+ if (EMAIL_PATTERN.test(out)) {
1770
+ out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1771
+ }
1772
+ EMAIL_PATTERN.lastIndex = 0;
1773
+ if (CARD_PATTERN.test(out)) {
1774
+ out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1775
+ }
1776
+ CARD_PATTERN.lastIndex = 0;
1777
+ return out;
1778
+ }
1779
+ function scrubPiiFromProperties(properties) {
1780
+ const out = {};
1781
+ for (const k of Object.keys(properties)) {
1782
+ const v = properties[k];
1783
+ if (typeof v === "string") {
1784
+ out[k] = scrubPii(v);
1785
+ } else if (Array.isArray(v)) {
1786
+ out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1787
+ } else {
1788
+ out[k] = v;
1789
+ }
1790
+ }
1791
+ return out;
1792
+ }
1793
+
1794
+ // src/breadcrumbs.ts
1795
+ var BreadcrumbBuffer = class {
1796
+ constructor(maxSize = 50) {
1797
+ this.maxSize = maxSize;
1798
+ this.items = [];
1799
+ }
1800
+ add(crumb) {
1801
+ this.items.push(crumb);
1802
+ if (this.items.length > this.maxSize) {
1803
+ this.items.shift();
1804
+ }
1805
+ }
1806
+ /** Defensive copy — caller can read freely without mutating buffer state. */
1807
+ snapshot() {
1808
+ return this.items.slice();
1809
+ }
1810
+ clear() {
1811
+ this.items = [];
1812
+ }
1813
+ get size() {
1814
+ return this.items.length;
1815
+ }
1816
+ };
1817
+
1818
+ // src/stack-parser.ts
1819
+ function parseStack(stack) {
1820
+ if (!stack || typeof stack !== "string") return [];
1821
+ const lines = stack.split("\n");
1822
+ const frames = [];
1823
+ for (const line of lines) {
1824
+ const trimmed = line.trim();
1825
+ if (!trimmed) continue;
1826
+ const frame = parseLine(trimmed);
1827
+ if (frame) frames.push(frame);
1828
+ }
1829
+ return frames;
1830
+ }
1831
+ function parseLine(line) {
1832
+ let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
1833
+ if (m) {
1834
+ return buildFrame({
1835
+ function: m[1],
1836
+ filename: m[2],
1837
+ lineno: parseInt(m[3], 10),
1838
+ colno: parseInt(m[4], 10),
1839
+ raw: line
1840
+ });
1841
+ }
1842
+ m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
1843
+ if (m) {
1844
+ return buildFrame({
1845
+ function: "?",
1846
+ filename: m[1],
1847
+ lineno: parseInt(m[2], 10),
1848
+ colno: parseInt(m[3], 10),
1849
+ raw: line
1850
+ });
1851
+ }
1852
+ m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
1853
+ if (m) {
1854
+ return buildFrame({
1855
+ function: m[1] || "?",
1856
+ filename: m[2],
1857
+ lineno: parseInt(m[3], 10),
1858
+ colno: parseInt(m[4], 10),
1859
+ raw: line
1860
+ });
1861
+ }
1862
+ if (/^\w*Error/.test(line) || !line.includes(":")) {
1863
+ return null;
1864
+ }
1865
+ return {
1866
+ function: "?",
1867
+ filename: "",
1868
+ lineno: 0,
1869
+ colno: 0,
1870
+ in_app: true,
1871
+ raw: line
1872
+ };
1873
+ }
1874
+ function buildFrame(input) {
1875
+ return {
1876
+ function: input.function || "?",
1877
+ filename: input.filename,
1878
+ lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
1879
+ colno: Number.isFinite(input.colno) ? input.colno : 0,
1880
+ in_app: isInAppFrame(input.filename),
1881
+ raw: input.raw
1882
+ };
1883
+ }
1884
+ function isInAppFrame(filename) {
1885
+ if (!filename) return true;
1886
+ if (/^(?:chrome|moz|safari|webkit)-extension:\/\//.test(filename)) return false;
1887
+ if (/\bcdn\.jsdelivr\.net\b/.test(filename)) return false;
1888
+ if (/\bunpkg\.com\b/.test(filename)) return false;
1889
+ if (/\bgoogletagmanager\.com\b/.test(filename)) return false;
1890
+ if (/\bgoogle-analytics\.com\b/.test(filename)) return false;
1891
+ if (/\b@cross-deck\/web\b/.test(filename)) return false;
1892
+ if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
1893
+ return true;
1894
+ }
1895
+ function fingerprintError(message, frames) {
1896
+ const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
1897
+ const key = [
1898
+ (message || "").slice(0, 200),
1899
+ ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
1900
+ ].join("|");
1901
+ return djb2Hex(key);
1902
+ }
1903
+ function djb2Hex(input) {
1904
+ let h = 5381;
1905
+ for (let i = 0; i < input.length; i++) {
1906
+ h = (h << 5) + h + input.charCodeAt(i) | 0;
1907
+ }
1908
+ return (h >>> 0).toString(16).padStart(8, "0");
1909
+ }
1910
+
1911
+ // src/error-capture.ts
1912
+ var DEFAULT_ERROR_CAPTURE = {
1913
+ enabled: true,
1914
+ onError: true,
1915
+ onUnhandledRejection: true,
1916
+ wrapFetch: true,
1917
+ wrapXhr: true,
1918
+ captureConsole: false,
1919
+ ignoreErrors: [
1920
+ // Classic browser noise. These aren't application bugs.
1921
+ "ResizeObserver loop limit exceeded",
1922
+ "ResizeObserver loop completed with undelivered notifications",
1923
+ "Non-Error promise rejection captured",
1924
+ // Cross-origin script errors that the browser strips — no info,
1925
+ // no way to act on them, just noise.
1926
+ "Script error.",
1927
+ "Script error"
1928
+ ],
1929
+ allowUrls: [],
1930
+ denyUrls: [
1931
+ // Common third-party extensions that pollute error streams.
1932
+ /^chrome-extension:\/\//,
1933
+ /^moz-extension:\/\//,
1934
+ /^safari-extension:\/\//,
1935
+ /^webkit-extension:\/\//,
1936
+ /^safari-web-extension:\/\//
1937
+ ],
1938
+ sampleRate: 1,
1939
+ maxPerFingerprintPerMinute: 5,
1940
+ maxPerSession: 100
1941
+ };
1942
+ var ErrorTracker = class {
1943
+ constructor(opts) {
1944
+ this.opts = opts;
1945
+ this.installed = false;
1946
+ this.cleanups = [];
1947
+ this._reporting = false;
1948
+ this.sessionCount = 0;
1949
+ this.fingerprintWindow = /* @__PURE__ */ new Map();
1950
+ }
1951
+ install() {
1952
+ if (this.installed) return;
1953
+ if (!this.opts.config.enabled) return;
1954
+ if (typeof globalThis === "undefined" || !("window" in globalThis)) return;
1955
+ const w = globalThis.window;
1956
+ if (this.opts.config.onError) this.installOnErrorListener(w);
1957
+ if (this.opts.config.onUnhandledRejection) this.installRejectionListener(w);
1958
+ if (this.opts.config.wrapFetch) this.installFetchWrap(w);
1959
+ if (this.opts.config.wrapXhr) this.installXhrWrap(w);
1960
+ if (this.opts.config.captureConsole) this.installConsoleWrap();
1961
+ this.installed = true;
1962
+ }
1963
+ uninstall() {
1964
+ for (const fn of this.cleanups.splice(0)) {
1965
+ try {
1966
+ fn();
1967
+ } catch {
1968
+ }
1969
+ }
1970
+ this.installed = false;
1971
+ }
1972
+ /**
1973
+ * Manual API. Either an Error instance or any unknown value (we
1974
+ * coerce). Returns silently — never throws.
1975
+ */
1976
+ captureError(error, options) {
1977
+ if (!this.opts.isConsented()) return;
1978
+ try {
1979
+ const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
1980
+ if (options?.context) captured.context = { ...captured.context, ...options.context };
1981
+ if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
1982
+ this.maybeReport(captured);
1983
+ } catch {
1984
+ }
1985
+ }
1986
+ /**
1987
+ * Capture a non-error event as an issue. For "we hit a soft-warning
1988
+ * code path" / "deprecated API used" kinds of signals. Pairs with
1989
+ * Sentry's captureMessage().
1990
+ */
1991
+ captureMessage(message, level = "info") {
1992
+ if (!this.opts.isConsented()) return;
1993
+ try {
1994
+ const captured = {
1995
+ timestamp: Date.now(),
1996
+ kind: "error.message",
1997
+ level,
1998
+ message,
1999
+ errorType: null,
2000
+ frames: [],
2001
+ rawStack: null,
2002
+ filename: null,
2003
+ lineno: null,
2004
+ colno: null,
2005
+ fingerprint: fingerprintError(message, []),
2006
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2007
+ context: this.opts.getContext(),
2008
+ tags: this.opts.getTags()
2009
+ };
2010
+ this.maybeReport(captured);
2011
+ } catch {
2012
+ }
2013
+ }
2014
+ // ============================================================
2015
+ // Listener installation
2016
+ // ============================================================
2017
+ installOnErrorListener(w) {
2018
+ const handler = (event) => {
2019
+ if (this._reporting) return;
2020
+ if (!this.opts.isConsented()) return;
2021
+ try {
2022
+ this._reporting = true;
2023
+ const captured = this.buildFromErrorEvent(event);
2024
+ this.maybeReport(captured);
2025
+ } catch {
2026
+ } finally {
2027
+ this._reporting = false;
2028
+ }
2029
+ };
2030
+ w.addEventListener("error", handler, true);
2031
+ this.cleanups.push(() => w.removeEventListener("error", handler, true));
2032
+ }
2033
+ installRejectionListener(w) {
2034
+ const handler = (event) => {
2035
+ if (this._reporting) return;
2036
+ if (!this.opts.isConsented()) return;
2037
+ try {
2038
+ this._reporting = true;
2039
+ const captured = this.buildFromUnknown(
2040
+ event.reason,
2041
+ "error.unhandledrejection",
2042
+ "error"
2043
+ );
2044
+ this.maybeReport(captured);
2045
+ } catch {
2046
+ } finally {
2047
+ this._reporting = false;
2048
+ }
2049
+ };
2050
+ w.addEventListener("unhandledrejection", handler);
2051
+ this.cleanups.push(() => w.removeEventListener("unhandledrejection", handler));
2052
+ }
2053
+ /**
2054
+ * Wrap fetch() so failed HTTP requests get auto-captured. We do NOT
2055
+ * call this an "error" for 4xx (those are often expected — auth
2056
+ * required, validation failed). Only 5xx + network failures fire.
2057
+ */
2058
+ installFetchWrap(w) {
2059
+ const origFetch = w.fetch?.bind(w);
2060
+ if (!origFetch) return;
2061
+ const wrapped = async (...args) => {
2062
+ const input = args[0];
2063
+ const init = args[1] ?? {};
2064
+ const url = typeof input === "string" ? input : input?.url ?? "";
2065
+ const method = (init.method || "GET").toUpperCase();
2066
+ const start = Date.now();
2067
+ this.opts.breadcrumbs.add({
2068
+ timestamp: start,
2069
+ category: "http",
2070
+ message: `${method} ${url}`,
2071
+ data: { url, method }
2072
+ });
2073
+ try {
2074
+ const response = await origFetch(...args);
2075
+ if (response.status >= 500 && this.opts.isConsented()) {
2076
+ if (!url.includes("api.cross-deck.com")) {
2077
+ this.captureHttp({
2078
+ url,
2079
+ method,
2080
+ status: response.status,
2081
+ statusText: response.statusText
2082
+ });
2083
+ }
2084
+ }
2085
+ return response;
2086
+ } catch (err) {
2087
+ if (this.opts.isConsented() && !url.includes("api.cross-deck.com")) {
2088
+ this.captureHttp({
2089
+ url,
2090
+ method,
2091
+ status: 0,
2092
+ statusText: err instanceof Error ? err.message : "network error"
2093
+ });
2094
+ }
2095
+ throw err;
2096
+ }
2097
+ };
2098
+ w.fetch = wrapped;
2099
+ this.cleanups.push(() => {
2100
+ if (w.fetch === wrapped) w.fetch = origFetch;
2101
+ });
2102
+ }
2103
+ /**
2104
+ * Wrap XMLHttpRequest for legacy consumers (jQuery $.ajax under the
2105
+ * hood, older bundlers). Same capture semantics as fetch.
2106
+ */
2107
+ installXhrWrap(w) {
2108
+ const xhrCtor = w.XMLHttpRequest;
2109
+ const proto = xhrCtor?.prototype;
2110
+ if (!proto) return;
2111
+ const origOpen = proto.open;
2112
+ const origSend = proto.send;
2113
+ const tracker = this;
2114
+ proto.open = function(method, url, ...rest) {
2115
+ this._cdMethod = method;
2116
+ this._cdUrl = url;
2117
+ return origOpen.apply(this, [method, url, ...rest]);
2118
+ };
2119
+ proto.send = function(body) {
2120
+ const xhr = this;
2121
+ const onLoad = () => {
2122
+ try {
2123
+ if (xhr.status >= 500 && tracker.opts.isConsented()) {
2124
+ const url = xhr._cdUrl ?? "";
2125
+ if (!url.includes("api.cross-deck.com")) {
2126
+ tracker.captureHttp({
2127
+ url,
2128
+ method: (xhr._cdMethod ?? "GET").toUpperCase(),
2129
+ status: xhr.status,
2130
+ statusText: xhr.statusText
2131
+ });
2132
+ }
2133
+ }
2134
+ } catch {
2135
+ }
2136
+ };
2137
+ xhr.addEventListener("loadend", onLoad);
2138
+ return origSend.apply(this, [body ?? null]);
2139
+ };
2140
+ this.cleanups.push(() => {
2141
+ proto.open = origOpen;
2142
+ proto.send = origSend;
2143
+ });
2144
+ }
2145
+ installConsoleWrap() {
2146
+ const console2 = globalThis.console;
2147
+ if (!console2) return;
2148
+ const orig = console2.error.bind(console2);
2149
+ console2.error = (...args) => {
2150
+ try {
2151
+ if (this.opts.isConsented()) {
2152
+ this.captureMessage(args.map((a) => safeStringify2(a)).join(" "), "error");
2153
+ }
2154
+ } catch {
2155
+ }
2156
+ return orig(...args);
2157
+ };
2158
+ this.cleanups.push(() => {
2159
+ console2.error = orig;
2160
+ });
2161
+ }
2162
+ // ============================================================
2163
+ // Builders
2164
+ // ============================================================
2165
+ buildFromErrorEvent(event) {
2166
+ const err = event.error;
2167
+ const message = event.message || (err instanceof Error ? err.message : "Unknown error");
2168
+ const stack = err instanceof Error ? err.stack ?? null : null;
2169
+ const frames = parseStack(stack);
2170
+ return {
2171
+ timestamp: Date.now(),
2172
+ kind: "error.unhandled",
2173
+ level: "error",
2174
+ message: String(message).slice(0, 1024),
2175
+ errorType: err instanceof Error ? err.name : null,
2176
+ frames,
2177
+ rawStack: stack,
2178
+ filename: event.filename || null,
2179
+ lineno: typeof event.lineno === "number" ? event.lineno : null,
2180
+ colno: typeof event.colno === "number" ? event.colno : null,
2181
+ fingerprint: fingerprintError(message, frames),
2182
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2183
+ context: this.opts.getContext(),
2184
+ tags: this.opts.getTags()
2185
+ };
2186
+ }
2187
+ buildFromUnknown(err, kind, level) {
2188
+ if (err instanceof Error) {
2189
+ const frames = parseStack(err.stack);
2190
+ return {
2191
+ timestamp: Date.now(),
2192
+ kind,
2193
+ level,
2194
+ message: String(err.message).slice(0, 1024),
2195
+ errorType: err.name,
2196
+ frames,
2197
+ rawStack: err.stack ?? null,
2198
+ filename: null,
2199
+ lineno: null,
2200
+ colno: null,
2201
+ fingerprint: fingerprintError(err.message, frames),
2202
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2203
+ context: this.opts.getContext(),
2204
+ tags: this.opts.getTags()
2205
+ };
2206
+ }
2207
+ const message = safeStringify2(err).slice(0, 1024);
2208
+ return {
2209
+ timestamp: Date.now(),
2210
+ kind,
2211
+ level,
2212
+ message,
2213
+ errorType: null,
2214
+ frames: [],
2215
+ rawStack: null,
2216
+ filename: null,
2217
+ lineno: null,
2218
+ colno: null,
2219
+ fingerprint: fingerprintError(message, []),
2220
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2221
+ context: this.opts.getContext(),
2222
+ tags: this.opts.getTags()
2223
+ };
2224
+ }
2225
+ captureHttp(info) {
2226
+ try {
2227
+ const message = `HTTP ${info.status} ${info.method} ${info.url}`;
2228
+ const captured = {
2229
+ timestamp: Date.now(),
2230
+ kind: "error.http",
2231
+ level: "error",
2232
+ message,
2233
+ errorType: `HTTPError`,
2234
+ frames: [],
2235
+ rawStack: null,
2236
+ filename: info.url,
2237
+ lineno: null,
2238
+ colno: null,
2239
+ fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
2240
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2241
+ context: this.opts.getContext(),
2242
+ tags: this.opts.getTags(),
2243
+ http: info
2244
+ };
2245
+ this.maybeReport(captured);
2246
+ } catch {
2247
+ }
2248
+ }
2249
+ // ============================================================
2250
+ // Reporting pipeline — filter / sample / rate-limit / send
2251
+ // ============================================================
2252
+ maybeReport(err) {
2253
+ if (this.sessionCount >= this.opts.config.maxPerSession) return;
2254
+ if (this.shouldIgnore(err)) return;
2255
+ if (!this.passesUrlGate(err)) return;
2256
+ if (!this.passesSample(err)) return;
2257
+ if (!this.passesRateLimit(err)) return;
2258
+ let finalErr = err;
2259
+ if (this.opts.beforeSend) {
2260
+ try {
2261
+ finalErr = this.opts.beforeSend(err);
2262
+ } catch {
2263
+ finalErr = err;
2264
+ }
2265
+ if (!finalErr) return;
2266
+ }
2267
+ this.sessionCount += 1;
2268
+ try {
2269
+ this.opts.report(finalErr);
2270
+ } catch {
2271
+ }
2272
+ }
2273
+ shouldIgnore(err) {
2274
+ for (const pat of this.opts.config.ignoreErrors) {
2275
+ if (typeof pat === "string" && err.message.includes(pat)) return true;
2276
+ if (pat instanceof RegExp && pat.test(err.message)) return true;
2277
+ }
2278
+ return false;
2279
+ }
2280
+ passesUrlGate(err) {
2281
+ const topFrame = err.frames.find((f) => f.filename) ?? null;
2282
+ const url = topFrame?.filename ?? err.filename ?? "";
2283
+ if (!url) return true;
2284
+ for (const pat of this.opts.config.denyUrls) {
2285
+ if (typeof pat === "string" && url.includes(pat)) return false;
2286
+ if (pat instanceof RegExp && pat.test(url)) return false;
2287
+ }
2288
+ if (this.opts.config.allowUrls.length > 0) {
2289
+ for (const pat of this.opts.config.allowUrls) {
2290
+ if (typeof pat === "string" && url.includes(pat)) return true;
2291
+ if (pat instanceof RegExp && pat.test(url)) return true;
2292
+ }
2293
+ return false;
2294
+ }
2295
+ return true;
2296
+ }
2297
+ passesSample(err) {
2298
+ if (this.opts.config.sampleRate >= 1) return true;
2299
+ if (this.opts.config.sampleRate <= 0) return false;
2300
+ const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
2301
+ return hashByte / 255 < this.opts.config.sampleRate;
2302
+ }
2303
+ passesRateLimit(err) {
2304
+ const windowMs = 6e4;
2305
+ const now = Date.now();
2306
+ const max = this.opts.config.maxPerFingerprintPerMinute;
2307
+ const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
2308
+ const fresh = arr.filter((t) => now - t < windowMs);
2309
+ if (fresh.length >= max) {
2310
+ this.fingerprintWindow.set(err.fingerprint, fresh);
2311
+ return false;
2312
+ }
2313
+ fresh.push(now);
2314
+ this.fingerprintWindow.set(err.fingerprint, fresh);
2315
+ return true;
2316
+ }
2317
+ };
2318
+ function safeStringify2(v) {
2319
+ if (v == null) return String(v);
2320
+ if (typeof v === "string") return v;
2321
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
2322
+ try {
2323
+ return JSON.stringify(v);
2324
+ } catch {
2325
+ return Object.prototype.toString.call(v);
2326
+ }
2327
+ }
2328
+
1051
2329
  // src/crossdeck.ts
1052
2330
  var CrossdeckClient = class {
1053
2331
  constructor() {
@@ -1137,6 +2415,13 @@ var CrossdeckClient = class {
1137
2415
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
1138
2416
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
1139
2417
  const entitlements = new EntitlementCache();
2418
+ const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
2419
+ if (persistentEvents) {
2420
+ debug.emit(
2421
+ "sdk.queue_restored",
2422
+ "Restored persisted event queue from a prior session."
2423
+ );
2424
+ }
1140
2425
  const events = new EventQueue({
1141
2426
  http,
1142
2427
  batchSize: opts.eventFlushBatchSize,
@@ -1146,26 +2431,57 @@ var CrossdeckClient = class {
1146
2431
  environment: opts.environment,
1147
2432
  sdk: { name: SDK_NAME, version: opts.sdkVersion }
1148
2433
  }),
2434
+ persistentStore: persistentEvents ?? void 0,
1149
2435
  onFirstFlushSuccess: () => {
1150
2436
  debug.emit(
1151
2437
  "sdk.first_event_sent",
1152
2438
  "First telemetry event received. View it in Live Events.",
1153
2439
  { appId: opts.appId, environment: opts.environment }
1154
2440
  );
2441
+ },
2442
+ onRetryScheduled: (info) => {
2443
+ debug.emit(
2444
+ "sdk.flush_retry_scheduled",
2445
+ `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2446
+ { ...info }
2447
+ );
1155
2448
  }
1156
2449
  });
1157
2450
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
2451
+ const superProps = new SuperPropertyStore(
2452
+ persistIdentity ? effectiveStorage : new MemoryStorage(),
2453
+ opts.storagePrefix
2454
+ );
2455
+ const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
2456
+ if (consent.isDntDenied) {
2457
+ debug.emit(
2458
+ "sdk.consent_dnt_applied",
2459
+ "Do Not Track detected \u2014 all tracking dimensions denied at init."
2460
+ );
2461
+ }
2462
+ const breadcrumbs = new BreadcrumbBuffer(50);
1158
2463
  this.state = {
1159
2464
  http,
1160
2465
  identity,
1161
2466
  entitlements,
1162
2467
  events,
1163
2468
  autoTracker: null,
2469
+ webVitals: null,
2470
+ errors: null,
2471
+ breadcrumbs,
2472
+ errorContext: {},
2473
+ errorTags: {},
2474
+ errorBeforeSend: null,
2475
+ superProps,
2476
+ consent,
2477
+ scrubPii: options.scrubPii !== false,
1164
2478
  deviceInfo,
1165
2479
  options: opts,
1166
2480
  debug,
1167
2481
  developerUserId: null,
1168
- uninstallUnloadFlush: null
2482
+ uninstallUnloadFlush: null,
2483
+ lastServerTime: null,
2484
+ lastClientTime: null
1169
2485
  };
1170
2486
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
1171
2487
  appId: opts.appId,
@@ -1180,6 +2496,27 @@ var CrossdeckClient = class {
1180
2496
  this.state.autoTracker = tracker;
1181
2497
  tracker.install();
1182
2498
  }
2499
+ if (autoTrack.webVitals) {
2500
+ const vitals = new WebVitalsTracker(
2501
+ { enabled: true },
2502
+ (name, properties) => this.track(name, properties)
2503
+ );
2504
+ this.state.webVitals = vitals;
2505
+ vitals.install();
2506
+ }
2507
+ if (autoTrack.errors) {
2508
+ const tracker = new ErrorTracker({
2509
+ config: { ...DEFAULT_ERROR_CAPTURE, enabled: true },
2510
+ breadcrumbs,
2511
+ report: (err) => this.reportError(err),
2512
+ getContext: () => ({ ...this.state.errorContext }),
2513
+ getTags: () => ({ ...this.state.errorTags }),
2514
+ beforeSend: this.state.errorBeforeSend,
2515
+ isConsented: () => this.state.consent.errors
2516
+ });
2517
+ this.state.errors = tracker;
2518
+ tracker.install();
2519
+ }
1183
2520
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
1184
2521
  void this.flush({ keepalive: true }).catch(() => void 0);
1185
2522
  });
@@ -1203,8 +2540,19 @@ var CrossdeckClient = class {
1203
2540
  /**
1204
2541
  * Link the anonymous device to a developer-supplied user ID. Cache
1205
2542
  * the resolved Crossdeck customer for follow-up calls.
2543
+ *
2544
+ * v0.9.0+ accepts an optional `traits` bag — profile data (name,
2545
+ * plan, signupDate, role) persisted on the Crossdeck customer record
2546
+ * and queryable from dashboards. Traits are sanitised through the
2547
+ * same validator that gates `track()` properties, so a `{ avatar:
2548
+ * <File>, onSave: () => {} }` payload can't corrupt the alias call.
2549
+ *
2550
+ * Crossdeck.identify("user_847", {
2551
+ * email: "wes@pinet.co.za",
2552
+ * traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
2553
+ * });
1206
2554
  */
1207
- async identify(userId, _options) {
2555
+ async identify(userId, options) {
1208
2556
  const s = this.requireStarted();
1209
2557
  if (!userId) {
1210
2558
  throw new CrossdeckError({
@@ -1213,13 +2561,265 @@ var CrossdeckClient = class {
1213
2561
  message: "identify(userId) requires a non-empty userId."
1214
2562
  });
1215
2563
  }
2564
+ if (!s.consent.analytics) {
2565
+ s.debug.emit(
2566
+ "sdk.consent_denied",
2567
+ `identify() skipped \u2014 consent denied for analytics.`
2568
+ );
2569
+ return {
2570
+ object: "alias_result",
2571
+ crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
2572
+ linked: [],
2573
+ mergePending: false,
2574
+ env: s.options.environment
2575
+ };
2576
+ }
2577
+ const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
2578
+ const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
2579
+ if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
2580
+ for (const w of traitsValidation.warnings) {
2581
+ s.debug.emit(
2582
+ "sdk.property_coerced",
2583
+ `identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2584
+ { key: w.key, kind: w.kind }
2585
+ );
2586
+ }
2587
+ }
2588
+ const body = {
2589
+ userId,
2590
+ anonymousId: s.identity.anonymousId
2591
+ };
2592
+ if (options?.email) body.email = options.email;
2593
+ if (traits) body.traits = traits;
1216
2594
  const result = await s.http.request("POST", "/identity/alias", {
1217
- body: { userId, anonymousId: s.identity.anonymousId }
2595
+ body
1218
2596
  });
1219
2597
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
1220
2598
  s.developerUserId = userId;
1221
2599
  return result;
1222
2600
  }
2601
+ /**
2602
+ * Register super-properties — Mixpanel pattern. Once set, every
2603
+ * subsequent event of THIS SDK instance carries these keys on its
2604
+ * properties bag automatically.
2605
+ *
2606
+ * Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
2607
+ * Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
2608
+ *
2609
+ * Values that are `null` are deleted (the explicit "stop tracking
2610
+ * this key" idiom). Returns the resulting bag.
2611
+ *
2612
+ * Sanitised through `validateEventProperties` so a `{ avatar: File }`
2613
+ * payload can't poison the queue at flush time.
2614
+ */
2615
+ register(properties) {
2616
+ const s = this.requireStarted();
2617
+ const validation = validateEventProperties(properties);
2618
+ return s.superProps.register(validation.properties);
2619
+ }
2620
+ /** Remove a single super-property key. Idempotent. */
2621
+ unregister(key) {
2622
+ const s = this.requireStarted();
2623
+ s.superProps.unregister(key);
2624
+ }
2625
+ /** Snapshot of the current super-property bag. */
2626
+ getSuperProperties() {
2627
+ if (!this.state) return {};
2628
+ return this.state.superProps.getSuperProperties();
2629
+ }
2630
+ /**
2631
+ * Associate the current user with a group (org, team, account, etc.).
2632
+ * Mixpanel / Segment "Group Analytics" pattern.
2633
+ *
2634
+ * Crossdeck.group("org", "acme_inc");
2635
+ * Crossdeck.group("team", "design", { headcount: 12 });
2636
+ *
2637
+ * Once set, every subsequent event carries `$groups.<type>: id` on
2638
+ * its properties bag, enabling B2B dashboards ("how is Acme using
2639
+ * the product"). Pass `id: null` to clear a group membership.
2640
+ */
2641
+ group(type, id, traits) {
2642
+ const s = this.requireStarted();
2643
+ if (!type) {
2644
+ throw new CrossdeckError({
2645
+ type: "invalid_request_error",
2646
+ code: "missing_group_type",
2647
+ message: "group(type, id) requires a non-empty type."
2648
+ });
2649
+ }
2650
+ const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
2651
+ s.superProps.setGroup(type, id, sanitisedTraits);
2652
+ }
2653
+ /** Snapshot of the current groups map keyed by type. */
2654
+ getGroups() {
2655
+ if (!this.state) return {};
2656
+ return this.state.superProps.getGroups();
2657
+ }
2658
+ /**
2659
+ * Update consent state. Three independent dimensions:
2660
+ *
2661
+ * analytics — track() + identify() + auto-emissions
2662
+ * marketing — paid-traffic click IDs + referrer URL on events
2663
+ * errors — Web Vitals + (future) error reporting
2664
+ *
2665
+ * Each defaults to `true` (granted). Pass partial state — only the
2666
+ * keys you provide are changed.
2667
+ *
2668
+ * Crossdeck.consent({ analytics: false });
2669
+ * Crossdeck.consent({ marketing: true, errors: true });
2670
+ *
2671
+ * DNT-derived denies cannot be flipped back on; if the browser said
2672
+ * "don't track" we don't track even if the developer code disagrees.
2673
+ */
2674
+ consent(state) {
2675
+ const s = this.requireStarted();
2676
+ const next = s.consent.set(state);
2677
+ s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
2678
+ return next;
2679
+ }
2680
+ /** Snapshot of the current consent state. */
2681
+ consentStatus() {
2682
+ if (!this.state) {
2683
+ return { analytics: true, marketing: true, errors: true };
2684
+ }
2685
+ return this.state.consent.get();
2686
+ }
2687
+ // ============================================================
2688
+ // Error capture surface (v1.0.0+)
2689
+ // ============================================================
2690
+ /**
2691
+ * Manually capture an error from a try/catch block.
2692
+ *
2693
+ * try { …risky… } catch (err) {
2694
+ * Crossdeck.captureError(err, { context: { plan: "pro" } });
2695
+ * }
2696
+ *
2697
+ * The error is shipped through the same event queue as analytics
2698
+ * (durable, retried, rate-limited per fingerprint). Sends are gated
2699
+ * by `consent.errors`. Returns silently — never throws, even if the
2700
+ * SDK isn't initialised yet.
2701
+ */
2702
+ captureError(error, options) {
2703
+ if (!this.state?.errors) return;
2704
+ this.state.errors.captureError(error, options);
2705
+ }
2706
+ /**
2707
+ * Capture a non-error event you want to surface as an issue
2708
+ * ("deprecated path hit", "we entered the slow code path"). Sentry
2709
+ * captureMessage pattern. Returns silently if not initialised.
2710
+ */
2711
+ captureMessage(message, level = "info") {
2712
+ if (!this.state?.errors) return;
2713
+ this.state.errors.captureMessage(message, level);
2714
+ }
2715
+ /**
2716
+ * Attach a tag to every subsequent error report. Tags are key/value
2717
+ * strings (Sentry pattern): `setTag("flow", "checkout")` → every
2718
+ * error from this point on carries `tags.flow === "checkout"`.
2719
+ */
2720
+ setTag(key, value) {
2721
+ if (!this.state) return;
2722
+ this.state.errorTags[key] = value;
2723
+ }
2724
+ /** Bulk-set tags. Merges with existing tags. */
2725
+ setTags(tags) {
2726
+ if (!this.state) return;
2727
+ Object.assign(this.state.errorTags, tags);
2728
+ }
2729
+ /**
2730
+ * Attach a structured context blob to every subsequent error report.
2731
+ * Unlike tags (flat key/value), context is a named bag of arbitrary
2732
+ * data: `setContext("cart", { items: 3, total: 42.99 })`.
2733
+ */
2734
+ setContext(name, data) {
2735
+ if (!this.state) return;
2736
+ this.state.errorContext[name] = data;
2737
+ }
2738
+ /**
2739
+ * Add a custom breadcrumb to the rolling buffer. Useful for marking
2740
+ * domain-meaningful moments ("user opened paywall") that aren't
2741
+ * already auto-captured. The buffer caps at 50 entries; old ones
2742
+ * evict.
2743
+ */
2744
+ addBreadcrumb(crumb) {
2745
+ if (!this.state) return;
2746
+ this.state.breadcrumbs.add(crumb);
2747
+ }
2748
+ /**
2749
+ * Install a pre-send hook for errors. Return null to drop, or a
2750
+ * modified CapturedError to scrub / rewrite. Sentry's beforeSend
2751
+ * pattern — the only way to redact app-specific PII (auth tokens
2752
+ * in URLs, etc.) before the report leaves the browser.
2753
+ */
2754
+ setErrorBeforeSend(hook) {
2755
+ if (!this.state) return;
2756
+ this.state.errorBeforeSend = hook;
2757
+ }
2758
+ /**
2759
+ * Internal: turn a CapturedError into a Crossdeck event and enqueue
2760
+ * it. Goes through the same queue / persistence / consent / scrub
2761
+ * pipeline as analytics events.
2762
+ */
2763
+ reportError(err) {
2764
+ const properties = {
2765
+ // Identifiers
2766
+ fingerprint: err.fingerprint,
2767
+ level: err.level,
2768
+ // Error shape
2769
+ errorType: err.errorType,
2770
+ message: err.message,
2771
+ // Stack
2772
+ stack: err.rawStack ?? void 0,
2773
+ frames: err.frames,
2774
+ filename: err.filename ?? void 0,
2775
+ lineno: err.lineno ?? void 0,
2776
+ colno: err.colno ?? void 0,
2777
+ // Context
2778
+ tags: err.tags,
2779
+ context: err.context,
2780
+ breadcrumbs: err.breadcrumbs,
2781
+ // HTTP (only when applicable)
2782
+ http: err.http
2783
+ };
2784
+ for (const k of Object.keys(properties)) {
2785
+ if (properties[k] === void 0) delete properties[k];
2786
+ }
2787
+ this.track(err.kind, properties);
2788
+ }
2789
+ /**
2790
+ * GDPR/CCPA "right to be forgotten" — calls the backend's
2791
+ * /v1/identity/forget endpoint to schedule a server-side deletion of
2792
+ * the customer's events and profile, then wipes all local state
2793
+ * (identity, entitlements, queue, super-props, persistent stores).
2794
+ *
2795
+ * Idempotent. Safe to call when no identity has been established
2796
+ * (it just wipes the empty local state).
2797
+ *
2798
+ * After forget() resolves, the SDK is in the same shape as if the
2799
+ * developer had called `Crossdeck.reset()` — a fresh anonymousId is
2800
+ * minted and the next session is a brand new identity-graph entry.
2801
+ */
2802
+ async forget() {
2803
+ const s = this.requireStarted();
2804
+ const identityQuery = this.identityQueryParams();
2805
+ try {
2806
+ await s.http.request("POST", "/identity/forget", {
2807
+ body: {
2808
+ // Send every identity hint we hold; the server resolves the
2809
+ // canonical customer record and queues deletion. Missing
2810
+ // endpoint (older backend) gracefully degrades — local state
2811
+ // still wipes via the reset() call below.
2812
+ ...identityQuery
2813
+ }
2814
+ });
2815
+ } catch (err) {
2816
+ s.debug.emit(
2817
+ "sdk.consent_denied",
2818
+ `forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
2819
+ );
2820
+ }
2821
+ this.reset();
2822
+ }
1223
2823
  /**
1224
2824
  * Read the current customer's active entitlements from the server.
1225
2825
  * Updates the local cache so subsequent isEntitled() calls answer
@@ -1297,6 +2897,18 @@ var CrossdeckClient = class {
1297
2897
  message: "track(name) requires a non-empty name."
1298
2898
  });
1299
2899
  }
2900
+ const isError = name.startsWith("error.");
2901
+ const isWebVital = name.startsWith("webvitals.");
2902
+ const consentGateOk = isError || isWebVital ? s.consent.errors : s.consent.analytics;
2903
+ if (!consentGateOk) {
2904
+ if (s.debug.enabled) {
2905
+ s.debug.emit(
2906
+ "sdk.consent_denied",
2907
+ `Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
2908
+ );
2909
+ }
2910
+ return;
2911
+ }
1300
2912
  if (s.debug.enabled && properties) {
1301
2913
  const flagged = findSensitivePropertyKeys(properties);
1302
2914
  if (flagged.length > 0) {
@@ -1313,9 +2925,21 @@ var CrossdeckClient = class {
1313
2925
  "Using anonymous user until identify(userId) is called."
1314
2926
  );
1315
2927
  }
2928
+ const validation = validateEventProperties(properties);
2929
+ if (s.debug.enabled && validation.warnings.length > 0) {
2930
+ for (const w of validation.warnings) {
2931
+ s.debug.emit(
2932
+ "sdk.property_coerced",
2933
+ `Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2934
+ { eventName: name, key: w.key, kind: w.kind }
2935
+ );
2936
+ }
2937
+ }
1316
2938
  const enriched = { ...s.deviceInfo };
1317
2939
  const sessionId = s.autoTracker?.currentSessionId;
1318
2940
  if (sessionId) enriched.sessionId = sessionId;
2941
+ const pageviewId = s.autoTracker?.currentPageviewId;
2942
+ if (pageviewId) enriched.pageviewId = pageviewId;
1319
2943
  const acquisition = s.autoTracker?.currentAcquisition;
1320
2944
  if (acquisition) {
1321
2945
  if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
@@ -1323,17 +2947,46 @@ var CrossdeckClient = class {
1323
2947
  if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1324
2948
  if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1325
2949
  if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1326
- if (acquisition.referrer) enriched.referrer = acquisition.referrer;
2950
+ if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
2951
+ if (s.consent.marketing) {
2952
+ if (acquisition.gclid) enriched.gclid = acquisition.gclid;
2953
+ if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
2954
+ if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
2955
+ if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
2956
+ if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
2957
+ if (acquisition.twclid) enriched.twclid = acquisition.twclid;
2958
+ }
2959
+ }
2960
+ const supers = s.superProps.getSuperProperties();
2961
+ for (const k of Object.keys(supers)) {
2962
+ if (!(k in enriched)) enriched[k] = supers[k];
1327
2963
  }
1328
- if (properties) Object.assign(enriched, properties);
2964
+ const groupIds = s.superProps.getGroupIds();
2965
+ if (Object.keys(groupIds).length > 0) {
2966
+ enriched.$groups = groupIds;
2967
+ }
2968
+ Object.assign(enriched, validation.properties);
2969
+ const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
1329
2970
  const event = {
1330
2971
  eventId: this.mintEventId(),
1331
2972
  name,
1332
2973
  timestamp: Date.now(),
1333
- properties: enriched
2974
+ properties: finalProperties
1334
2975
  };
1335
2976
  Object.assign(event, this.identityHintForEvent());
1336
2977
  s.events.enqueue(event);
2978
+ if (!isError && !isWebVital) {
2979
+ const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
2980
+ s.breadcrumbs.add({
2981
+ timestamp: event.timestamp,
2982
+ category,
2983
+ message: name,
2984
+ // Strip the device-info / session bloat from the breadcrumb
2985
+ // payload — only the caller-supplied properties belong in
2986
+ // the user-readable trail.
2987
+ data: properties ? { ...properties } : void 0
2988
+ });
2989
+ }
1337
2990
  }
1338
2991
  /**
1339
2992
  * Force-flush queued events. Useful to call from page-unload handlers.
@@ -1408,7 +3061,12 @@ var CrossdeckClient = class {
1408
3061
  */
1409
3062
  async heartbeat() {
1410
3063
  const s = this.requireStarted();
1411
- return await s.http.request("GET", "/sdk/heartbeat");
3064
+ const result = await s.http.request("GET", "/sdk/heartbeat");
3065
+ if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
3066
+ s.lastServerTime = result.serverTime;
3067
+ s.lastClientTime = Date.now();
3068
+ }
3069
+ return result;
1412
3070
  }
1413
3071
  /**
1414
3072
  * Wipe persisted identity + entitlement cache. Use on logout. The
@@ -1427,6 +3085,10 @@ var CrossdeckClient = class {
1427
3085
  this.state.identity.reset();
1428
3086
  this.state.entitlements.clear();
1429
3087
  this.state.events.reset();
3088
+ this.state.superProps.clear();
3089
+ this.state.breadcrumbs.clear();
3090
+ this.state.errorContext = {};
3091
+ this.state.errorTags = {};
1430
3092
  this.state.developerUserId = null;
1431
3093
  if (this.state.autoTracker) {
1432
3094
  const tracker = new AutoTracker(
@@ -1454,17 +3116,21 @@ var CrossdeckClient = class {
1454
3116
  developerUserId: null,
1455
3117
  sdkVersion: null,
1456
3118
  baseUrl: null,
1457
- entitlements: { count: 0, lastUpdated: 0 },
3119
+ clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
3120
+ entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
1458
3121
  events: {
1459
3122
  buffered: 0,
1460
3123
  dropped: 0,
1461
3124
  inFlight: 0,
1462
3125
  lastFlushAt: 0,
1463
- lastError: null
3126
+ lastError: null,
3127
+ consecutiveFailures: 0,
3128
+ nextRetryAt: null
1464
3129
  }
1465
3130
  };
1466
3131
  }
1467
3132
  const s = this.state;
3133
+ const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
1468
3134
  return {
1469
3135
  started: true,
1470
3136
  anonymousId: s.identity.anonymousId,
@@ -1472,9 +3138,15 @@ var CrossdeckClient = class {
1472
3138
  developerUserId: s.developerUserId,
1473
3139
  sdkVersion: s.options.sdkVersion,
1474
3140
  baseUrl: s.options.baseUrl,
3141
+ clock: {
3142
+ lastServerTime: s.lastServerTime,
3143
+ lastClientTime: s.lastClientTime,
3144
+ skewMs
3145
+ },
1475
3146
  entitlements: {
1476
3147
  count: s.entitlements.list().length,
1477
- lastUpdated: s.entitlements.freshness
3148
+ lastUpdated: s.entitlements.freshness,
3149
+ listenerErrors: s.entitlements.listenerErrors
1478
3150
  },
1479
3151
  events: s.events.getStats()
1480
3152
  };
@@ -1541,6 +3213,7 @@ function inferEnvFromKey(publicKey) {
1541
3213
  }
1542
3214
  function isLocalHostname() {
1543
3215
  const w = globalThis.window;
3216
+ if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
1544
3217
  const hostname = w?.location?.hostname;
1545
3218
  if (!hostname) return false;
1546
3219
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
@@ -1553,7 +3226,14 @@ function isLocalHostname() {
1553
3226
  }
1554
3227
  function resolveAutoTrack(input) {
1555
3228
  if (input === false) {
1556
- return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
3229
+ return {
3230
+ sessions: false,
3231
+ pageViews: false,
3232
+ deviceInfo: false,
3233
+ clicks: false,
3234
+ webVitals: false,
3235
+ errors: false
3236
+ };
1557
3237
  }
1558
3238
  if (input === void 0 || input === true) {
1559
3239
  return { ...DEFAULT_AUTO_TRACK };
@@ -1562,7 +3242,9 @@ function resolveAutoTrack(input) {
1562
3242
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1563
3243
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1564
3244
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1565
- clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
3245
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
3246
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals,
3247
+ errors: input.errors ?? DEFAULT_AUTO_TRACK.errors
1566
3248
  };
1567
3249
  }
1568
3250
  function installUnloadFlush(onUnload) {
@@ -1582,13 +3264,109 @@ function installUnloadFlush(onUnload) {
1582
3264
  w.removeEventListener("beforeunload", onTerminal);
1583
3265
  };
1584
3266
  }
3267
+
3268
+ // src/error-codes.ts
3269
+ var CROSSDECK_ERROR_CODES = Object.freeze([
3270
+ // ----- Configuration -----
3271
+ {
3272
+ code: "invalid_public_key",
3273
+ type: "configuration_error",
3274
+ description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
3275
+ resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
3276
+ retryable: false
3277
+ },
3278
+ {
3279
+ code: "missing_app_id",
3280
+ type: "configuration_error",
3281
+ description: "Crossdeck.init() was called without an appId.",
3282
+ resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
3283
+ retryable: false
3284
+ },
3285
+ {
3286
+ code: "invalid_environment",
3287
+ type: "configuration_error",
3288
+ description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
3289
+ resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
3290
+ retryable: false
3291
+ },
3292
+ {
3293
+ code: "environment_mismatch",
3294
+ type: "configuration_error",
3295
+ description: "The publishable key's env prefix doesn't match the declared environment option.",
3296
+ resolution: "Either change `environment` to match the key prefix (cd_pub_test_ \u2194 sandbox, cd_pub_live_ \u2194 production), or swap the key for one minted in the right env.",
3297
+ retryable: false
3298
+ },
3299
+ {
3300
+ code: "not_initialized",
3301
+ type: "configuration_error",
3302
+ description: "An SDK method was called before Crossdeck.init().",
3303
+ resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
3304
+ retryable: false
3305
+ },
3306
+ // ----- Identify / track / purchase argument validation -----
3307
+ {
3308
+ code: "missing_user_id",
3309
+ type: "invalid_request_error",
3310
+ description: "identify() was called with an empty userId.",
3311
+ resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
3312
+ retryable: false
3313
+ },
3314
+ {
3315
+ code: "missing_event_name",
3316
+ type: "invalid_request_error",
3317
+ description: "track() was called without an event name.",
3318
+ resolution: "Pass a non-empty string as the first argument.",
3319
+ retryable: false
3320
+ },
3321
+ {
3322
+ code: "missing_group_type",
3323
+ type: "invalid_request_error",
3324
+ description: "group() was called without a group type.",
3325
+ resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
3326
+ retryable: false
3327
+ },
3328
+ {
3329
+ code: "missing_signed_transaction_info",
3330
+ type: "invalid_request_error",
3331
+ description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
3332
+ resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
3333
+ retryable: false
3334
+ },
3335
+ // ----- Network / transport -----
3336
+ {
3337
+ code: "fetch_failed",
3338
+ type: "network_error",
3339
+ description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
3340
+ resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
3341
+ retryable: true
3342
+ },
3343
+ {
3344
+ code: "request_timeout",
3345
+ type: "network_error",
3346
+ description: "A request was aborted after the configured timeoutMs (default 15s).",
3347
+ resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
3348
+ retryable: true
3349
+ },
3350
+ {
3351
+ code: "invalid_json_response",
3352
+ type: "internal_error",
3353
+ description: "The server returned a 2xx with an unparseable body.",
3354
+ resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
3355
+ retryable: true
3356
+ }
3357
+ ]);
3358
+ function getErrorCode(code) {
3359
+ return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
3360
+ }
1585
3361
  export {
3362
+ CROSSDECK_ERROR_CODES,
1586
3363
  Crossdeck,
1587
3364
  CrossdeckClient,
1588
3365
  CrossdeckError,
1589
3366
  DEFAULT_BASE_URL,
1590
3367
  MemoryStorage,
1591
3368
  SDK_NAME,
1592
- SDK_VERSION
3369
+ SDK_VERSION,
3370
+ getErrorCode
1593
3371
  };
1594
3372
  //# sourceMappingURL=index.mjs.map