@cross-deck/web 0.6.0 → 0.10.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.mjs CHANGED
@@ -10,11 +10,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
10
10
  this.code = payload.code;
11
11
  this.requestId = payload.requestId;
12
12
  this.status = payload.status;
13
+ this.retryAfterMs = payload.retryAfterMs;
13
14
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
14
15
  }
15
16
  };
16
17
  async function crossdeckErrorFromResponse(res) {
17
18
  const requestId = res.headers.get("x-request-id") ?? void 0;
19
+ const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
18
20
  let body;
19
21
  try {
20
22
  body = await res.json();
@@ -28,7 +30,8 @@ async function crossdeckErrorFromResponse(res) {
28
30
  code: envelope.code,
29
31
  message: envelope.message ?? `HTTP ${res.status}`,
30
32
  requestId: envelope.request_id ?? requestId,
31
- status: res.status
33
+ status: res.status,
34
+ retryAfterMs
32
35
  });
33
36
  }
34
37
  return new CrossdeckError({
@@ -36,9 +39,25 @@ async function crossdeckErrorFromResponse(res) {
36
39
  code: `http_${res.status}`,
37
40
  message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
38
41
  requestId,
39
- status: res.status
42
+ status: res.status,
43
+ retryAfterMs
40
44
  });
41
45
  }
46
+ function parseRetryAfterHeader(value) {
47
+ if (!value) return void 0;
48
+ const trimmed = value.trim();
49
+ if (!trimmed) return void 0;
50
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
51
+ const secs = Number(trimmed);
52
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
53
+ return Math.round(secs * 1e3);
54
+ }
55
+ if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
56
+ const target = Date.parse(trimmed);
57
+ if (!Number.isFinite(target)) return void 0;
58
+ const delta = target - Date.now();
59
+ return delta > 0 ? delta : 0;
60
+ }
42
61
  function typeMapForStatus(status) {
43
62
  if (status === 401) return "authentication_error";
44
63
  if (status === 403) return "permission_error";
@@ -49,8 +68,9 @@ function typeMapForStatus(status) {
49
68
 
50
69
  // src/http.ts
51
70
  var SDK_NAME = "@cross-deck/web";
52
- var SDK_VERSION = "0.6.0";
71
+ var SDK_VERSION = "0.10.0";
53
72
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
73
+ var DEFAULT_TIMEOUT_MS = 15e3;
54
74
  var HttpClient = class {
55
75
  constructor(config) {
56
76
  this.config = config;
@@ -65,31 +85,47 @@ var HttpClient = class {
65
85
  * - JSON parse failure on a 2xx (treated as `internal_error`)
66
86
  */
67
87
  async request(method, path, options = {}) {
88
+ if (this.config.localDevMode) {
89
+ return synthesizeLocalDevResponse(path);
90
+ }
68
91
  const url = this.buildUrl(path, options.query);
69
92
  const headers = {
70
93
  Authorization: `Bearer ${this.config.publicKey}`,
71
94
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
72
95
  Accept: "application/json"
73
96
  };
97
+ if (options.idempotencyKey) {
98
+ headers["Idempotency-Key"] = options.idempotencyKey;
99
+ }
74
100
  let bodyInit;
75
101
  if (options.body !== void 0) {
76
102
  headers["Content-Type"] = "application/json";
77
103
  bodyInit = JSON.stringify(options.body);
78
104
  }
105
+ const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
106
+ const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
107
+ let timeoutHandle = null;
108
+ if (controller && effectiveTimeout > 0) {
109
+ timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
110
+ }
79
111
  let response;
80
112
  try {
81
113
  response = await fetch(url, {
82
114
  method,
83
115
  headers,
84
116
  body: bodyInit,
85
- keepalive: options.keepalive === true
117
+ keepalive: options.keepalive === true,
118
+ signal: controller?.signal
86
119
  });
87
120
  } catch (err) {
121
+ const aborted = controller?.signal?.aborted === true;
88
122
  throw new CrossdeckError({
89
123
  type: "network_error",
90
- code: "fetch_failed",
91
- message: err instanceof Error ? err.message : "fetch failed"
124
+ code: aborted ? "request_timeout" : "fetch_failed",
125
+ message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
92
126
  });
127
+ } finally {
128
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
93
129
  }
94
130
  if (!response.ok) {
95
131
  throw await crossdeckErrorFromResponse(response);
@@ -107,6 +143,14 @@ var HttpClient = class {
107
143
  });
108
144
  }
109
145
  }
146
+ /**
147
+ * Whether this client is in localhost dev-mode short-circuit. Used
148
+ * by other SDK pieces (event-queue) to skip network-bound work
149
+ * entirely rather than going through synthesizeLocalDevResponse.
150
+ */
151
+ get isLocalDevMode() {
152
+ return this.config.localDevMode === true;
153
+ }
110
154
  buildUrl(path, query) {
111
155
  const base = this.config.baseUrl.replace(/\/+$/, "");
112
156
  const cleanPath = path.startsWith("/") ? path : `/${path}`;
@@ -122,6 +166,49 @@ var HttpClient = class {
122
166
  return url;
123
167
  }
124
168
  };
169
+ var cachedLocalCdcust = null;
170
+ function synthesizeLocalDevResponse(path) {
171
+ if (path.startsWith("/sdk/heartbeat")) {
172
+ return {
173
+ object: "heartbeat",
174
+ ok: true,
175
+ projectId: "proj_local_dev",
176
+ appId: "app_local_dev",
177
+ platform: "web",
178
+ env: "sandbox",
179
+ serverTime: Date.now()
180
+ };
181
+ }
182
+ if (path.startsWith("/identity/alias")) {
183
+ if (!cachedLocalCdcust) {
184
+ const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
185
+ cachedLocalCdcust = `cdcust_local_${tail}`;
186
+ }
187
+ return {
188
+ object: "alias_result",
189
+ crossdeckCustomerId: cachedLocalCdcust,
190
+ linked: [],
191
+ mergePending: false,
192
+ env: "sandbox"
193
+ };
194
+ }
195
+ if (path.startsWith("/entitlements")) {
196
+ return {
197
+ object: "list",
198
+ data: [],
199
+ crossdeckCustomerId: cachedLocalCdcust ?? "",
200
+ env: "sandbox"
201
+ };
202
+ }
203
+ if (path.startsWith("/events")) {
204
+ return {
205
+ object: "list",
206
+ received: 0,
207
+ env: "sandbox"
208
+ };
209
+ }
210
+ return {};
211
+ }
125
212
 
126
213
  // src/identity.ts
127
214
  var KEY_ANON = "anon_id";
@@ -235,6 +322,7 @@ var EntitlementCache = class {
235
322
  this.all = [];
236
323
  this.lastUpdated = 0;
237
324
  this.listeners = /* @__PURE__ */ new Set();
325
+ this.listenerErrorCount = 0;
238
326
  }
239
327
  /** Sync read — true iff the entitlement key is currently active. */
240
328
  isEntitled(key) {
@@ -248,6 +336,15 @@ var EntitlementCache = class {
248
336
  get freshness() {
249
337
  return this.lastUpdated;
250
338
  }
339
+ /**
340
+ * Cumulative count of listener invocations that threw. Listener errors
341
+ * are swallowed (a buggy consumer must not crash the SDK) but the
342
+ * counter lets diagnostics() surface "you have a broken subscriber"
343
+ * without putting the developer in a debug session.
344
+ */
345
+ get listenerErrors() {
346
+ return this.listenerErrorCount;
347
+ }
251
348
  /**
252
349
  * Replace the cache with a fresh server response. The backend already
253
350
  * filters to active + env-matching, so we don't re-filter — just trust
@@ -301,11 +398,54 @@ var EntitlementCache = class {
301
398
  try {
302
399
  listener(snapshot);
303
400
  } catch {
401
+ this.listenerErrorCount += 1;
304
402
  }
305
403
  }
306
404
  }
307
405
  };
308
406
 
407
+ // src/retry-policy.ts
408
+ var DEFAULT_BASE = 1e3;
409
+ var DEFAULT_MAX = 6e4;
410
+ var DEFAULT_FACTOR = 2;
411
+ var DEFAULT_WARN = 8;
412
+ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
413
+ const base = options.baseMs ?? DEFAULT_BASE;
414
+ const max = options.maxMs ?? DEFAULT_MAX;
415
+ const factor = options.factor ?? DEFAULT_FACTOR;
416
+ const safeAttempts = Math.min(attempts, 30);
417
+ const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
418
+ const jittered = ceiling * random();
419
+ if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
420
+ return Math.min(max, retryAfterMs);
421
+ }
422
+ return Math.max(0, Math.round(jittered));
423
+ }
424
+ var RetryPolicy = class {
425
+ constructor(options = {}) {
426
+ this.options = options;
427
+ this.attempts = 0;
428
+ }
429
+ /** How many consecutive failures since the last success. */
430
+ get consecutiveFailures() {
431
+ return this.attempts;
432
+ }
433
+ /** Whether we've crossed the failuresBeforeWarn threshold. */
434
+ get isWarning() {
435
+ return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
436
+ }
437
+ /** Schedule-time delay for the NEXT retry. Increments the counter. */
438
+ nextDelay(retryAfterMs, random = Math.random) {
439
+ const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
440
+ this.attempts += 1;
441
+ return delay;
442
+ }
443
+ /** Mark a successful flush — reset the counter. */
444
+ recordSuccess() {
445
+ this.attempts = 0;
446
+ }
447
+ };
448
+
309
449
  // src/event-queue.ts
310
450
  var HARD_BUFFER_CAP = 1e3;
311
451
  var EventQueue = class {
@@ -318,6 +458,22 @@ var EventQueue = class {
318
458
  this.lastError = null;
319
459
  this.cancelTimer = null;
320
460
  this.firstFlushFired = false;
461
+ this.nextRetryAt = null;
462
+ this.retry = new RetryPolicy(cfg.retry ?? {});
463
+ this.persistent = cfg.persistentStore ?? null;
464
+ if (this.persistent) {
465
+ const restored = this.persistent.load();
466
+ if (restored.length > 0) {
467
+ if (restored.length > HARD_BUFFER_CAP) {
468
+ this.dropped += restored.length - HARD_BUFFER_CAP;
469
+ this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
470
+ } else {
471
+ this.buffer = restored;
472
+ }
473
+ this.cfg.onBufferChange?.(this.buffer.length);
474
+ this.scheduleIdleFlush();
475
+ }
476
+ }
321
477
  }
322
478
  enqueue(event) {
323
479
  this.buffer.push(event);
@@ -327,6 +483,8 @@ var EventQueue = class {
327
483
  this.dropped += overflow;
328
484
  this.cfg.onDrop?.(overflow);
329
485
  }
486
+ this.cfg.onBufferChange?.(this.buffer.length);
487
+ this.persistent?.save(this.buffer);
330
488
  if (this.buffer.length >= this.cfg.batchSize) {
331
489
  void this.flush();
332
490
  } else {
@@ -336,7 +494,7 @@ var EventQueue = class {
336
494
  /**
337
495
  * Flush the buffer to /v1/events. Resolves when the network call
338
496
  * completes (success or failure). On failure, events stay in the
339
- * buffer for the next flush attempt.
497
+ * buffer for the next scheduled retry.
340
498
  *
341
499
  * `options.keepalive` marks the underlying fetch as keepalive so the
342
500
  * browser keeps the request alive past page unload. Use this for
@@ -345,25 +503,32 @@ var EventQueue = class {
345
503
  async flush(options = {}) {
346
504
  if (this.buffer.length === 0) return null;
347
505
  this.cancelTimerIfSet();
506
+ this.nextRetryAt = null;
348
507
  const batch = this.buffer.splice(0);
508
+ const batchId = this.mintBatchId();
349
509
  this.inFlight += batch.length;
510
+ this.persistent?.save(this.buffer);
511
+ this.cfg.onBufferChange?.(this.buffer.length);
350
512
  try {
351
513
  const env = this.cfg.envelope();
352
514
  const result = await this.cfg.http.request("POST", "/events", {
353
515
  body: {
354
516
  // NorthStar §13.1 batch envelope. The backend validates these
355
- // against the API-key-resolved app and rejects mismatches loudly
356
- // (env_mismatch).
517
+ // against the API-key-resolved app and rejects mismatches
518
+ // loudly (env_mismatch).
357
519
  appId: env.appId,
358
520
  environment: env.environment,
359
521
  sdk: env.sdk,
360
522
  events: batch
361
523
  },
362
- keepalive: options.keepalive === true
524
+ keepalive: options.keepalive === true,
525
+ idempotencyKey: batchId
363
526
  });
364
527
  this.lastFlushAt = Date.now();
365
528
  this.lastError = null;
366
529
  this.inFlight -= batch.length;
530
+ this.retry.recordSuccess();
531
+ this.persistent?.save(this.buffer);
367
532
  if (!this.firstFlushFired) {
368
533
  this.firstFlushFired = true;
369
534
  this.cfg.onFirstFlushSuccess?.();
@@ -372,18 +537,33 @@ var EventQueue = class {
372
537
  } catch (err) {
373
538
  this.buffer.unshift(...batch);
374
539
  this.inFlight -= batch.length;
375
- this.lastError = err instanceof Error ? err.message : String(err);
376
- this.scheduleIdleFlush();
540
+ const message = err instanceof Error ? err.message : String(err);
541
+ this.lastError = message;
542
+ this.persistent?.save(this.buffer);
543
+ this.cfg.onBufferChange?.(this.buffer.length);
544
+ const retryAfterMs = extractRetryAfterMs(err);
545
+ const delay = this.retry.nextDelay(retryAfterMs);
546
+ this.scheduleRetry(delay);
547
+ this.cfg.onRetryScheduled?.({
548
+ delayMs: delay,
549
+ consecutiveFailures: this.retry.consecutiveFailures,
550
+ retryAfterMs,
551
+ lastError: message
552
+ });
377
553
  return null;
378
554
  }
379
555
  }
380
- /** Cancel any pending timer and clear in-memory state. */
556
+ /** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
381
557
  reset() {
382
558
  this.cancelTimerIfSet();
559
+ this.nextRetryAt = null;
383
560
  this.buffer = [];
384
561
  this.dropped = 0;
385
562
  this.inFlight = 0;
386
563
  this.lastError = null;
564
+ this.retry.recordSuccess();
565
+ this.persistent?.clear();
566
+ this.cfg.onBufferChange?.(0);
387
567
  }
388
568
  getStats() {
389
569
  return {
@@ -391,9 +571,12 @@ var EventQueue = class {
391
571
  dropped: this.dropped,
392
572
  inFlight: this.inFlight,
393
573
  lastFlushAt: this.lastFlushAt,
394
- lastError: this.lastError
574
+ lastError: this.lastError,
575
+ consecutiveFailures: this.retry.consecutiveFailures,
576
+ nextRetryAt: this.nextRetryAt
395
577
  };
396
578
  }
579
+ // ---------- internal scheduling ----------
397
580
  scheduleIdleFlush() {
398
581
  this.cancelTimerIfSet();
399
582
  const sched = this.cfg.scheduler ?? defaultScheduler;
@@ -401,13 +584,31 @@ var EventQueue = class {
401
584
  void this.flush();
402
585
  }, this.cfg.intervalMs);
403
586
  }
587
+ scheduleRetry(delayMs) {
588
+ this.cancelTimerIfSet();
589
+ this.nextRetryAt = Date.now() + delayMs;
590
+ const sched = this.cfg.scheduler ?? defaultScheduler;
591
+ this.cancelTimer = sched(() => {
592
+ void this.flush();
593
+ }, delayMs);
594
+ }
404
595
  cancelTimerIfSet() {
405
596
  if (this.cancelTimer) {
406
597
  this.cancelTimer();
407
598
  this.cancelTimer = null;
408
599
  }
409
600
  }
601
+ mintBatchId() {
602
+ return `batch_${Date.now().toString(36)}${randomChars(10)}`;
603
+ }
410
604
  };
605
+ function extractRetryAfterMs(err) {
606
+ if (err && typeof err === "object" && "retryAfterMs" in err) {
607
+ const v = err.retryAfterMs;
608
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
609
+ }
610
+ return void 0;
611
+ }
411
612
  function defaultScheduler(fn, ms) {
412
613
  const id = setTimeout(fn, ms);
413
614
  if (typeof id.unref === "function") {
@@ -419,6 +620,87 @@ function defaultScheduler(fn, ms) {
419
620
  return () => clearTimeout(id);
420
621
  }
421
622
 
623
+ // src/event-storage.ts
624
+ var PersistentEventStore = class {
625
+ constructor(options) {
626
+ this.options = options;
627
+ this.writeScheduled = false;
628
+ // Pending events captured on the most recent write request. We keep
629
+ // the latest snapshot ref so a debounced write always picks up the
630
+ // freshest buffer state.
631
+ this.pendingSnapshot = null;
632
+ this.key = `${options.prefix}queue.v1`;
633
+ }
634
+ /**
635
+ * Read the persisted queue on boot. Returns an empty array (with no
636
+ * warning) when nothing is stored, the blob is malformed, or storage
637
+ * is unavailable. Caller is responsible for treating duplicates from
638
+ * the persisted queue as the SAME events (eventId-based dedup).
639
+ */
640
+ load() {
641
+ let raw;
642
+ try {
643
+ raw = this.options.storage.getItem(this.key);
644
+ } catch {
645
+ return [];
646
+ }
647
+ if (!raw) return [];
648
+ try {
649
+ const parsed = JSON.parse(raw);
650
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
651
+ return [];
652
+ }
653
+ return parsed.events;
654
+ } catch {
655
+ return [];
656
+ }
657
+ }
658
+ /**
659
+ * Schedule a write of the current buffer. Debounced via microtask so
660
+ * a burst of enqueue() calls coalesces into one persistence write.
661
+ * Writes are best-effort: if storage throws (quota, private mode),
662
+ * we swallow and rely on the in-memory buffer.
663
+ */
664
+ save(snapshot) {
665
+ this.pendingSnapshot = snapshot.slice();
666
+ if (this.writeScheduled) return;
667
+ this.writeScheduled = true;
668
+ queueMicrotask(() => this.flushWrite());
669
+ }
670
+ /** Synchronous variant for terminal flushes (pagehide / beforeunload). */
671
+ saveSync(snapshot) {
672
+ this.pendingSnapshot = snapshot.slice();
673
+ this.flushWrite();
674
+ }
675
+ /** Wipe the persisted blob. Used by reset() (logout). */
676
+ clear() {
677
+ this.pendingSnapshot = null;
678
+ this.writeScheduled = false;
679
+ try {
680
+ this.options.storage.removeItem(this.key);
681
+ } catch {
682
+ }
683
+ }
684
+ flushWrite() {
685
+ this.writeScheduled = false;
686
+ const snapshot = this.pendingSnapshot;
687
+ this.pendingSnapshot = null;
688
+ if (snapshot === null) return;
689
+ if (snapshot.length === 0) {
690
+ try {
691
+ this.options.storage.removeItem(this.key);
692
+ } catch {
693
+ }
694
+ return;
695
+ }
696
+ const blob = { version: 1, events: snapshot };
697
+ try {
698
+ this.options.storage.setItem(this.key, JSON.stringify(blob));
699
+ } catch {
700
+ }
701
+ }
702
+ };
703
+
422
704
  // src/storage.ts
423
705
  var MemoryStorage = class {
424
706
  constructor() {
@@ -608,7 +890,9 @@ function parseUserAgent(ua) {
608
890
  var DEFAULT_AUTO_TRACK = {
609
891
  sessions: true,
610
892
  pageViews: true,
611
- deviceInfo: true
893
+ deviceInfo: true,
894
+ clicks: true,
895
+ webVitals: true
612
896
  };
613
897
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
614
898
  var EMPTY_ACQUISITION = {
@@ -617,7 +901,13 @@ var EMPTY_ACQUISITION = {
617
901
  utm_campaign: "",
618
902
  utm_content: "",
619
903
  utm_term: "",
620
- referrer: ""
904
+ referrer: "",
905
+ gclid: "",
906
+ fbclid: "",
907
+ msclkid: "",
908
+ ttclid: "",
909
+ li_fat_id: "",
910
+ twclid: ""
621
911
  };
622
912
  var AutoTracker = class {
623
913
  constructor(cfg, track) {
@@ -625,11 +915,23 @@ var AutoTracker = class {
625
915
  this.track = track;
626
916
  this.session = null;
627
917
  this.cleanups = [];
918
+ /**
919
+ * Stable per-page-view identifier. Minted at every `page.viewed`
920
+ * emission and attached to every subsequent event until the next
921
+ * `page.viewed`. Lets dashboards correlate "user clicked X" to
922
+ * "user viewed page Y" without timestamp arithmetic — the canonical
923
+ * Mixpanel `$current_url` / Segment `pageId` pattern.
924
+ *
925
+ * Null until the first `page.viewed` fires (which happens at SDK
926
+ * install if `autoTrack.pageViews !== false`).
927
+ */
928
+ this.pageviewId = null;
628
929
  }
629
930
  install() {
630
931
  if (!isBrowserSafe()) return;
631
932
  if (this.cfg.sessions) this.installSessionTracking();
632
933
  if (this.cfg.pageViews) this.installPageViewTracking();
934
+ if (this.cfg.clicks) this.installClickTracking();
633
935
  }
634
936
  uninstall() {
635
937
  while (this.cleanups.length) {
@@ -654,6 +956,10 @@ var AutoTracker = class {
654
956
  get currentSessionId() {
655
957
  return this.session?.sessionId ?? null;
656
958
  }
959
+ /** Stable per-page-view ID. Null before the first page.viewed has fired. */
960
+ get currentPageviewId() {
961
+ return this.pageviewId;
962
+ }
657
963
  /**
658
964
  * Per-session acquisition context — utm_* + referrer, captured once
659
965
  * at session start. Returns empty strings when there's no session
@@ -724,11 +1030,21 @@ var AutoTracker = class {
724
1030
  installPageViewTracking() {
725
1031
  const w = globalThis.window;
726
1032
  const doc = globalThis.document;
727
- const fire = () => {
1033
+ let lastFiredAt = 0;
1034
+ let lastFiredUrl = "";
1035
+ const DEDUP_WINDOW_MS = 250;
1036
+ const fire = (force = false) => {
728
1037
  const loc = w.location;
1038
+ const url = loc.href;
1039
+ const now = Date.now();
1040
+ if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
1041
+ lastFiredAt = now;
1042
+ lastFiredUrl = url;
1043
+ this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
729
1044
  this.track("page.viewed", {
1045
+ pageviewId: this.pageviewId,
730
1046
  path: loc.pathname,
731
- url: loc.href,
1047
+ url,
732
1048
  search: loc.search || void 0,
733
1049
  hash: loc.hash || void 0,
734
1050
  title: doc.title,
@@ -750,7 +1066,7 @@ var AutoTracker = class {
750
1066
  }
751
1067
  w.history.pushState = patchedPush;
752
1068
  w.history.replaceState = patchedReplace;
753
- const onPopState = () => fire();
1069
+ const onPopState = () => fire(true);
754
1070
  w.addEventListener("popstate", onPopState);
755
1071
  this.cleanups.push(() => {
756
1072
  if (w.history.pushState === patchedPush) {
@@ -762,7 +1078,156 @@ var AutoTracker = class {
762
1078
  w.removeEventListener("popstate", onPopState);
763
1079
  });
764
1080
  }
1081
+ // ---------- click autocapture ----------
1082
+ /**
1083
+ * Global click tracking — Mixpanel / Amplitude style autocapture.
1084
+ * Fires `element.clicked` for every interactive click with the
1085
+ * target element's selector path, text content, tag, href, data-*
1086
+ * attributes, and viewport coordinates. Powers the funnel /
1087
+ * attribution USP: "users who clicked X then converted within
1088
+ * 7 days." Default ON because behavioural attribution is the
1089
+ * core product promise.
1090
+ *
1091
+ * Privacy guardrails:
1092
+ * - Skip clicks ON inputs / textareas / selects (form interaction
1093
+ * isn't button telemetry; the dev should track form submits
1094
+ * deliberately via track('form_submitted'))
1095
+ * - Skip clicks INSIDE [type="password"] and password-class
1096
+ * elements
1097
+ * - Skip clicks inside elements opted out via class="cd-noTrack"
1098
+ * or data-cd-noTrack attribute (Mixpanel's exact opt-out
1099
+ * idiom — most devs already know it)
1100
+ * - Capture text content but cap at 64 chars and trim — never
1101
+ * more than what you'd see on a button label
1102
+ *
1103
+ * Volume guardrails:
1104
+ * - Coalesce double-clicks within 100ms (React's synthetic click
1105
+ * pattern + browser's native dblclick can fire twice)
1106
+ * - Listen on document at capture phase so we see the click
1107
+ * before any framework's own handlers stop propagation
1108
+ */
1109
+ installClickTracking() {
1110
+ const w = globalThis.window;
1111
+ const doc = globalThis.document;
1112
+ let lastFiredAt = 0;
1113
+ let lastFiredTarget = null;
1114
+ const COALESCE_MS = 100;
1115
+ const TEXT_CAP = 64;
1116
+ const onClick = (ev) => {
1117
+ const target = ev.target;
1118
+ if (!target || !(target instanceof Element)) return;
1119
+ const now = Date.now();
1120
+ if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
1121
+ lastFiredAt = now;
1122
+ lastFiredTarget = target;
1123
+ const actionable = closestActionable(target);
1124
+ const clicked = actionable || target;
1125
+ if (isFormInput(clicked)) return;
1126
+ if (isInOptedOut(clicked)) return;
1127
+ if (isInsidePasswordField(clicked)) return;
1128
+ const tag = clicked.tagName.toLowerCase();
1129
+ const text = trimText(extractText(clicked), TEXT_CAP);
1130
+ const href = clicked.href || void 0;
1131
+ const linkTarget = clicked.target || void 0;
1132
+ const elementId = clicked.id || void 0;
1133
+ const role = clicked.getAttribute("role") || void 0;
1134
+ const ariaLabel = clicked.getAttribute("aria-label") || void 0;
1135
+ const selector = buildSelector(clicked);
1136
+ const dataAttrs = collectDataAttrs(clicked);
1137
+ const isLink = tag === "a" && !!href;
1138
+ const explicitName = clicked.getAttribute("data-cd-event");
1139
+ const props = {
1140
+ selector,
1141
+ tag,
1142
+ text,
1143
+ elementId,
1144
+ role,
1145
+ ariaLabel,
1146
+ href,
1147
+ isLink,
1148
+ linkTarget,
1149
+ viewportX: ev.clientX,
1150
+ viewportY: ev.clientY,
1151
+ pageX: ev.pageX,
1152
+ pageY: ev.pageY,
1153
+ ...dataAttrs
1154
+ };
1155
+ for (const k of Object.keys(props)) {
1156
+ if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
1157
+ }
1158
+ this.track(explicitName || "element.clicked", props);
1159
+ };
1160
+ doc.addEventListener("click", onClick, { capture: true, passive: true });
1161
+ this.cleanups.push(() => {
1162
+ doc.removeEventListener("click", onClick, { capture: true });
1163
+ });
1164
+ }
765
1165
  };
1166
+ function closestActionable(el) {
1167
+ return el.closest("[data-cd-event]") || el.closest("[data-cd-noTrack]") || el.closest("button, a, [role='button'], [role='link'], input[type='button'], input[type='submit']") || null;
1168
+ }
1169
+ function isFormInput(el) {
1170
+ if (!(el instanceof HTMLElement)) return false;
1171
+ const tag = el.tagName.toLowerCase();
1172
+ if (tag === "textarea" || tag === "select") return true;
1173
+ if (tag === "input") {
1174
+ const type = (el.type || "").toLowerCase();
1175
+ return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
1176
+ }
1177
+ return false;
1178
+ }
1179
+ function isInOptedOut(el) {
1180
+ if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
1181
+ return false;
1182
+ }
1183
+ function isInsidePasswordField(el) {
1184
+ if (el.closest('input[type="password"]')) return true;
1185
+ return false;
1186
+ }
1187
+ function extractText(el) {
1188
+ const aria = el.getAttribute("aria-label");
1189
+ if (aria) return aria.replace(/\s+/g, " ").trim();
1190
+ if (el instanceof HTMLInputElement && el.value) return el.value;
1191
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim();
1192
+ return text;
1193
+ }
1194
+ function trimText(s, cap) {
1195
+ if (s.length <= cap) return s;
1196
+ return s.slice(0, cap - 1) + "\u2026";
1197
+ }
1198
+ function buildSelector(el) {
1199
+ const parts = [];
1200
+ let cur = el;
1201
+ let depth = 0;
1202
+ while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
1203
+ let part = cur.nodeName.toLowerCase();
1204
+ if (cur.id) {
1205
+ parts.unshift(`${part}#${cur.id}`);
1206
+ break;
1207
+ }
1208
+ if (cur.classList.length > 0) {
1209
+ const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
1210
+ if (cls) part += `.${cls}`;
1211
+ }
1212
+ parts.unshift(part);
1213
+ cur = cur.parentElement;
1214
+ depth++;
1215
+ }
1216
+ return parts.join(" > ");
1217
+ }
1218
+ function collectDataAttrs(el) {
1219
+ const out = {};
1220
+ if (!(el instanceof HTMLElement)) return out;
1221
+ for (const name of el.getAttributeNames()) {
1222
+ if (!name.startsWith("data-")) continue;
1223
+ if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
1224
+ if (name === "data-cd-event") continue;
1225
+ const value = el.getAttribute(name) || "";
1226
+ const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
1227
+ out[key] = value;
1228
+ }
1229
+ return out;
1230
+ }
766
1231
  function isBrowserSafe() {
767
1232
  return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
768
1233
  }
@@ -781,6 +1246,12 @@ function captureAcquisition() {
781
1246
  result.utm_campaign = params.get("utm_campaign") ?? "";
782
1247
  result.utm_content = params.get("utm_content") ?? "";
783
1248
  result.utm_term = params.get("utm_term") ?? "";
1249
+ result.gclid = params.get("gclid") ?? "";
1250
+ result.fbclid = params.get("fbclid") ?? "";
1251
+ result.msclkid = params.get("msclkid") ?? "";
1252
+ result.ttclid = params.get("ttclid") ?? "";
1253
+ result.li_fat_id = params.get("li_fat_id") ?? "";
1254
+ result.twclid = params.get("twclid") ?? "";
784
1255
  } catch {
785
1256
  }
786
1257
  try {
@@ -838,6 +1309,490 @@ function safeJson(obj) {
838
1309
  }
839
1310
  }
840
1311
 
1312
+ // src/event-validation.ts
1313
+ var DEFAULT_MAX_STRING = 1024;
1314
+ var DEFAULT_MAX_BYTES = 8 * 1024;
1315
+ var DEFAULT_MAX_DEPTH = 5;
1316
+ function validateEventProperties(input, options = {}) {
1317
+ const warnings = [];
1318
+ if (!input) return { properties: {}, warnings };
1319
+ const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1320
+ const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1321
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1322
+ const seen = /* @__PURE__ */ new WeakSet();
1323
+ const visit = (value, key, depth) => {
1324
+ if (depth > maxDepth) {
1325
+ warnings.push({ kind: "depth_exceeded", key });
1326
+ return { keep: true, value: "[depth-exceeded]" };
1327
+ }
1328
+ if (value === null) return { keep: true, value: null };
1329
+ const t = typeof value;
1330
+ if (t === "string") {
1331
+ const s = value;
1332
+ if (s.length > maxStringLength) {
1333
+ warnings.push({ kind: "truncated_string", key });
1334
+ return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
1335
+ }
1336
+ return { keep: true, value: s };
1337
+ }
1338
+ if (t === "number") {
1339
+ if (!Number.isFinite(value)) {
1340
+ warnings.push({ kind: "non_serialisable", key });
1341
+ return { keep: true, value: null };
1342
+ }
1343
+ return { keep: true, value };
1344
+ }
1345
+ if (t === "boolean") return { keep: true, value };
1346
+ if (t === "bigint") {
1347
+ warnings.push({ kind: "coerced_bigint", key });
1348
+ return { keep: true, value: value.toString() };
1349
+ }
1350
+ if (t === "function") {
1351
+ warnings.push({ kind: "dropped_function", key });
1352
+ return { keep: false, value: void 0 };
1353
+ }
1354
+ if (t === "symbol") {
1355
+ warnings.push({ kind: "dropped_symbol", key });
1356
+ return { keep: false, value: void 0 };
1357
+ }
1358
+ if (t === "undefined") {
1359
+ warnings.push({ kind: "dropped_undefined", key });
1360
+ return { keep: false, value: void 0 };
1361
+ }
1362
+ if (value instanceof Date) {
1363
+ warnings.push({ kind: "coerced_date", key });
1364
+ const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
1365
+ return { keep: true, value: iso };
1366
+ }
1367
+ if (value instanceof Error) {
1368
+ warnings.push({ kind: "coerced_error", key });
1369
+ return {
1370
+ keep: true,
1371
+ value: {
1372
+ name: value.name,
1373
+ message: value.message,
1374
+ stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
1375
+ }
1376
+ };
1377
+ }
1378
+ if (value instanceof Map) {
1379
+ warnings.push({ kind: "coerced_map", key });
1380
+ const obj = {};
1381
+ for (const [k, v] of value.entries()) {
1382
+ const subKey = typeof k === "string" ? k : String(k);
1383
+ const result = visit(v, `${key}.${subKey}`, depth + 1);
1384
+ if (result.keep) obj[subKey] = result.value;
1385
+ }
1386
+ return { keep: true, value: obj };
1387
+ }
1388
+ if (value instanceof Set) {
1389
+ warnings.push({ kind: "coerced_set", key });
1390
+ const arr = [];
1391
+ let i = 0;
1392
+ for (const v of value.values()) {
1393
+ const result = visit(v, `${key}[${i}]`, depth + 1);
1394
+ if (result.keep) arr.push(result.value);
1395
+ i++;
1396
+ }
1397
+ return { keep: true, value: arr };
1398
+ }
1399
+ if (Array.isArray(value)) {
1400
+ if (seen.has(value)) {
1401
+ warnings.push({ kind: "circular_reference", key });
1402
+ return { keep: true, value: "[circular]" };
1403
+ }
1404
+ seen.add(value);
1405
+ const out = [];
1406
+ for (let i = 0; i < value.length; i++) {
1407
+ const result = visit(value[i], `${key}[${i}]`, depth + 1);
1408
+ if (result.keep) out.push(result.value);
1409
+ }
1410
+ return { keep: true, value: out };
1411
+ }
1412
+ if (t === "object") {
1413
+ const obj = value;
1414
+ if (seen.has(obj)) {
1415
+ warnings.push({ kind: "circular_reference", key });
1416
+ return { keep: true, value: "[circular]" };
1417
+ }
1418
+ seen.add(obj);
1419
+ const out = {};
1420
+ for (const k of Object.keys(obj)) {
1421
+ const result = visit(obj[k], `${key}.${k}`, depth + 1);
1422
+ if (result.keep) out[k] = result.value;
1423
+ }
1424
+ return { keep: true, value: out };
1425
+ }
1426
+ warnings.push({ kind: "non_serialisable", key });
1427
+ try {
1428
+ return { keep: true, value: String(value) };
1429
+ } catch {
1430
+ return { keep: false, value: void 0 };
1431
+ }
1432
+ };
1433
+ const cleaned = {};
1434
+ for (const k of Object.keys(input)) {
1435
+ const result = visit(input[k], k, 0);
1436
+ if (result.keep) cleaned[k] = result.value;
1437
+ }
1438
+ const serialised = safeStringify(cleaned);
1439
+ if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
1440
+ warnings.push({ kind: "size_cap_exceeded", key: "*" });
1441
+ const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
1442
+ let currentSize = byteLength(serialised);
1443
+ for (const { k } of sizes) {
1444
+ if (currentSize <= maxBatchPropertyBytes) break;
1445
+ currentSize -= sizes.find((s) => s.k === k).size;
1446
+ delete cleaned[k];
1447
+ }
1448
+ cleaned.__truncated = true;
1449
+ }
1450
+ return { properties: cleaned, warnings };
1451
+ }
1452
+ function safeStringify(v) {
1453
+ try {
1454
+ return JSON.stringify(v) ?? null;
1455
+ } catch {
1456
+ return null;
1457
+ }
1458
+ }
1459
+ function byteLength(s) {
1460
+ if (typeof TextEncoder !== "undefined") {
1461
+ return new TextEncoder().encode(s).length;
1462
+ }
1463
+ return s.length * 4;
1464
+ }
1465
+
1466
+ // src/super-properties.ts
1467
+ var KEY_SUPER = "super_props";
1468
+ var KEY_GROUPS = "groups";
1469
+ var SuperPropertyStore = class {
1470
+ constructor(storage, prefix) {
1471
+ this.storage = storage;
1472
+ this.prefix = prefix;
1473
+ this.superProps = {};
1474
+ this.groups = {};
1475
+ this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
1476
+ this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
1477
+ }
1478
+ // ---------- super properties ----------
1479
+ /**
1480
+ * Merge new keys into the super-property bag. Returns a snapshot of
1481
+ * the resulting bag. Values that are `null` are deleted (Mixpanel
1482
+ * semantics — explicit null = "stop tracking this key").
1483
+ */
1484
+ register(props) {
1485
+ for (const [k, v] of Object.entries(props)) {
1486
+ if (v === null) {
1487
+ delete this.superProps[k];
1488
+ } else if (v !== void 0) {
1489
+ this.superProps[k] = v;
1490
+ }
1491
+ }
1492
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1493
+ return { ...this.superProps };
1494
+ }
1495
+ /** Remove a single super-property key. Idempotent. */
1496
+ unregister(key) {
1497
+ if (key in this.superProps) {
1498
+ delete this.superProps[key];
1499
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1500
+ }
1501
+ }
1502
+ /** Snapshot of the current super-property bag. */
1503
+ getSuperProperties() {
1504
+ return { ...this.superProps };
1505
+ }
1506
+ // ---------- groups ----------
1507
+ /**
1508
+ * Set a group membership. Passing `id: null` clears the membership
1509
+ * for that group type — the SDK stops attaching it to events.
1510
+ */
1511
+ setGroup(type, id, traits) {
1512
+ if (id === null) {
1513
+ delete this.groups[type];
1514
+ } else {
1515
+ this.groups[type] = traits !== void 0 ? { id, traits } : { id };
1516
+ }
1517
+ writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
1518
+ }
1519
+ /**
1520
+ * Snapshot of the current groups map, keyed by group type. Returned
1521
+ * shape mirrors what the SDK attaches to every event as
1522
+ * `$groups.{type}`. The `traits` sub-object is the most-recent
1523
+ * traits payload passed to `setGroup` for that type; null when none.
1524
+ */
1525
+ getGroups() {
1526
+ return JSON.parse(JSON.stringify(this.groups));
1527
+ }
1528
+ /**
1529
+ * The flat `{ type: id }` projection used for event-attachment. Stable
1530
+ * for fast every-event merge — we don't want to JSON-clone on each
1531
+ * track() call.
1532
+ */
1533
+ getGroupIds() {
1534
+ const out = {};
1535
+ for (const [type, info] of Object.entries(this.groups)) {
1536
+ out[type] = info.id;
1537
+ }
1538
+ return out;
1539
+ }
1540
+ /** Wipe both bags. Called by Crossdeck.reset() (logout). */
1541
+ clear() {
1542
+ this.superProps = {};
1543
+ this.groups = {};
1544
+ try {
1545
+ this.storage.removeItem(this.prefix + KEY_SUPER);
1546
+ } catch {
1547
+ }
1548
+ try {
1549
+ this.storage.removeItem(this.prefix + KEY_GROUPS);
1550
+ } catch {
1551
+ }
1552
+ }
1553
+ };
1554
+ function readJson(storage, key) {
1555
+ let raw;
1556
+ try {
1557
+ raw = storage.getItem(key);
1558
+ } catch {
1559
+ return null;
1560
+ }
1561
+ if (!raw) return null;
1562
+ try {
1563
+ return JSON.parse(raw);
1564
+ } catch {
1565
+ return null;
1566
+ }
1567
+ }
1568
+ function writeJson(storage, key, value) {
1569
+ try {
1570
+ storage.setItem(key, JSON.stringify(value));
1571
+ } catch {
1572
+ }
1573
+ }
1574
+
1575
+ // src/web-vitals.ts
1576
+ var WebVitalsTracker = class {
1577
+ constructor(cfg, report) {
1578
+ this.cfg = cfg;
1579
+ this.report = report;
1580
+ this.observers = [];
1581
+ this.flushed = /* @__PURE__ */ new Set();
1582
+ this.cls = 0;
1583
+ this.clsEntries = [];
1584
+ this.inp = 0;
1585
+ this.cleanups = [];
1586
+ }
1587
+ install() {
1588
+ if (!this.cfg.enabled) return;
1589
+ if (typeof PerformanceObserver === "undefined") return;
1590
+ if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
1591
+ const doc = globalThis.document;
1592
+ try {
1593
+ const navObserver = new PerformanceObserver((list) => {
1594
+ for (const entry of list.getEntries()) {
1595
+ const e = entry;
1596
+ if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
1597
+ this.flushed.add("ttfb");
1598
+ this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
1599
+ }
1600
+ }
1601
+ });
1602
+ navObserver.observe({ type: "navigation", buffered: true });
1603
+ this.observers.push(navObserver);
1604
+ } catch {
1605
+ }
1606
+ try {
1607
+ const paintObserver = new PerformanceObserver((list) => {
1608
+ for (const entry of list.getEntries()) {
1609
+ if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
1610
+ this.flushed.add("fcp");
1611
+ this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
1612
+ }
1613
+ }
1614
+ });
1615
+ paintObserver.observe({ type: "paint", buffered: true });
1616
+ this.observers.push(paintObserver);
1617
+ } catch {
1618
+ }
1619
+ let lcpValue = 0;
1620
+ try {
1621
+ const lcpObserver = new PerformanceObserver((list) => {
1622
+ const entries = list.getEntries();
1623
+ const last = entries[entries.length - 1];
1624
+ if (last) lcpValue = last.startTime;
1625
+ });
1626
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
1627
+ this.observers.push(lcpObserver);
1628
+ } catch {
1629
+ }
1630
+ try {
1631
+ const clsObserver = new PerformanceObserver((list) => {
1632
+ for (const entry of list.getEntries()) {
1633
+ const e = entry;
1634
+ if (typeof e.value === "number" && !e.hadRecentInput) {
1635
+ this.cls += e.value;
1636
+ this.clsEntries.push(entry);
1637
+ }
1638
+ }
1639
+ });
1640
+ clsObserver.observe({ type: "layout-shift", buffered: true });
1641
+ this.observers.push(clsObserver);
1642
+ } catch {
1643
+ }
1644
+ try {
1645
+ const eventObserver = new PerformanceObserver((list) => {
1646
+ for (const entry of list.getEntries()) {
1647
+ const e = entry;
1648
+ if (e.interactionId && e.duration > this.inp) {
1649
+ this.inp = e.duration;
1650
+ }
1651
+ }
1652
+ });
1653
+ try {
1654
+ eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
1655
+ } catch {
1656
+ eventObserver.observe({ type: "first-input", buffered: true });
1657
+ }
1658
+ this.observers.push(eventObserver);
1659
+ } catch {
1660
+ }
1661
+ const flush = () => {
1662
+ if (lcpValue > 0 && !this.flushed.has("lcp")) {
1663
+ this.flushed.add("lcp");
1664
+ this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
1665
+ }
1666
+ if (this.cls > 0 && !this.flushed.has("cls")) {
1667
+ this.flushed.add("cls");
1668
+ this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
1669
+ }
1670
+ if (this.inp > 0 && !this.flushed.has("inp")) {
1671
+ this.flushed.add("inp");
1672
+ this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
1673
+ }
1674
+ };
1675
+ const onHidden = () => {
1676
+ if (doc.visibilityState === "hidden") flush();
1677
+ };
1678
+ doc.addEventListener("visibilitychange", onHidden);
1679
+ globalThis.window.addEventListener("pagehide", flush);
1680
+ this.cleanups.push(() => {
1681
+ doc.removeEventListener("visibilitychange", onHidden);
1682
+ globalThis.window.removeEventListener("pagehide", flush);
1683
+ });
1684
+ }
1685
+ uninstall() {
1686
+ for (const o of this.observers) {
1687
+ try {
1688
+ o.disconnect();
1689
+ } catch {
1690
+ }
1691
+ }
1692
+ this.observers = [];
1693
+ for (const fn of this.cleanups.splice(0)) {
1694
+ try {
1695
+ fn();
1696
+ } catch {
1697
+ }
1698
+ }
1699
+ }
1700
+ };
1701
+
1702
+ // src/consent.ts
1703
+ var ALL_GRANTED = {
1704
+ analytics: true,
1705
+ marketing: true,
1706
+ errors: true
1707
+ };
1708
+ var ConsentManager = class {
1709
+ constructor(options) {
1710
+ this.state = { ...ALL_GRANTED };
1711
+ this.dntDenied = false;
1712
+ if (options?.respectDnt && this.detectDnt()) {
1713
+ this.dntDenied = true;
1714
+ this.state = { analytics: false, marketing: false, errors: false };
1715
+ }
1716
+ }
1717
+ /**
1718
+ * Merge new dimensions onto the current state. Returns the resulting
1719
+ * snapshot. DNT-derived denies cannot be flipped back on by a `set`
1720
+ * call — once the browser says "don't track", we don't track even if
1721
+ * the developer code disagrees. That's the contract.
1722
+ */
1723
+ set(partial) {
1724
+ if (this.dntDenied) return { ...this.state };
1725
+ for (const k of Object.keys(partial)) {
1726
+ const v = partial[k];
1727
+ if (typeof v === "boolean") this.state[k] = v;
1728
+ }
1729
+ return { ...this.state };
1730
+ }
1731
+ /** Snapshot of the current state. */
1732
+ get() {
1733
+ return { ...this.state };
1734
+ }
1735
+ /** Convenience getters for hot paths. */
1736
+ get analytics() {
1737
+ return this.state.analytics;
1738
+ }
1739
+ get marketing() {
1740
+ return this.state.marketing;
1741
+ }
1742
+ get errors() {
1743
+ return this.state.errors;
1744
+ }
1745
+ /** True iff the constructor detected and applied DNT. */
1746
+ get isDntDenied() {
1747
+ return this.dntDenied;
1748
+ }
1749
+ detectDnt() {
1750
+ try {
1751
+ const nav = globalThis.navigator;
1752
+ if (!nav) return false;
1753
+ const sources = [
1754
+ nav.doNotTrack,
1755
+ nav.msDoNotTrack,
1756
+ globalThis.doNotTrack
1757
+ ];
1758
+ return sources.some((v) => v === "1" || v === "yes");
1759
+ } catch {
1760
+ return false;
1761
+ }
1762
+ }
1763
+ };
1764
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1765
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1766
+ var REPLACEMENT_EMAIL = "[email]";
1767
+ var REPLACEMENT_CARD = "[card]";
1768
+ function scrubPii(value) {
1769
+ if (!value) return value;
1770
+ let out = value;
1771
+ if (EMAIL_PATTERN.test(out)) {
1772
+ out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1773
+ }
1774
+ EMAIL_PATTERN.lastIndex = 0;
1775
+ if (CARD_PATTERN.test(out)) {
1776
+ out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1777
+ }
1778
+ CARD_PATTERN.lastIndex = 0;
1779
+ return out;
1780
+ }
1781
+ function scrubPiiFromProperties(properties) {
1782
+ const out = {};
1783
+ for (const k of Object.keys(properties)) {
1784
+ const v = properties[k];
1785
+ if (typeof v === "string") {
1786
+ out[k] = scrubPii(v);
1787
+ } else if (Array.isArray(v)) {
1788
+ out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1789
+ } else {
1790
+ out[k] = v;
1791
+ }
1792
+ }
1793
+ return out;
1794
+ }
1795
+
841
1796
  // src/crossdeck.ts
842
1797
  var CrossdeckClient = class {
843
1798
  constructor() {
@@ -882,6 +1837,7 @@ var CrossdeckClient = class {
882
1837
  message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
883
1838
  });
884
1839
  }
1840
+ const localDevMode = isLocalHostname();
885
1841
  const storage = options.storage ?? detectDefaultStorage();
886
1842
  const persistIdentity = options.persistIdentity ?? true;
887
1843
  const autoTrack = resolveAutoTrack(options.autoTrack);
@@ -908,14 +1864,31 @@ var CrossdeckClient = class {
908
1864
  const http = new HttpClient({
909
1865
  publicKey: opts.publicKey,
910
1866
  baseUrl: opts.baseUrl,
911
- sdkVersion: opts.sdkVersion
1867
+ sdkVersion: opts.sdkVersion,
1868
+ // Localhost auto-route: HttpClient short-circuits every request
1869
+ // to a successful no-op response when localDevMode is set.
1870
+ // SDK methods continue to work locally; nothing reaches the
1871
+ // server.
1872
+ localDevMode
912
1873
  });
1874
+ if (localDevMode) {
1875
+ console.log(
1876
+ "[crossdeck] Localhost detected \u2014 running in dev mode (no network calls). Set publicKey: 'cd_pub_test_\u2026' and deploy to a real domain to test against the Crossdeck Sandbox."
1877
+ );
1878
+ }
913
1879
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
914
1880
  const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
915
1881
  typeof globalThis.document !== "undefined";
916
1882
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
917
1883
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
918
1884
  const entitlements = new EntitlementCache();
1885
+ const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
1886
+ if (persistentEvents) {
1887
+ debug.emit(
1888
+ "sdk.queue_restored",
1889
+ "Restored persisted event queue from a prior session."
1890
+ );
1891
+ }
919
1892
  const events = new EventQueue({
920
1893
  http,
921
1894
  batchSize: opts.eventFlushBatchSize,
@@ -925,26 +1898,51 @@ var CrossdeckClient = class {
925
1898
  environment: opts.environment,
926
1899
  sdk: { name: SDK_NAME, version: opts.sdkVersion }
927
1900
  }),
1901
+ persistentStore: persistentEvents ?? void 0,
928
1902
  onFirstFlushSuccess: () => {
929
1903
  debug.emit(
930
1904
  "sdk.first_event_sent",
931
1905
  "First telemetry event received. View it in Live Events.",
932
1906
  { appId: opts.appId, environment: opts.environment }
933
1907
  );
1908
+ },
1909
+ onRetryScheduled: (info) => {
1910
+ debug.emit(
1911
+ "sdk.flush_retry_scheduled",
1912
+ `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
1913
+ { ...info }
1914
+ );
934
1915
  }
935
1916
  });
936
1917
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
1918
+ const superProps = new SuperPropertyStore(
1919
+ persistIdentity ? effectiveStorage : new MemoryStorage(),
1920
+ opts.storagePrefix
1921
+ );
1922
+ const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
1923
+ if (consent.isDntDenied) {
1924
+ debug.emit(
1925
+ "sdk.consent_dnt_applied",
1926
+ "Do Not Track detected \u2014 all tracking dimensions denied at init."
1927
+ );
1928
+ }
937
1929
  this.state = {
938
1930
  http,
939
1931
  identity,
940
1932
  entitlements,
941
1933
  events,
942
1934
  autoTracker: null,
1935
+ webVitals: null,
1936
+ superProps,
1937
+ consent,
1938
+ scrubPii: options.scrubPii !== false,
943
1939
  deviceInfo,
944
1940
  options: opts,
945
1941
  debug,
946
1942
  developerUserId: null,
947
- uninstallUnloadFlush: null
1943
+ uninstallUnloadFlush: null,
1944
+ lastServerTime: null,
1945
+ lastClientTime: null
948
1946
  };
949
1947
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
950
1948
  appId: opts.appId,
@@ -959,10 +1957,18 @@ var CrossdeckClient = class {
959
1957
  this.state.autoTracker = tracker;
960
1958
  tracker.install();
961
1959
  }
1960
+ if (autoTrack.webVitals) {
1961
+ const vitals = new WebVitalsTracker(
1962
+ { enabled: true },
1963
+ (name, properties) => this.track(name, properties)
1964
+ );
1965
+ this.state.webVitals = vitals;
1966
+ vitals.install();
1967
+ }
962
1968
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
963
1969
  void this.flush({ keepalive: true }).catch(() => void 0);
964
1970
  });
965
- if (opts.autoHeartbeat) {
1971
+ if (opts.autoHeartbeat && !localDevMode) {
966
1972
  void this.heartbeat().catch(() => void 0);
967
1973
  }
968
1974
  }
@@ -982,8 +1988,19 @@ var CrossdeckClient = class {
982
1988
  /**
983
1989
  * Link the anonymous device to a developer-supplied user ID. Cache
984
1990
  * the resolved Crossdeck customer for follow-up calls.
1991
+ *
1992
+ * v0.9.0+ accepts an optional `traits` bag — profile data (name,
1993
+ * plan, signupDate, role) persisted on the Crossdeck customer record
1994
+ * and queryable from dashboards. Traits are sanitised through the
1995
+ * same validator that gates `track()` properties, so a `{ avatar:
1996
+ * <File>, onSave: () => {} }` payload can't corrupt the alias call.
1997
+ *
1998
+ * Crossdeck.identify("user_847", {
1999
+ * email: "wes@pinet.co.za",
2000
+ * traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
2001
+ * });
985
2002
  */
986
- async identify(userId, _options) {
2003
+ async identify(userId, options) {
987
2004
  const s = this.requireStarted();
988
2005
  if (!userId) {
989
2006
  throw new CrossdeckError({
@@ -992,13 +2009,163 @@ var CrossdeckClient = class {
992
2009
  message: "identify(userId) requires a non-empty userId."
993
2010
  });
994
2011
  }
2012
+ if (!s.consent.analytics) {
2013
+ s.debug.emit(
2014
+ "sdk.consent_denied",
2015
+ `identify() skipped \u2014 consent denied for analytics.`
2016
+ );
2017
+ return {
2018
+ object: "alias_result",
2019
+ crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
2020
+ linked: [],
2021
+ mergePending: false,
2022
+ env: s.options.environment
2023
+ };
2024
+ }
2025
+ const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
2026
+ const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
2027
+ if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
2028
+ for (const w of traitsValidation.warnings) {
2029
+ s.debug.emit(
2030
+ "sdk.property_coerced",
2031
+ `identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2032
+ { key: w.key, kind: w.kind }
2033
+ );
2034
+ }
2035
+ }
2036
+ const body = {
2037
+ userId,
2038
+ anonymousId: s.identity.anonymousId
2039
+ };
2040
+ if (options?.email) body.email = options.email;
2041
+ if (traits) body.traits = traits;
995
2042
  const result = await s.http.request("POST", "/identity/alias", {
996
- body: { userId, anonymousId: s.identity.anonymousId }
2043
+ body
997
2044
  });
998
2045
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
999
2046
  s.developerUserId = userId;
1000
2047
  return result;
1001
2048
  }
2049
+ /**
2050
+ * Register super-properties — Mixpanel pattern. Once set, every
2051
+ * subsequent event of THIS SDK instance carries these keys on its
2052
+ * properties bag automatically.
2053
+ *
2054
+ * Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
2055
+ * Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
2056
+ *
2057
+ * Values that are `null` are deleted (the explicit "stop tracking
2058
+ * this key" idiom). Returns the resulting bag.
2059
+ *
2060
+ * Sanitised through `validateEventProperties` so a `{ avatar: File }`
2061
+ * payload can't poison the queue at flush time.
2062
+ */
2063
+ register(properties) {
2064
+ const s = this.requireStarted();
2065
+ const validation = validateEventProperties(properties);
2066
+ return s.superProps.register(validation.properties);
2067
+ }
2068
+ /** Remove a single super-property key. Idempotent. */
2069
+ unregister(key) {
2070
+ const s = this.requireStarted();
2071
+ s.superProps.unregister(key);
2072
+ }
2073
+ /** Snapshot of the current super-property bag. */
2074
+ getSuperProperties() {
2075
+ if (!this.state) return {};
2076
+ return this.state.superProps.getSuperProperties();
2077
+ }
2078
+ /**
2079
+ * Associate the current user with a group (org, team, account, etc.).
2080
+ * Mixpanel / Segment "Group Analytics" pattern.
2081
+ *
2082
+ * Crossdeck.group("org", "acme_inc");
2083
+ * Crossdeck.group("team", "design", { headcount: 12 });
2084
+ *
2085
+ * Once set, every subsequent event carries `$groups.<type>: id` on
2086
+ * its properties bag, enabling B2B dashboards ("how is Acme using
2087
+ * the product"). Pass `id: null` to clear a group membership.
2088
+ */
2089
+ group(type, id, traits) {
2090
+ const s = this.requireStarted();
2091
+ if (!type) {
2092
+ throw new CrossdeckError({
2093
+ type: "invalid_request_error",
2094
+ code: "missing_group_type",
2095
+ message: "group(type, id) requires a non-empty type."
2096
+ });
2097
+ }
2098
+ const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
2099
+ s.superProps.setGroup(type, id, sanitisedTraits);
2100
+ }
2101
+ /** Snapshot of the current groups map keyed by type. */
2102
+ getGroups() {
2103
+ if (!this.state) return {};
2104
+ return this.state.superProps.getGroups();
2105
+ }
2106
+ /**
2107
+ * Update consent state. Three independent dimensions:
2108
+ *
2109
+ * analytics — track() + identify() + auto-emissions
2110
+ * marketing — paid-traffic click IDs + referrer URL on events
2111
+ * errors — Web Vitals + (future) error reporting
2112
+ *
2113
+ * Each defaults to `true` (granted). Pass partial state — only the
2114
+ * keys you provide are changed.
2115
+ *
2116
+ * Crossdeck.consent({ analytics: false });
2117
+ * Crossdeck.consent({ marketing: true, errors: true });
2118
+ *
2119
+ * DNT-derived denies cannot be flipped back on; if the browser said
2120
+ * "don't track" we don't track even if the developer code disagrees.
2121
+ */
2122
+ consent(state) {
2123
+ const s = this.requireStarted();
2124
+ const next = s.consent.set(state);
2125
+ s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
2126
+ return next;
2127
+ }
2128
+ /** Snapshot of the current consent state. */
2129
+ consentStatus() {
2130
+ if (!this.state) {
2131
+ return { analytics: true, marketing: true, errors: true };
2132
+ }
2133
+ return this.state.consent.get();
2134
+ }
2135
+ /**
2136
+ * GDPR/CCPA "right to be forgotten" — calls the backend's
2137
+ * /v1/identity/forget endpoint to schedule a server-side deletion of
2138
+ * the customer's events and profile, then wipes all local state
2139
+ * (identity, entitlements, queue, super-props, persistent stores).
2140
+ *
2141
+ * Idempotent. Safe to call when no identity has been established
2142
+ * (it just wipes the empty local state).
2143
+ *
2144
+ * After forget() resolves, the SDK is in the same shape as if the
2145
+ * developer had called `Crossdeck.reset()` — a fresh anonymousId is
2146
+ * minted and the next session is a brand new identity-graph entry.
2147
+ */
2148
+ async forget() {
2149
+ const s = this.requireStarted();
2150
+ const identityQuery = this.identityQueryParams();
2151
+ try {
2152
+ await s.http.request("POST", "/identity/forget", {
2153
+ body: {
2154
+ // Send every identity hint we hold; the server resolves the
2155
+ // canonical customer record and queues deletion. Missing
2156
+ // endpoint (older backend) gracefully degrades — local state
2157
+ // still wipes via the reset() call below.
2158
+ ...identityQuery
2159
+ }
2160
+ });
2161
+ } catch (err) {
2162
+ s.debug.emit(
2163
+ "sdk.consent_denied",
2164
+ `forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
2165
+ );
2166
+ }
2167
+ this.reset();
2168
+ }
1002
2169
  /**
1003
2170
  * Read the current customer's active entitlements from the server.
1004
2171
  * Updates the local cache so subsequent isEntitled() calls answer
@@ -1076,6 +2243,17 @@ var CrossdeckClient = class {
1076
2243
  message: "track(name) requires a non-empty name."
1077
2244
  });
1078
2245
  }
2246
+ const isWebVital = name.startsWith("webvitals.");
2247
+ const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
2248
+ if (!consentGateOk) {
2249
+ if (s.debug.enabled) {
2250
+ s.debug.emit(
2251
+ "sdk.consent_denied",
2252
+ `Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
2253
+ );
2254
+ }
2255
+ return;
2256
+ }
1079
2257
  if (s.debug.enabled && properties) {
1080
2258
  const flagged = findSensitivePropertyKeys(properties);
1081
2259
  if (flagged.length > 0) {
@@ -1092,9 +2270,21 @@ var CrossdeckClient = class {
1092
2270
  "Using anonymous user until identify(userId) is called."
1093
2271
  );
1094
2272
  }
2273
+ const validation = validateEventProperties(properties);
2274
+ if (s.debug.enabled && validation.warnings.length > 0) {
2275
+ for (const w of validation.warnings) {
2276
+ s.debug.emit(
2277
+ "sdk.property_coerced",
2278
+ `Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2279
+ { eventName: name, key: w.key, kind: w.kind }
2280
+ );
2281
+ }
2282
+ }
1095
2283
  const enriched = { ...s.deviceInfo };
1096
2284
  const sessionId = s.autoTracker?.currentSessionId;
1097
2285
  if (sessionId) enriched.sessionId = sessionId;
2286
+ const pageviewId = s.autoTracker?.currentPageviewId;
2287
+ if (pageviewId) enriched.pageviewId = pageviewId;
1098
2288
  const acquisition = s.autoTracker?.currentAcquisition;
1099
2289
  if (acquisition) {
1100
2290
  if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
@@ -1102,14 +2292,31 @@ var CrossdeckClient = class {
1102
2292
  if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1103
2293
  if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1104
2294
  if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1105
- if (acquisition.referrer) enriched.referrer = acquisition.referrer;
2295
+ if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
2296
+ if (s.consent.marketing) {
2297
+ if (acquisition.gclid) enriched.gclid = acquisition.gclid;
2298
+ if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
2299
+ if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
2300
+ if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
2301
+ if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
2302
+ if (acquisition.twclid) enriched.twclid = acquisition.twclid;
2303
+ }
2304
+ }
2305
+ const supers = s.superProps.getSuperProperties();
2306
+ for (const k of Object.keys(supers)) {
2307
+ if (!(k in enriched)) enriched[k] = supers[k];
2308
+ }
2309
+ const groupIds = s.superProps.getGroupIds();
2310
+ if (Object.keys(groupIds).length > 0) {
2311
+ enriched.$groups = groupIds;
1106
2312
  }
1107
- if (properties) Object.assign(enriched, properties);
2313
+ Object.assign(enriched, validation.properties);
2314
+ const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
1108
2315
  const event = {
1109
2316
  eventId: this.mintEventId(),
1110
2317
  name,
1111
2318
  timestamp: Date.now(),
1112
- properties: enriched
2319
+ properties: finalProperties
1113
2320
  };
1114
2321
  Object.assign(event, this.identityHintForEvent());
1115
2322
  s.events.enqueue(event);
@@ -1187,7 +2394,12 @@ var CrossdeckClient = class {
1187
2394
  */
1188
2395
  async heartbeat() {
1189
2396
  const s = this.requireStarted();
1190
- return await s.http.request("GET", "/sdk/heartbeat");
2397
+ const result = await s.http.request("GET", "/sdk/heartbeat");
2398
+ if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
2399
+ s.lastServerTime = result.serverTime;
2400
+ s.lastClientTime = Date.now();
2401
+ }
2402
+ return result;
1191
2403
  }
1192
2404
  /**
1193
2405
  * Wipe persisted identity + entitlement cache. Use on logout. The
@@ -1196,10 +2408,17 @@ var CrossdeckClient = class {
1196
2408
  */
1197
2409
  reset() {
1198
2410
  if (!this.state) return;
2411
+ if (this.state.developerUserId) {
2412
+ try {
2413
+ this.track("user.signed_out", { auto: true });
2414
+ } catch {
2415
+ }
2416
+ }
1199
2417
  this.state.autoTracker?.uninstall();
1200
2418
  this.state.identity.reset();
1201
2419
  this.state.entitlements.clear();
1202
2420
  this.state.events.reset();
2421
+ this.state.superProps.clear();
1203
2422
  this.state.developerUserId = null;
1204
2423
  if (this.state.autoTracker) {
1205
2424
  const tracker = new AutoTracker(
@@ -1227,17 +2446,21 @@ var CrossdeckClient = class {
1227
2446
  developerUserId: null,
1228
2447
  sdkVersion: null,
1229
2448
  baseUrl: null,
1230
- entitlements: { count: 0, lastUpdated: 0 },
2449
+ clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
2450
+ entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
1231
2451
  events: {
1232
2452
  buffered: 0,
1233
2453
  dropped: 0,
1234
2454
  inFlight: 0,
1235
2455
  lastFlushAt: 0,
1236
- lastError: null
2456
+ lastError: null,
2457
+ consecutiveFailures: 0,
2458
+ nextRetryAt: null
1237
2459
  }
1238
2460
  };
1239
2461
  }
1240
2462
  const s = this.state;
2463
+ const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
1241
2464
  return {
1242
2465
  started: true,
1243
2466
  anonymousId: s.identity.anonymousId,
@@ -1245,9 +2468,15 @@ var CrossdeckClient = class {
1245
2468
  developerUserId: s.developerUserId,
1246
2469
  sdkVersion: s.options.sdkVersion,
1247
2470
  baseUrl: s.options.baseUrl,
2471
+ clock: {
2472
+ lastServerTime: s.lastServerTime,
2473
+ lastClientTime: s.lastClientTime,
2474
+ skewMs
2475
+ },
1248
2476
  entitlements: {
1249
2477
  count: s.entitlements.list().length,
1250
- lastUpdated: s.entitlements.freshness
2478
+ lastUpdated: s.entitlements.freshness,
2479
+ listenerErrors: s.entitlements.listenerErrors
1251
2480
  },
1252
2481
  events: s.events.getStats()
1253
2482
  };
@@ -1276,14 +2505,30 @@ var CrossdeckClient = class {
1276
2505
  if (s.developerUserId) return { userId: s.developerUserId };
1277
2506
  return { anonymousId: s.identity.anonymousId };
1278
2507
  }
1279
- /** Pick the right identity hint to embed on a queued event. */
2508
+ /**
2509
+ * Embed every known identity axis on the event. Earlier this returned
2510
+ * just the highest-priority hint (cdcust → developerUserId → anonymousId)
2511
+ * to keep payloads small, but that leaked into analytics: once a user
2512
+ * was logged in, every subsequent page.viewed shipped without
2513
+ * anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
2514
+ * counted 0 visitors for the entire authenticated app.
2515
+ *
2516
+ * Bank-grade rule: the server is the single source of truth on
2517
+ * dedup. Send everything we know; let CH count by whichever axis
2518
+ * matches the question. Each field is at most 32 bytes — sending
2519
+ * three on every event costs ~80 bytes per request, which is
2520
+ * trivial compared to the analytics correctness it buys.
2521
+ */
1280
2522
  identityHintForEvent() {
1281
2523
  const s = this.requireStarted();
2524
+ const hint = {
2525
+ anonymousId: s.identity.anonymousId
2526
+ };
2527
+ if (s.developerUserId) hint.developerUserId = s.developerUserId;
1282
2528
  if (s.identity.crossdeckCustomerId) {
1283
- return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
2529
+ hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
1284
2530
  }
1285
- if (s.developerUserId) return { developerUserId: s.developerUserId };
1286
- return { anonymousId: s.identity.anonymousId };
2531
+ return hint;
1287
2532
  }
1288
2533
  mintEventId() {
1289
2534
  const ts = Date.now().toString(36);
@@ -1296,9 +2541,28 @@ function inferEnvFromKey(publicKey) {
1296
2541
  if (publicKey.startsWith("cd_pub_live_")) return "production";
1297
2542
  return null;
1298
2543
  }
2544
+ function isLocalHostname() {
2545
+ const w = globalThis.window;
2546
+ if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
2547
+ const hostname = w?.location?.hostname;
2548
+ if (!hostname) return false;
2549
+ if (hostname === "localhost" || hostname === "127.0.0.1") return true;
2550
+ if (hostname === "::1" || hostname === "[::1]") return true;
2551
+ if (hostname.endsWith(".local")) return true;
2552
+ if (/^10\./.test(hostname)) return true;
2553
+ if (/^192\.168\./.test(hostname)) return true;
2554
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
2555
+ return false;
2556
+ }
1299
2557
  function resolveAutoTrack(input) {
1300
2558
  if (input === false) {
1301
- return { sessions: false, pageViews: false, deviceInfo: false };
2559
+ return {
2560
+ sessions: false,
2561
+ pageViews: false,
2562
+ deviceInfo: false,
2563
+ clicks: false,
2564
+ webVitals: false
2565
+ };
1302
2566
  }
1303
2567
  if (input === void 0 || input === true) {
1304
2568
  return { ...DEFAULT_AUTO_TRACK };
@@ -1306,7 +2570,9 @@ function resolveAutoTrack(input) {
1306
2570
  return {
1307
2571
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1308
2572
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1309
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
2573
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
2574
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
2575
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
1310
2576
  };
1311
2577
  }
1312
2578
  function installUnloadFlush(onUnload) {