@cross-deck/web 0.7.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/CHANGELOG.md +93 -0
- package/dist/crossdeck.umd.min.js +2 -0
- package/dist/crossdeck.umd.min.js.map +1 -0
- package/dist/error-codes.json +91 -0
- package/dist/index.cjs +1135 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +258 -4
- package/dist/index.d.ts +258 -4
- package/dist/index.mjs +1132 -29
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1035 -28
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +1035 -28
- package/dist/react.mjs.map +1 -1
- package/dist/vue.cjs +2675 -0
- package/dist/vue.cjs.map +1 -0
- package/dist/vue.d.mts +37 -0
- package/dist/vue.d.ts +37 -0
- package/dist/vue.mjs +2649 -0
- package/dist/vue.mjs.map +1 -0
- package/package.json +25 -6
package/dist/react.cjs
CHANGED
|
@@ -35,11 +35,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
|
|
|
35
35
|
this.code = payload.code;
|
|
36
36
|
this.requestId = payload.requestId;
|
|
37
37
|
this.status = payload.status;
|
|
38
|
+
this.retryAfterMs = payload.retryAfterMs;
|
|
38
39
|
Object.setPrototypeOf(this, _CrossdeckError.prototype);
|
|
39
40
|
}
|
|
40
41
|
};
|
|
41
42
|
async function crossdeckErrorFromResponse(res) {
|
|
42
43
|
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
44
|
+
const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
|
|
43
45
|
let body;
|
|
44
46
|
try {
|
|
45
47
|
body = await res.json();
|
|
@@ -53,7 +55,8 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
53
55
|
code: envelope.code,
|
|
54
56
|
message: envelope.message ?? `HTTP ${res.status}`,
|
|
55
57
|
requestId: envelope.request_id ?? requestId,
|
|
56
|
-
status: res.status
|
|
58
|
+
status: res.status,
|
|
59
|
+
retryAfterMs
|
|
57
60
|
});
|
|
58
61
|
}
|
|
59
62
|
return new CrossdeckError({
|
|
@@ -61,9 +64,25 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
61
64
|
code: `http_${res.status}`,
|
|
62
65
|
message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
|
|
63
66
|
requestId,
|
|
64
|
-
status: res.status
|
|
67
|
+
status: res.status,
|
|
68
|
+
retryAfterMs
|
|
65
69
|
});
|
|
66
70
|
}
|
|
71
|
+
function parseRetryAfterHeader(value) {
|
|
72
|
+
if (!value) return void 0;
|
|
73
|
+
const trimmed = value.trim();
|
|
74
|
+
if (!trimmed) return void 0;
|
|
75
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
76
|
+
const secs = Number(trimmed);
|
|
77
|
+
if (!Number.isFinite(secs) || secs < 0) return void 0;
|
|
78
|
+
return Math.round(secs * 1e3);
|
|
79
|
+
}
|
|
80
|
+
if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
|
|
81
|
+
const target = Date.parse(trimmed);
|
|
82
|
+
if (!Number.isFinite(target)) return void 0;
|
|
83
|
+
const delta = target - Date.now();
|
|
84
|
+
return delta > 0 ? delta : 0;
|
|
85
|
+
}
|
|
67
86
|
function typeMapForStatus(status) {
|
|
68
87
|
if (status === 401) return "authentication_error";
|
|
69
88
|
if (status === 403) return "permission_error";
|
|
@@ -74,8 +93,9 @@ function typeMapForStatus(status) {
|
|
|
74
93
|
|
|
75
94
|
// src/http.ts
|
|
76
95
|
var SDK_NAME = "@cross-deck/web";
|
|
77
|
-
var SDK_VERSION = "0.
|
|
96
|
+
var SDK_VERSION = "0.10.0";
|
|
78
97
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
98
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
79
99
|
var HttpClient = class {
|
|
80
100
|
constructor(config) {
|
|
81
101
|
this.config = config;
|
|
@@ -99,25 +119,38 @@ var HttpClient = class {
|
|
|
99
119
|
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
100
120
|
Accept: "application/json"
|
|
101
121
|
};
|
|
122
|
+
if (options.idempotencyKey) {
|
|
123
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
124
|
+
}
|
|
102
125
|
let bodyInit;
|
|
103
126
|
if (options.body !== void 0) {
|
|
104
127
|
headers["Content-Type"] = "application/json";
|
|
105
128
|
bodyInit = JSON.stringify(options.body);
|
|
106
129
|
}
|
|
130
|
+
const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
131
|
+
const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
|
|
132
|
+
let timeoutHandle = null;
|
|
133
|
+
if (controller && effectiveTimeout > 0) {
|
|
134
|
+
timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
|
|
135
|
+
}
|
|
107
136
|
let response;
|
|
108
137
|
try {
|
|
109
138
|
response = await fetch(url, {
|
|
110
139
|
method,
|
|
111
140
|
headers,
|
|
112
141
|
body: bodyInit,
|
|
113
|
-
keepalive: options.keepalive === true
|
|
142
|
+
keepalive: options.keepalive === true,
|
|
143
|
+
signal: controller?.signal
|
|
114
144
|
});
|
|
115
145
|
} catch (err) {
|
|
146
|
+
const aborted = controller?.signal?.aborted === true;
|
|
116
147
|
throw new CrossdeckError({
|
|
117
148
|
type: "network_error",
|
|
118
|
-
code: "fetch_failed",
|
|
119
|
-
message: err instanceof Error ? err.message : "fetch failed"
|
|
149
|
+
code: aborted ? "request_timeout" : "fetch_failed",
|
|
150
|
+
message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
|
|
120
151
|
});
|
|
152
|
+
} finally {
|
|
153
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
121
154
|
}
|
|
122
155
|
if (!response.ok) {
|
|
123
156
|
throw await crossdeckErrorFromResponse(response);
|
|
@@ -314,6 +347,7 @@ var EntitlementCache = class {
|
|
|
314
347
|
this.all = [];
|
|
315
348
|
this.lastUpdated = 0;
|
|
316
349
|
this.listeners = /* @__PURE__ */ new Set();
|
|
350
|
+
this.listenerErrorCount = 0;
|
|
317
351
|
}
|
|
318
352
|
/** Sync read — true iff the entitlement key is currently active. */
|
|
319
353
|
isEntitled(key) {
|
|
@@ -327,6 +361,15 @@ var EntitlementCache = class {
|
|
|
327
361
|
get freshness() {
|
|
328
362
|
return this.lastUpdated;
|
|
329
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Cumulative count of listener invocations that threw. Listener errors
|
|
366
|
+
* are swallowed (a buggy consumer must not crash the SDK) but the
|
|
367
|
+
* counter lets diagnostics() surface "you have a broken subscriber"
|
|
368
|
+
* without putting the developer in a debug session.
|
|
369
|
+
*/
|
|
370
|
+
get listenerErrors() {
|
|
371
|
+
return this.listenerErrorCount;
|
|
372
|
+
}
|
|
330
373
|
/**
|
|
331
374
|
* Replace the cache with a fresh server response. The backend already
|
|
332
375
|
* filters to active + env-matching, so we don't re-filter — just trust
|
|
@@ -380,11 +423,54 @@ var EntitlementCache = class {
|
|
|
380
423
|
try {
|
|
381
424
|
listener(snapshot);
|
|
382
425
|
} catch {
|
|
426
|
+
this.listenerErrorCount += 1;
|
|
383
427
|
}
|
|
384
428
|
}
|
|
385
429
|
}
|
|
386
430
|
};
|
|
387
431
|
|
|
432
|
+
// src/retry-policy.ts
|
|
433
|
+
var DEFAULT_BASE = 1e3;
|
|
434
|
+
var DEFAULT_MAX = 6e4;
|
|
435
|
+
var DEFAULT_FACTOR = 2;
|
|
436
|
+
var DEFAULT_WARN = 8;
|
|
437
|
+
function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
|
|
438
|
+
const base = options.baseMs ?? DEFAULT_BASE;
|
|
439
|
+
const max = options.maxMs ?? DEFAULT_MAX;
|
|
440
|
+
const factor = options.factor ?? DEFAULT_FACTOR;
|
|
441
|
+
const safeAttempts = Math.min(attempts, 30);
|
|
442
|
+
const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
|
|
443
|
+
const jittered = ceiling * random();
|
|
444
|
+
if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
|
|
445
|
+
return Math.min(max, retryAfterMs);
|
|
446
|
+
}
|
|
447
|
+
return Math.max(0, Math.round(jittered));
|
|
448
|
+
}
|
|
449
|
+
var RetryPolicy = class {
|
|
450
|
+
constructor(options = {}) {
|
|
451
|
+
this.options = options;
|
|
452
|
+
this.attempts = 0;
|
|
453
|
+
}
|
|
454
|
+
/** How many consecutive failures since the last success. */
|
|
455
|
+
get consecutiveFailures() {
|
|
456
|
+
return this.attempts;
|
|
457
|
+
}
|
|
458
|
+
/** Whether we've crossed the failuresBeforeWarn threshold. */
|
|
459
|
+
get isWarning() {
|
|
460
|
+
return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
|
|
461
|
+
}
|
|
462
|
+
/** Schedule-time delay for the NEXT retry. Increments the counter. */
|
|
463
|
+
nextDelay(retryAfterMs, random = Math.random) {
|
|
464
|
+
const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
|
|
465
|
+
this.attempts += 1;
|
|
466
|
+
return delay;
|
|
467
|
+
}
|
|
468
|
+
/** Mark a successful flush — reset the counter. */
|
|
469
|
+
recordSuccess() {
|
|
470
|
+
this.attempts = 0;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
388
474
|
// src/event-queue.ts
|
|
389
475
|
var HARD_BUFFER_CAP = 1e3;
|
|
390
476
|
var EventQueue = class {
|
|
@@ -397,6 +483,22 @@ var EventQueue = class {
|
|
|
397
483
|
this.lastError = null;
|
|
398
484
|
this.cancelTimer = null;
|
|
399
485
|
this.firstFlushFired = false;
|
|
486
|
+
this.nextRetryAt = null;
|
|
487
|
+
this.retry = new RetryPolicy(cfg.retry ?? {});
|
|
488
|
+
this.persistent = cfg.persistentStore ?? null;
|
|
489
|
+
if (this.persistent) {
|
|
490
|
+
const restored = this.persistent.load();
|
|
491
|
+
if (restored.length > 0) {
|
|
492
|
+
if (restored.length > HARD_BUFFER_CAP) {
|
|
493
|
+
this.dropped += restored.length - HARD_BUFFER_CAP;
|
|
494
|
+
this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
|
|
495
|
+
} else {
|
|
496
|
+
this.buffer = restored;
|
|
497
|
+
}
|
|
498
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
499
|
+
this.scheduleIdleFlush();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
400
502
|
}
|
|
401
503
|
enqueue(event) {
|
|
402
504
|
this.buffer.push(event);
|
|
@@ -406,6 +508,8 @@ var EventQueue = class {
|
|
|
406
508
|
this.dropped += overflow;
|
|
407
509
|
this.cfg.onDrop?.(overflow);
|
|
408
510
|
}
|
|
511
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
512
|
+
this.persistent?.save(this.buffer);
|
|
409
513
|
if (this.buffer.length >= this.cfg.batchSize) {
|
|
410
514
|
void this.flush();
|
|
411
515
|
} else {
|
|
@@ -415,7 +519,7 @@ var EventQueue = class {
|
|
|
415
519
|
/**
|
|
416
520
|
* Flush the buffer to /v1/events. Resolves when the network call
|
|
417
521
|
* completes (success or failure). On failure, events stay in the
|
|
418
|
-
* buffer for the next
|
|
522
|
+
* buffer for the next scheduled retry.
|
|
419
523
|
*
|
|
420
524
|
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
421
525
|
* browser keeps the request alive past page unload. Use this for
|
|
@@ -424,25 +528,32 @@ var EventQueue = class {
|
|
|
424
528
|
async flush(options = {}) {
|
|
425
529
|
if (this.buffer.length === 0) return null;
|
|
426
530
|
this.cancelTimerIfSet();
|
|
531
|
+
this.nextRetryAt = null;
|
|
427
532
|
const batch = this.buffer.splice(0);
|
|
533
|
+
const batchId = this.mintBatchId();
|
|
428
534
|
this.inFlight += batch.length;
|
|
535
|
+
this.persistent?.save(this.buffer);
|
|
536
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
429
537
|
try {
|
|
430
538
|
const env = this.cfg.envelope();
|
|
431
539
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
432
540
|
body: {
|
|
433
541
|
// NorthStar §13.1 batch envelope. The backend validates these
|
|
434
|
-
// against the API-key-resolved app and rejects mismatches
|
|
435
|
-
// (env_mismatch).
|
|
542
|
+
// against the API-key-resolved app and rejects mismatches
|
|
543
|
+
// loudly (env_mismatch).
|
|
436
544
|
appId: env.appId,
|
|
437
545
|
environment: env.environment,
|
|
438
546
|
sdk: env.sdk,
|
|
439
547
|
events: batch
|
|
440
548
|
},
|
|
441
|
-
keepalive: options.keepalive === true
|
|
549
|
+
keepalive: options.keepalive === true,
|
|
550
|
+
idempotencyKey: batchId
|
|
442
551
|
});
|
|
443
552
|
this.lastFlushAt = Date.now();
|
|
444
553
|
this.lastError = null;
|
|
445
554
|
this.inFlight -= batch.length;
|
|
555
|
+
this.retry.recordSuccess();
|
|
556
|
+
this.persistent?.save(this.buffer);
|
|
446
557
|
if (!this.firstFlushFired) {
|
|
447
558
|
this.firstFlushFired = true;
|
|
448
559
|
this.cfg.onFirstFlushSuccess?.();
|
|
@@ -451,18 +562,33 @@ var EventQueue = class {
|
|
|
451
562
|
} catch (err) {
|
|
452
563
|
this.buffer.unshift(...batch);
|
|
453
564
|
this.inFlight -= batch.length;
|
|
454
|
-
|
|
455
|
-
this.
|
|
565
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
566
|
+
this.lastError = message;
|
|
567
|
+
this.persistent?.save(this.buffer);
|
|
568
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
569
|
+
const retryAfterMs = extractRetryAfterMs(err);
|
|
570
|
+
const delay = this.retry.nextDelay(retryAfterMs);
|
|
571
|
+
this.scheduleRetry(delay);
|
|
572
|
+
this.cfg.onRetryScheduled?.({
|
|
573
|
+
delayMs: delay,
|
|
574
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
575
|
+
retryAfterMs,
|
|
576
|
+
lastError: message
|
|
577
|
+
});
|
|
456
578
|
return null;
|
|
457
579
|
}
|
|
458
580
|
}
|
|
459
|
-
/** Cancel any pending timer and clear in-memory state. */
|
|
581
|
+
/** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
|
|
460
582
|
reset() {
|
|
461
583
|
this.cancelTimerIfSet();
|
|
584
|
+
this.nextRetryAt = null;
|
|
462
585
|
this.buffer = [];
|
|
463
586
|
this.dropped = 0;
|
|
464
587
|
this.inFlight = 0;
|
|
465
588
|
this.lastError = null;
|
|
589
|
+
this.retry.recordSuccess();
|
|
590
|
+
this.persistent?.clear();
|
|
591
|
+
this.cfg.onBufferChange?.(0);
|
|
466
592
|
}
|
|
467
593
|
getStats() {
|
|
468
594
|
return {
|
|
@@ -470,9 +596,12 @@ var EventQueue = class {
|
|
|
470
596
|
dropped: this.dropped,
|
|
471
597
|
inFlight: this.inFlight,
|
|
472
598
|
lastFlushAt: this.lastFlushAt,
|
|
473
|
-
lastError: this.lastError
|
|
599
|
+
lastError: this.lastError,
|
|
600
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
601
|
+
nextRetryAt: this.nextRetryAt
|
|
474
602
|
};
|
|
475
603
|
}
|
|
604
|
+
// ---------- internal scheduling ----------
|
|
476
605
|
scheduleIdleFlush() {
|
|
477
606
|
this.cancelTimerIfSet();
|
|
478
607
|
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
@@ -480,13 +609,31 @@ var EventQueue = class {
|
|
|
480
609
|
void this.flush();
|
|
481
610
|
}, this.cfg.intervalMs);
|
|
482
611
|
}
|
|
612
|
+
scheduleRetry(delayMs) {
|
|
613
|
+
this.cancelTimerIfSet();
|
|
614
|
+
this.nextRetryAt = Date.now() + delayMs;
|
|
615
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
616
|
+
this.cancelTimer = sched(() => {
|
|
617
|
+
void this.flush();
|
|
618
|
+
}, delayMs);
|
|
619
|
+
}
|
|
483
620
|
cancelTimerIfSet() {
|
|
484
621
|
if (this.cancelTimer) {
|
|
485
622
|
this.cancelTimer();
|
|
486
623
|
this.cancelTimer = null;
|
|
487
624
|
}
|
|
488
625
|
}
|
|
626
|
+
mintBatchId() {
|
|
627
|
+
return `batch_${Date.now().toString(36)}${randomChars(10)}`;
|
|
628
|
+
}
|
|
489
629
|
};
|
|
630
|
+
function extractRetryAfterMs(err) {
|
|
631
|
+
if (err && typeof err === "object" && "retryAfterMs" in err) {
|
|
632
|
+
const v = err.retryAfterMs;
|
|
633
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
|
|
634
|
+
}
|
|
635
|
+
return void 0;
|
|
636
|
+
}
|
|
490
637
|
function defaultScheduler(fn, ms) {
|
|
491
638
|
const id = setTimeout(fn, ms);
|
|
492
639
|
if (typeof id.unref === "function") {
|
|
@@ -498,6 +645,87 @@ function defaultScheduler(fn, ms) {
|
|
|
498
645
|
return () => clearTimeout(id);
|
|
499
646
|
}
|
|
500
647
|
|
|
648
|
+
// src/event-storage.ts
|
|
649
|
+
var PersistentEventStore = class {
|
|
650
|
+
constructor(options) {
|
|
651
|
+
this.options = options;
|
|
652
|
+
this.writeScheduled = false;
|
|
653
|
+
// Pending events captured on the most recent write request. We keep
|
|
654
|
+
// the latest snapshot ref so a debounced write always picks up the
|
|
655
|
+
// freshest buffer state.
|
|
656
|
+
this.pendingSnapshot = null;
|
|
657
|
+
this.key = `${options.prefix}queue.v1`;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Read the persisted queue on boot. Returns an empty array (with no
|
|
661
|
+
* warning) when nothing is stored, the blob is malformed, or storage
|
|
662
|
+
* is unavailable. Caller is responsible for treating duplicates from
|
|
663
|
+
* the persisted queue as the SAME events (eventId-based dedup).
|
|
664
|
+
*/
|
|
665
|
+
load() {
|
|
666
|
+
let raw;
|
|
667
|
+
try {
|
|
668
|
+
raw = this.options.storage.getItem(this.key);
|
|
669
|
+
} catch {
|
|
670
|
+
return [];
|
|
671
|
+
}
|
|
672
|
+
if (!raw) return [];
|
|
673
|
+
try {
|
|
674
|
+
const parsed = JSON.parse(raw);
|
|
675
|
+
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
|
|
676
|
+
return [];
|
|
677
|
+
}
|
|
678
|
+
return parsed.events;
|
|
679
|
+
} catch {
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Schedule a write of the current buffer. Debounced via microtask so
|
|
685
|
+
* a burst of enqueue() calls coalesces into one persistence write.
|
|
686
|
+
* Writes are best-effort: if storage throws (quota, private mode),
|
|
687
|
+
* we swallow and rely on the in-memory buffer.
|
|
688
|
+
*/
|
|
689
|
+
save(snapshot) {
|
|
690
|
+
this.pendingSnapshot = snapshot.slice();
|
|
691
|
+
if (this.writeScheduled) return;
|
|
692
|
+
this.writeScheduled = true;
|
|
693
|
+
queueMicrotask(() => this.flushWrite());
|
|
694
|
+
}
|
|
695
|
+
/** Synchronous variant for terminal flushes (pagehide / beforeunload). */
|
|
696
|
+
saveSync(snapshot) {
|
|
697
|
+
this.pendingSnapshot = snapshot.slice();
|
|
698
|
+
this.flushWrite();
|
|
699
|
+
}
|
|
700
|
+
/** Wipe the persisted blob. Used by reset() (logout). */
|
|
701
|
+
clear() {
|
|
702
|
+
this.pendingSnapshot = null;
|
|
703
|
+
this.writeScheduled = false;
|
|
704
|
+
try {
|
|
705
|
+
this.options.storage.removeItem(this.key);
|
|
706
|
+
} catch {
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
flushWrite() {
|
|
710
|
+
this.writeScheduled = false;
|
|
711
|
+
const snapshot = this.pendingSnapshot;
|
|
712
|
+
this.pendingSnapshot = null;
|
|
713
|
+
if (snapshot === null) return;
|
|
714
|
+
if (snapshot.length === 0) {
|
|
715
|
+
try {
|
|
716
|
+
this.options.storage.removeItem(this.key);
|
|
717
|
+
} catch {
|
|
718
|
+
}
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const blob = { version: 1, events: snapshot };
|
|
722
|
+
try {
|
|
723
|
+
this.options.storage.setItem(this.key, JSON.stringify(blob));
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
501
729
|
// src/storage.ts
|
|
502
730
|
var MemoryStorage = class {
|
|
503
731
|
constructor() {
|
|
@@ -688,7 +916,8 @@ var DEFAULT_AUTO_TRACK = {
|
|
|
688
916
|
sessions: true,
|
|
689
917
|
pageViews: true,
|
|
690
918
|
deviceInfo: true,
|
|
691
|
-
clicks: true
|
|
919
|
+
clicks: true,
|
|
920
|
+
webVitals: true
|
|
692
921
|
};
|
|
693
922
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
694
923
|
var EMPTY_ACQUISITION = {
|
|
@@ -697,7 +926,13 @@ var EMPTY_ACQUISITION = {
|
|
|
697
926
|
utm_campaign: "",
|
|
698
927
|
utm_content: "",
|
|
699
928
|
utm_term: "",
|
|
700
|
-
referrer: ""
|
|
929
|
+
referrer: "",
|
|
930
|
+
gclid: "",
|
|
931
|
+
fbclid: "",
|
|
932
|
+
msclkid: "",
|
|
933
|
+
ttclid: "",
|
|
934
|
+
li_fat_id: "",
|
|
935
|
+
twclid: ""
|
|
701
936
|
};
|
|
702
937
|
var AutoTracker = class {
|
|
703
938
|
constructor(cfg, track) {
|
|
@@ -705,6 +940,17 @@ var AutoTracker = class {
|
|
|
705
940
|
this.track = track;
|
|
706
941
|
this.session = null;
|
|
707
942
|
this.cleanups = [];
|
|
943
|
+
/**
|
|
944
|
+
* Stable per-page-view identifier. Minted at every `page.viewed`
|
|
945
|
+
* emission and attached to every subsequent event until the next
|
|
946
|
+
* `page.viewed`. Lets dashboards correlate "user clicked X" to
|
|
947
|
+
* "user viewed page Y" without timestamp arithmetic — the canonical
|
|
948
|
+
* Mixpanel `$current_url` / Segment `pageId` pattern.
|
|
949
|
+
*
|
|
950
|
+
* Null until the first `page.viewed` fires (which happens at SDK
|
|
951
|
+
* install if `autoTrack.pageViews !== false`).
|
|
952
|
+
*/
|
|
953
|
+
this.pageviewId = null;
|
|
708
954
|
}
|
|
709
955
|
install() {
|
|
710
956
|
if (!isBrowserSafe()) return;
|
|
@@ -735,6 +981,10 @@ var AutoTracker = class {
|
|
|
735
981
|
get currentSessionId() {
|
|
736
982
|
return this.session?.sessionId ?? null;
|
|
737
983
|
}
|
|
984
|
+
/** Stable per-page-view ID. Null before the first page.viewed has fired. */
|
|
985
|
+
get currentPageviewId() {
|
|
986
|
+
return this.pageviewId;
|
|
987
|
+
}
|
|
738
988
|
/**
|
|
739
989
|
* Per-session acquisition context — utm_* + referrer, captured once
|
|
740
990
|
* at session start. Returns empty strings when there's no session
|
|
@@ -815,7 +1065,9 @@ var AutoTracker = class {
|
|
|
815
1065
|
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
816
1066
|
lastFiredAt = now;
|
|
817
1067
|
lastFiredUrl = url;
|
|
1068
|
+
this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
|
|
818
1069
|
this.track("page.viewed", {
|
|
1070
|
+
pageviewId: this.pageviewId,
|
|
819
1071
|
path: loc.pathname,
|
|
820
1072
|
url,
|
|
821
1073
|
search: loc.search || void 0,
|
|
@@ -1019,6 +1271,12 @@ function captureAcquisition() {
|
|
|
1019
1271
|
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
1020
1272
|
result.utm_content = params.get("utm_content") ?? "";
|
|
1021
1273
|
result.utm_term = params.get("utm_term") ?? "";
|
|
1274
|
+
result.gclid = params.get("gclid") ?? "";
|
|
1275
|
+
result.fbclid = params.get("fbclid") ?? "";
|
|
1276
|
+
result.msclkid = params.get("msclkid") ?? "";
|
|
1277
|
+
result.ttclid = params.get("ttclid") ?? "";
|
|
1278
|
+
result.li_fat_id = params.get("li_fat_id") ?? "";
|
|
1279
|
+
result.twclid = params.get("twclid") ?? "";
|
|
1022
1280
|
} catch {
|
|
1023
1281
|
}
|
|
1024
1282
|
try {
|
|
@@ -1076,6 +1334,490 @@ function safeJson(obj) {
|
|
|
1076
1334
|
}
|
|
1077
1335
|
}
|
|
1078
1336
|
|
|
1337
|
+
// src/event-validation.ts
|
|
1338
|
+
var DEFAULT_MAX_STRING = 1024;
|
|
1339
|
+
var DEFAULT_MAX_BYTES = 8 * 1024;
|
|
1340
|
+
var DEFAULT_MAX_DEPTH = 5;
|
|
1341
|
+
function validateEventProperties(input, options = {}) {
|
|
1342
|
+
const warnings = [];
|
|
1343
|
+
if (!input) return { properties: {}, warnings };
|
|
1344
|
+
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
|
|
1345
|
+
const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
|
|
1346
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1347
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1348
|
+
const visit = (value, key, depth) => {
|
|
1349
|
+
if (depth > maxDepth) {
|
|
1350
|
+
warnings.push({ kind: "depth_exceeded", key });
|
|
1351
|
+
return { keep: true, value: "[depth-exceeded]" };
|
|
1352
|
+
}
|
|
1353
|
+
if (value === null) return { keep: true, value: null };
|
|
1354
|
+
const t = typeof value;
|
|
1355
|
+
if (t === "string") {
|
|
1356
|
+
const s = value;
|
|
1357
|
+
if (s.length > maxStringLength) {
|
|
1358
|
+
warnings.push({ kind: "truncated_string", key });
|
|
1359
|
+
return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
|
|
1360
|
+
}
|
|
1361
|
+
return { keep: true, value: s };
|
|
1362
|
+
}
|
|
1363
|
+
if (t === "number") {
|
|
1364
|
+
if (!Number.isFinite(value)) {
|
|
1365
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1366
|
+
return { keep: true, value: null };
|
|
1367
|
+
}
|
|
1368
|
+
return { keep: true, value };
|
|
1369
|
+
}
|
|
1370
|
+
if (t === "boolean") return { keep: true, value };
|
|
1371
|
+
if (t === "bigint") {
|
|
1372
|
+
warnings.push({ kind: "coerced_bigint", key });
|
|
1373
|
+
return { keep: true, value: value.toString() };
|
|
1374
|
+
}
|
|
1375
|
+
if (t === "function") {
|
|
1376
|
+
warnings.push({ kind: "dropped_function", key });
|
|
1377
|
+
return { keep: false, value: void 0 };
|
|
1378
|
+
}
|
|
1379
|
+
if (t === "symbol") {
|
|
1380
|
+
warnings.push({ kind: "dropped_symbol", key });
|
|
1381
|
+
return { keep: false, value: void 0 };
|
|
1382
|
+
}
|
|
1383
|
+
if (t === "undefined") {
|
|
1384
|
+
warnings.push({ kind: "dropped_undefined", key });
|
|
1385
|
+
return { keep: false, value: void 0 };
|
|
1386
|
+
}
|
|
1387
|
+
if (value instanceof Date) {
|
|
1388
|
+
warnings.push({ kind: "coerced_date", key });
|
|
1389
|
+
const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
|
|
1390
|
+
return { keep: true, value: iso };
|
|
1391
|
+
}
|
|
1392
|
+
if (value instanceof Error) {
|
|
1393
|
+
warnings.push({ kind: "coerced_error", key });
|
|
1394
|
+
return {
|
|
1395
|
+
keep: true,
|
|
1396
|
+
value: {
|
|
1397
|
+
name: value.name,
|
|
1398
|
+
message: value.message,
|
|
1399
|
+
stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
if (value instanceof Map) {
|
|
1404
|
+
warnings.push({ kind: "coerced_map", key });
|
|
1405
|
+
const obj = {};
|
|
1406
|
+
for (const [k, v] of value.entries()) {
|
|
1407
|
+
const subKey = typeof k === "string" ? k : String(k);
|
|
1408
|
+
const result = visit(v, `${key}.${subKey}`, depth + 1);
|
|
1409
|
+
if (result.keep) obj[subKey] = result.value;
|
|
1410
|
+
}
|
|
1411
|
+
return { keep: true, value: obj };
|
|
1412
|
+
}
|
|
1413
|
+
if (value instanceof Set) {
|
|
1414
|
+
warnings.push({ kind: "coerced_set", key });
|
|
1415
|
+
const arr = [];
|
|
1416
|
+
let i = 0;
|
|
1417
|
+
for (const v of value.values()) {
|
|
1418
|
+
const result = visit(v, `${key}[${i}]`, depth + 1);
|
|
1419
|
+
if (result.keep) arr.push(result.value);
|
|
1420
|
+
i++;
|
|
1421
|
+
}
|
|
1422
|
+
return { keep: true, value: arr };
|
|
1423
|
+
}
|
|
1424
|
+
if (Array.isArray(value)) {
|
|
1425
|
+
if (seen.has(value)) {
|
|
1426
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1427
|
+
return { keep: true, value: "[circular]" };
|
|
1428
|
+
}
|
|
1429
|
+
seen.add(value);
|
|
1430
|
+
const out = [];
|
|
1431
|
+
for (let i = 0; i < value.length; i++) {
|
|
1432
|
+
const result = visit(value[i], `${key}[${i}]`, depth + 1);
|
|
1433
|
+
if (result.keep) out.push(result.value);
|
|
1434
|
+
}
|
|
1435
|
+
return { keep: true, value: out };
|
|
1436
|
+
}
|
|
1437
|
+
if (t === "object") {
|
|
1438
|
+
const obj = value;
|
|
1439
|
+
if (seen.has(obj)) {
|
|
1440
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1441
|
+
return { keep: true, value: "[circular]" };
|
|
1442
|
+
}
|
|
1443
|
+
seen.add(obj);
|
|
1444
|
+
const out = {};
|
|
1445
|
+
for (const k of Object.keys(obj)) {
|
|
1446
|
+
const result = visit(obj[k], `${key}.${k}`, depth + 1);
|
|
1447
|
+
if (result.keep) out[k] = result.value;
|
|
1448
|
+
}
|
|
1449
|
+
return { keep: true, value: out };
|
|
1450
|
+
}
|
|
1451
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1452
|
+
try {
|
|
1453
|
+
return { keep: true, value: String(value) };
|
|
1454
|
+
} catch {
|
|
1455
|
+
return { keep: false, value: void 0 };
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
const cleaned = {};
|
|
1459
|
+
for (const k of Object.keys(input)) {
|
|
1460
|
+
const result = visit(input[k], k, 0);
|
|
1461
|
+
if (result.keep) cleaned[k] = result.value;
|
|
1462
|
+
}
|
|
1463
|
+
const serialised = safeStringify(cleaned);
|
|
1464
|
+
if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
|
|
1465
|
+
warnings.push({ kind: "size_cap_exceeded", key: "*" });
|
|
1466
|
+
const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
|
|
1467
|
+
let currentSize = byteLength(serialised);
|
|
1468
|
+
for (const { k } of sizes) {
|
|
1469
|
+
if (currentSize <= maxBatchPropertyBytes) break;
|
|
1470
|
+
currentSize -= sizes.find((s) => s.k === k).size;
|
|
1471
|
+
delete cleaned[k];
|
|
1472
|
+
}
|
|
1473
|
+
cleaned.__truncated = true;
|
|
1474
|
+
}
|
|
1475
|
+
return { properties: cleaned, warnings };
|
|
1476
|
+
}
|
|
1477
|
+
function safeStringify(v) {
|
|
1478
|
+
try {
|
|
1479
|
+
return JSON.stringify(v) ?? null;
|
|
1480
|
+
} catch {
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
function byteLength(s) {
|
|
1485
|
+
if (typeof TextEncoder !== "undefined") {
|
|
1486
|
+
return new TextEncoder().encode(s).length;
|
|
1487
|
+
}
|
|
1488
|
+
return s.length * 4;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// src/super-properties.ts
|
|
1492
|
+
var KEY_SUPER = "super_props";
|
|
1493
|
+
var KEY_GROUPS = "groups";
|
|
1494
|
+
var SuperPropertyStore = class {
|
|
1495
|
+
constructor(storage, prefix) {
|
|
1496
|
+
this.storage = storage;
|
|
1497
|
+
this.prefix = prefix;
|
|
1498
|
+
this.superProps = {};
|
|
1499
|
+
this.groups = {};
|
|
1500
|
+
this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
|
|
1501
|
+
this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
|
|
1502
|
+
}
|
|
1503
|
+
// ---------- super properties ----------
|
|
1504
|
+
/**
|
|
1505
|
+
* Merge new keys into the super-property bag. Returns a snapshot of
|
|
1506
|
+
* the resulting bag. Values that are `null` are deleted (Mixpanel
|
|
1507
|
+
* semantics — explicit null = "stop tracking this key").
|
|
1508
|
+
*/
|
|
1509
|
+
register(props) {
|
|
1510
|
+
for (const [k, v] of Object.entries(props)) {
|
|
1511
|
+
if (v === null) {
|
|
1512
|
+
delete this.superProps[k];
|
|
1513
|
+
} else if (v !== void 0) {
|
|
1514
|
+
this.superProps[k] = v;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1518
|
+
return { ...this.superProps };
|
|
1519
|
+
}
|
|
1520
|
+
/** Remove a single super-property key. Idempotent. */
|
|
1521
|
+
unregister(key) {
|
|
1522
|
+
if (key in this.superProps) {
|
|
1523
|
+
delete this.superProps[key];
|
|
1524
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
/** Snapshot of the current super-property bag. */
|
|
1528
|
+
getSuperProperties() {
|
|
1529
|
+
return { ...this.superProps };
|
|
1530
|
+
}
|
|
1531
|
+
// ---------- groups ----------
|
|
1532
|
+
/**
|
|
1533
|
+
* Set a group membership. Passing `id: null` clears the membership
|
|
1534
|
+
* for that group type — the SDK stops attaching it to events.
|
|
1535
|
+
*/
|
|
1536
|
+
setGroup(type, id, traits) {
|
|
1537
|
+
if (id === null) {
|
|
1538
|
+
delete this.groups[type];
|
|
1539
|
+
} else {
|
|
1540
|
+
this.groups[type] = traits !== void 0 ? { id, traits } : { id };
|
|
1541
|
+
}
|
|
1542
|
+
writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Snapshot of the current groups map, keyed by group type. Returned
|
|
1546
|
+
* shape mirrors what the SDK attaches to every event as
|
|
1547
|
+
* `$groups.{type}`. The `traits` sub-object is the most-recent
|
|
1548
|
+
* traits payload passed to `setGroup` for that type; null when none.
|
|
1549
|
+
*/
|
|
1550
|
+
getGroups() {
|
|
1551
|
+
return JSON.parse(JSON.stringify(this.groups));
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* The flat `{ type: id }` projection used for event-attachment. Stable
|
|
1555
|
+
* for fast every-event merge — we don't want to JSON-clone on each
|
|
1556
|
+
* track() call.
|
|
1557
|
+
*/
|
|
1558
|
+
getGroupIds() {
|
|
1559
|
+
const out = {};
|
|
1560
|
+
for (const [type, info] of Object.entries(this.groups)) {
|
|
1561
|
+
out[type] = info.id;
|
|
1562
|
+
}
|
|
1563
|
+
return out;
|
|
1564
|
+
}
|
|
1565
|
+
/** Wipe both bags. Called by Crossdeck.reset() (logout). */
|
|
1566
|
+
clear() {
|
|
1567
|
+
this.superProps = {};
|
|
1568
|
+
this.groups = {};
|
|
1569
|
+
try {
|
|
1570
|
+
this.storage.removeItem(this.prefix + KEY_SUPER);
|
|
1571
|
+
} catch {
|
|
1572
|
+
}
|
|
1573
|
+
try {
|
|
1574
|
+
this.storage.removeItem(this.prefix + KEY_GROUPS);
|
|
1575
|
+
} catch {
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
};
|
|
1579
|
+
function readJson(storage, key) {
|
|
1580
|
+
let raw;
|
|
1581
|
+
try {
|
|
1582
|
+
raw = storage.getItem(key);
|
|
1583
|
+
} catch {
|
|
1584
|
+
return null;
|
|
1585
|
+
}
|
|
1586
|
+
if (!raw) return null;
|
|
1587
|
+
try {
|
|
1588
|
+
return JSON.parse(raw);
|
|
1589
|
+
} catch {
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
function writeJson(storage, key, value) {
|
|
1594
|
+
try {
|
|
1595
|
+
storage.setItem(key, JSON.stringify(value));
|
|
1596
|
+
} catch {
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// src/web-vitals.ts
|
|
1601
|
+
var WebVitalsTracker = class {
|
|
1602
|
+
constructor(cfg, report) {
|
|
1603
|
+
this.cfg = cfg;
|
|
1604
|
+
this.report = report;
|
|
1605
|
+
this.observers = [];
|
|
1606
|
+
this.flushed = /* @__PURE__ */ new Set();
|
|
1607
|
+
this.cls = 0;
|
|
1608
|
+
this.clsEntries = [];
|
|
1609
|
+
this.inp = 0;
|
|
1610
|
+
this.cleanups = [];
|
|
1611
|
+
}
|
|
1612
|
+
install() {
|
|
1613
|
+
if (!this.cfg.enabled) return;
|
|
1614
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
1615
|
+
if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
|
|
1616
|
+
const doc = globalThis.document;
|
|
1617
|
+
try {
|
|
1618
|
+
const navObserver = new PerformanceObserver((list) => {
|
|
1619
|
+
for (const entry of list.getEntries()) {
|
|
1620
|
+
const e = entry;
|
|
1621
|
+
if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
|
|
1622
|
+
this.flushed.add("ttfb");
|
|
1623
|
+
this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
navObserver.observe({ type: "navigation", buffered: true });
|
|
1628
|
+
this.observers.push(navObserver);
|
|
1629
|
+
} catch {
|
|
1630
|
+
}
|
|
1631
|
+
try {
|
|
1632
|
+
const paintObserver = new PerformanceObserver((list) => {
|
|
1633
|
+
for (const entry of list.getEntries()) {
|
|
1634
|
+
if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
|
|
1635
|
+
this.flushed.add("fcp");
|
|
1636
|
+
this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
paintObserver.observe({ type: "paint", buffered: true });
|
|
1641
|
+
this.observers.push(paintObserver);
|
|
1642
|
+
} catch {
|
|
1643
|
+
}
|
|
1644
|
+
let lcpValue = 0;
|
|
1645
|
+
try {
|
|
1646
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
1647
|
+
const entries = list.getEntries();
|
|
1648
|
+
const last = entries[entries.length - 1];
|
|
1649
|
+
if (last) lcpValue = last.startTime;
|
|
1650
|
+
});
|
|
1651
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
1652
|
+
this.observers.push(lcpObserver);
|
|
1653
|
+
} catch {
|
|
1654
|
+
}
|
|
1655
|
+
try {
|
|
1656
|
+
const clsObserver = new PerformanceObserver((list) => {
|
|
1657
|
+
for (const entry of list.getEntries()) {
|
|
1658
|
+
const e = entry;
|
|
1659
|
+
if (typeof e.value === "number" && !e.hadRecentInput) {
|
|
1660
|
+
this.cls += e.value;
|
|
1661
|
+
this.clsEntries.push(entry);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
1666
|
+
this.observers.push(clsObserver);
|
|
1667
|
+
} catch {
|
|
1668
|
+
}
|
|
1669
|
+
try {
|
|
1670
|
+
const eventObserver = new PerformanceObserver((list) => {
|
|
1671
|
+
for (const entry of list.getEntries()) {
|
|
1672
|
+
const e = entry;
|
|
1673
|
+
if (e.interactionId && e.duration > this.inp) {
|
|
1674
|
+
this.inp = e.duration;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
try {
|
|
1679
|
+
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
1680
|
+
} catch {
|
|
1681
|
+
eventObserver.observe({ type: "first-input", buffered: true });
|
|
1682
|
+
}
|
|
1683
|
+
this.observers.push(eventObserver);
|
|
1684
|
+
} catch {
|
|
1685
|
+
}
|
|
1686
|
+
const flush = () => {
|
|
1687
|
+
if (lcpValue > 0 && !this.flushed.has("lcp")) {
|
|
1688
|
+
this.flushed.add("lcp");
|
|
1689
|
+
this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
|
|
1690
|
+
}
|
|
1691
|
+
if (this.cls > 0 && !this.flushed.has("cls")) {
|
|
1692
|
+
this.flushed.add("cls");
|
|
1693
|
+
this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
|
|
1694
|
+
}
|
|
1695
|
+
if (this.inp > 0 && !this.flushed.has("inp")) {
|
|
1696
|
+
this.flushed.add("inp");
|
|
1697
|
+
this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
|
|
1698
|
+
}
|
|
1699
|
+
};
|
|
1700
|
+
const onHidden = () => {
|
|
1701
|
+
if (doc.visibilityState === "hidden") flush();
|
|
1702
|
+
};
|
|
1703
|
+
doc.addEventListener("visibilitychange", onHidden);
|
|
1704
|
+
globalThis.window.addEventListener("pagehide", flush);
|
|
1705
|
+
this.cleanups.push(() => {
|
|
1706
|
+
doc.removeEventListener("visibilitychange", onHidden);
|
|
1707
|
+
globalThis.window.removeEventListener("pagehide", flush);
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
uninstall() {
|
|
1711
|
+
for (const o of this.observers) {
|
|
1712
|
+
try {
|
|
1713
|
+
o.disconnect();
|
|
1714
|
+
} catch {
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
this.observers = [];
|
|
1718
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1719
|
+
try {
|
|
1720
|
+
fn();
|
|
1721
|
+
} catch {
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
// src/consent.ts
|
|
1728
|
+
var ALL_GRANTED = {
|
|
1729
|
+
analytics: true,
|
|
1730
|
+
marketing: true,
|
|
1731
|
+
errors: true
|
|
1732
|
+
};
|
|
1733
|
+
var ConsentManager = class {
|
|
1734
|
+
constructor(options) {
|
|
1735
|
+
this.state = { ...ALL_GRANTED };
|
|
1736
|
+
this.dntDenied = false;
|
|
1737
|
+
if (options?.respectDnt && this.detectDnt()) {
|
|
1738
|
+
this.dntDenied = true;
|
|
1739
|
+
this.state = { analytics: false, marketing: false, errors: false };
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Merge new dimensions onto the current state. Returns the resulting
|
|
1744
|
+
* snapshot. DNT-derived denies cannot be flipped back on by a `set`
|
|
1745
|
+
* call — once the browser says "don't track", we don't track even if
|
|
1746
|
+
* the developer code disagrees. That's the contract.
|
|
1747
|
+
*/
|
|
1748
|
+
set(partial) {
|
|
1749
|
+
if (this.dntDenied) return { ...this.state };
|
|
1750
|
+
for (const k of Object.keys(partial)) {
|
|
1751
|
+
const v = partial[k];
|
|
1752
|
+
if (typeof v === "boolean") this.state[k] = v;
|
|
1753
|
+
}
|
|
1754
|
+
return { ...this.state };
|
|
1755
|
+
}
|
|
1756
|
+
/** Snapshot of the current state. */
|
|
1757
|
+
get() {
|
|
1758
|
+
return { ...this.state };
|
|
1759
|
+
}
|
|
1760
|
+
/** Convenience getters for hot paths. */
|
|
1761
|
+
get analytics() {
|
|
1762
|
+
return this.state.analytics;
|
|
1763
|
+
}
|
|
1764
|
+
get marketing() {
|
|
1765
|
+
return this.state.marketing;
|
|
1766
|
+
}
|
|
1767
|
+
get errors() {
|
|
1768
|
+
return this.state.errors;
|
|
1769
|
+
}
|
|
1770
|
+
/** True iff the constructor detected and applied DNT. */
|
|
1771
|
+
get isDntDenied() {
|
|
1772
|
+
return this.dntDenied;
|
|
1773
|
+
}
|
|
1774
|
+
detectDnt() {
|
|
1775
|
+
try {
|
|
1776
|
+
const nav = globalThis.navigator;
|
|
1777
|
+
if (!nav) return false;
|
|
1778
|
+
const sources = [
|
|
1779
|
+
nav.doNotTrack,
|
|
1780
|
+
nav.msDoNotTrack,
|
|
1781
|
+
globalThis.doNotTrack
|
|
1782
|
+
];
|
|
1783
|
+
return sources.some((v) => v === "1" || v === "yes");
|
|
1784
|
+
} catch {
|
|
1785
|
+
return false;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1789
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
1790
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
1791
|
+
var REPLACEMENT_EMAIL = "[email]";
|
|
1792
|
+
var REPLACEMENT_CARD = "[card]";
|
|
1793
|
+
function scrubPii(value) {
|
|
1794
|
+
if (!value) return value;
|
|
1795
|
+
let out = value;
|
|
1796
|
+
if (EMAIL_PATTERN.test(out)) {
|
|
1797
|
+
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
1798
|
+
}
|
|
1799
|
+
EMAIL_PATTERN.lastIndex = 0;
|
|
1800
|
+
if (CARD_PATTERN.test(out)) {
|
|
1801
|
+
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1802
|
+
}
|
|
1803
|
+
CARD_PATTERN.lastIndex = 0;
|
|
1804
|
+
return out;
|
|
1805
|
+
}
|
|
1806
|
+
function scrubPiiFromProperties(properties) {
|
|
1807
|
+
const out = {};
|
|
1808
|
+
for (const k of Object.keys(properties)) {
|
|
1809
|
+
const v = properties[k];
|
|
1810
|
+
if (typeof v === "string") {
|
|
1811
|
+
out[k] = scrubPii(v);
|
|
1812
|
+
} else if (Array.isArray(v)) {
|
|
1813
|
+
out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
|
|
1814
|
+
} else {
|
|
1815
|
+
out[k] = v;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return out;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1079
1821
|
// src/crossdeck.ts
|
|
1080
1822
|
var CrossdeckClient = class {
|
|
1081
1823
|
constructor() {
|
|
@@ -1165,6 +1907,13 @@ var CrossdeckClient = class {
|
|
|
1165
1907
|
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1166
1908
|
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
1167
1909
|
const entitlements = new EntitlementCache();
|
|
1910
|
+
const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
|
|
1911
|
+
if (persistentEvents) {
|
|
1912
|
+
debug.emit(
|
|
1913
|
+
"sdk.queue_restored",
|
|
1914
|
+
"Restored persisted event queue from a prior session."
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1168
1917
|
const events = new EventQueue({
|
|
1169
1918
|
http,
|
|
1170
1919
|
batchSize: opts.eventFlushBatchSize,
|
|
@@ -1174,26 +1923,51 @@ var CrossdeckClient = class {
|
|
|
1174
1923
|
environment: opts.environment,
|
|
1175
1924
|
sdk: { name: SDK_NAME, version: opts.sdkVersion }
|
|
1176
1925
|
}),
|
|
1926
|
+
persistentStore: persistentEvents ?? void 0,
|
|
1177
1927
|
onFirstFlushSuccess: () => {
|
|
1178
1928
|
debug.emit(
|
|
1179
1929
|
"sdk.first_event_sent",
|
|
1180
1930
|
"First telemetry event received. View it in Live Events.",
|
|
1181
1931
|
{ appId: opts.appId, environment: opts.environment }
|
|
1182
1932
|
);
|
|
1933
|
+
},
|
|
1934
|
+
onRetryScheduled: (info) => {
|
|
1935
|
+
debug.emit(
|
|
1936
|
+
"sdk.flush_retry_scheduled",
|
|
1937
|
+
`Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
|
|
1938
|
+
{ ...info }
|
|
1939
|
+
);
|
|
1183
1940
|
}
|
|
1184
1941
|
});
|
|
1185
1942
|
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
1943
|
+
const superProps = new SuperPropertyStore(
|
|
1944
|
+
persistIdentity ? effectiveStorage : new MemoryStorage(),
|
|
1945
|
+
opts.storagePrefix
|
|
1946
|
+
);
|
|
1947
|
+
const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
|
|
1948
|
+
if (consent.isDntDenied) {
|
|
1949
|
+
debug.emit(
|
|
1950
|
+
"sdk.consent_dnt_applied",
|
|
1951
|
+
"Do Not Track detected \u2014 all tracking dimensions denied at init."
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1186
1954
|
this.state = {
|
|
1187
1955
|
http,
|
|
1188
1956
|
identity,
|
|
1189
1957
|
entitlements,
|
|
1190
1958
|
events,
|
|
1191
1959
|
autoTracker: null,
|
|
1960
|
+
webVitals: null,
|
|
1961
|
+
superProps,
|
|
1962
|
+
consent,
|
|
1963
|
+
scrubPii: options.scrubPii !== false,
|
|
1192
1964
|
deviceInfo,
|
|
1193
1965
|
options: opts,
|
|
1194
1966
|
debug,
|
|
1195
1967
|
developerUserId: null,
|
|
1196
|
-
uninstallUnloadFlush: null
|
|
1968
|
+
uninstallUnloadFlush: null,
|
|
1969
|
+
lastServerTime: null,
|
|
1970
|
+
lastClientTime: null
|
|
1197
1971
|
};
|
|
1198
1972
|
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
1199
1973
|
appId: opts.appId,
|
|
@@ -1208,6 +1982,14 @@ var CrossdeckClient = class {
|
|
|
1208
1982
|
this.state.autoTracker = tracker;
|
|
1209
1983
|
tracker.install();
|
|
1210
1984
|
}
|
|
1985
|
+
if (autoTrack.webVitals) {
|
|
1986
|
+
const vitals = new WebVitalsTracker(
|
|
1987
|
+
{ enabled: true },
|
|
1988
|
+
(name, properties) => this.track(name, properties)
|
|
1989
|
+
);
|
|
1990
|
+
this.state.webVitals = vitals;
|
|
1991
|
+
vitals.install();
|
|
1992
|
+
}
|
|
1211
1993
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
1212
1994
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
1213
1995
|
});
|
|
@@ -1231,8 +2013,19 @@ var CrossdeckClient = class {
|
|
|
1231
2013
|
/**
|
|
1232
2014
|
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
1233
2015
|
* the resolved Crossdeck customer for follow-up calls.
|
|
2016
|
+
*
|
|
2017
|
+
* v0.9.0+ accepts an optional `traits` bag — profile data (name,
|
|
2018
|
+
* plan, signupDate, role) persisted on the Crossdeck customer record
|
|
2019
|
+
* and queryable from dashboards. Traits are sanitised through the
|
|
2020
|
+
* same validator that gates `track()` properties, so a `{ avatar:
|
|
2021
|
+
* <File>, onSave: () => {} }` payload can't corrupt the alias call.
|
|
2022
|
+
*
|
|
2023
|
+
* Crossdeck.identify("user_847", {
|
|
2024
|
+
* email: "wes@pinet.co.za",
|
|
2025
|
+
* traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
|
|
2026
|
+
* });
|
|
1234
2027
|
*/
|
|
1235
|
-
async identify(userId,
|
|
2028
|
+
async identify(userId, options) {
|
|
1236
2029
|
const s = this.requireStarted();
|
|
1237
2030
|
if (!userId) {
|
|
1238
2031
|
throw new CrossdeckError({
|
|
@@ -1241,13 +2034,163 @@ var CrossdeckClient = class {
|
|
|
1241
2034
|
message: "identify(userId) requires a non-empty userId."
|
|
1242
2035
|
});
|
|
1243
2036
|
}
|
|
2037
|
+
if (!s.consent.analytics) {
|
|
2038
|
+
s.debug.emit(
|
|
2039
|
+
"sdk.consent_denied",
|
|
2040
|
+
`identify() skipped \u2014 consent denied for analytics.`
|
|
2041
|
+
);
|
|
2042
|
+
return {
|
|
2043
|
+
object: "alias_result",
|
|
2044
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
|
|
2045
|
+
linked: [],
|
|
2046
|
+
mergePending: false,
|
|
2047
|
+
env: s.options.environment
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
|
|
2051
|
+
const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
|
|
2052
|
+
if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
|
|
2053
|
+
for (const w of traitsValidation.warnings) {
|
|
2054
|
+
s.debug.emit(
|
|
2055
|
+
"sdk.property_coerced",
|
|
2056
|
+
`identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2057
|
+
{ key: w.key, kind: w.kind }
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
const body = {
|
|
2062
|
+
userId,
|
|
2063
|
+
anonymousId: s.identity.anonymousId
|
|
2064
|
+
};
|
|
2065
|
+
if (options?.email) body.email = options.email;
|
|
2066
|
+
if (traits) body.traits = traits;
|
|
1244
2067
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
1245
|
-
body
|
|
2068
|
+
body
|
|
1246
2069
|
});
|
|
1247
2070
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
1248
2071
|
s.developerUserId = userId;
|
|
1249
2072
|
return result;
|
|
1250
2073
|
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Register super-properties — Mixpanel pattern. Once set, every
|
|
2076
|
+
* subsequent event of THIS SDK instance carries these keys on its
|
|
2077
|
+
* properties bag automatically.
|
|
2078
|
+
*
|
|
2079
|
+
* Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
|
|
2080
|
+
* Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
|
|
2081
|
+
*
|
|
2082
|
+
* Values that are `null` are deleted (the explicit "stop tracking
|
|
2083
|
+
* this key" idiom). Returns the resulting bag.
|
|
2084
|
+
*
|
|
2085
|
+
* Sanitised through `validateEventProperties` so a `{ avatar: File }`
|
|
2086
|
+
* payload can't poison the queue at flush time.
|
|
2087
|
+
*/
|
|
2088
|
+
register(properties) {
|
|
2089
|
+
const s = this.requireStarted();
|
|
2090
|
+
const validation = validateEventProperties(properties);
|
|
2091
|
+
return s.superProps.register(validation.properties);
|
|
2092
|
+
}
|
|
2093
|
+
/** Remove a single super-property key. Idempotent. */
|
|
2094
|
+
unregister(key) {
|
|
2095
|
+
const s = this.requireStarted();
|
|
2096
|
+
s.superProps.unregister(key);
|
|
2097
|
+
}
|
|
2098
|
+
/** Snapshot of the current super-property bag. */
|
|
2099
|
+
getSuperProperties() {
|
|
2100
|
+
if (!this.state) return {};
|
|
2101
|
+
return this.state.superProps.getSuperProperties();
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Associate the current user with a group (org, team, account, etc.).
|
|
2105
|
+
* Mixpanel / Segment "Group Analytics" pattern.
|
|
2106
|
+
*
|
|
2107
|
+
* Crossdeck.group("org", "acme_inc");
|
|
2108
|
+
* Crossdeck.group("team", "design", { headcount: 12 });
|
|
2109
|
+
*
|
|
2110
|
+
* Once set, every subsequent event carries `$groups.<type>: id` on
|
|
2111
|
+
* its properties bag, enabling B2B dashboards ("how is Acme using
|
|
2112
|
+
* the product"). Pass `id: null` to clear a group membership.
|
|
2113
|
+
*/
|
|
2114
|
+
group(type, id, traits) {
|
|
2115
|
+
const s = this.requireStarted();
|
|
2116
|
+
if (!type) {
|
|
2117
|
+
throw new CrossdeckError({
|
|
2118
|
+
type: "invalid_request_error",
|
|
2119
|
+
code: "missing_group_type",
|
|
2120
|
+
message: "group(type, id) requires a non-empty type."
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
|
|
2124
|
+
s.superProps.setGroup(type, id, sanitisedTraits);
|
|
2125
|
+
}
|
|
2126
|
+
/** Snapshot of the current groups map keyed by type. */
|
|
2127
|
+
getGroups() {
|
|
2128
|
+
if (!this.state) return {};
|
|
2129
|
+
return this.state.superProps.getGroups();
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Update consent state. Three independent dimensions:
|
|
2133
|
+
*
|
|
2134
|
+
* analytics — track() + identify() + auto-emissions
|
|
2135
|
+
* marketing — paid-traffic click IDs + referrer URL on events
|
|
2136
|
+
* errors — Web Vitals + (future) error reporting
|
|
2137
|
+
*
|
|
2138
|
+
* Each defaults to `true` (granted). Pass partial state — only the
|
|
2139
|
+
* keys you provide are changed.
|
|
2140
|
+
*
|
|
2141
|
+
* Crossdeck.consent({ analytics: false });
|
|
2142
|
+
* Crossdeck.consent({ marketing: true, errors: true });
|
|
2143
|
+
*
|
|
2144
|
+
* DNT-derived denies cannot be flipped back on; if the browser said
|
|
2145
|
+
* "don't track" we don't track even if the developer code disagrees.
|
|
2146
|
+
*/
|
|
2147
|
+
consent(state) {
|
|
2148
|
+
const s = this.requireStarted();
|
|
2149
|
+
const next = s.consent.set(state);
|
|
2150
|
+
s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
|
|
2151
|
+
return next;
|
|
2152
|
+
}
|
|
2153
|
+
/** Snapshot of the current consent state. */
|
|
2154
|
+
consentStatus() {
|
|
2155
|
+
if (!this.state) {
|
|
2156
|
+
return { analytics: true, marketing: true, errors: true };
|
|
2157
|
+
}
|
|
2158
|
+
return this.state.consent.get();
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* GDPR/CCPA "right to be forgotten" — calls the backend's
|
|
2162
|
+
* /v1/identity/forget endpoint to schedule a server-side deletion of
|
|
2163
|
+
* the customer's events and profile, then wipes all local state
|
|
2164
|
+
* (identity, entitlements, queue, super-props, persistent stores).
|
|
2165
|
+
*
|
|
2166
|
+
* Idempotent. Safe to call when no identity has been established
|
|
2167
|
+
* (it just wipes the empty local state).
|
|
2168
|
+
*
|
|
2169
|
+
* After forget() resolves, the SDK is in the same shape as if the
|
|
2170
|
+
* developer had called `Crossdeck.reset()` — a fresh anonymousId is
|
|
2171
|
+
* minted and the next session is a brand new identity-graph entry.
|
|
2172
|
+
*/
|
|
2173
|
+
async forget() {
|
|
2174
|
+
const s = this.requireStarted();
|
|
2175
|
+
const identityQuery = this.identityQueryParams();
|
|
2176
|
+
try {
|
|
2177
|
+
await s.http.request("POST", "/identity/forget", {
|
|
2178
|
+
body: {
|
|
2179
|
+
// Send every identity hint we hold; the server resolves the
|
|
2180
|
+
// canonical customer record and queues deletion. Missing
|
|
2181
|
+
// endpoint (older backend) gracefully degrades — local state
|
|
2182
|
+
// still wipes via the reset() call below.
|
|
2183
|
+
...identityQuery
|
|
2184
|
+
}
|
|
2185
|
+
});
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
s.debug.emit(
|
|
2188
|
+
"sdk.consent_denied",
|
|
2189
|
+
`forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
this.reset();
|
|
2193
|
+
}
|
|
1251
2194
|
/**
|
|
1252
2195
|
* Read the current customer's active entitlements from the server.
|
|
1253
2196
|
* Updates the local cache so subsequent isEntitled() calls answer
|
|
@@ -1325,6 +2268,17 @@ var CrossdeckClient = class {
|
|
|
1325
2268
|
message: "track(name) requires a non-empty name."
|
|
1326
2269
|
});
|
|
1327
2270
|
}
|
|
2271
|
+
const isWebVital = name.startsWith("webvitals.");
|
|
2272
|
+
const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
|
|
2273
|
+
if (!consentGateOk) {
|
|
2274
|
+
if (s.debug.enabled) {
|
|
2275
|
+
s.debug.emit(
|
|
2276
|
+
"sdk.consent_denied",
|
|
2277
|
+
`Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
1328
2282
|
if (s.debug.enabled && properties) {
|
|
1329
2283
|
const flagged = findSensitivePropertyKeys(properties);
|
|
1330
2284
|
if (flagged.length > 0) {
|
|
@@ -1341,9 +2295,21 @@ var CrossdeckClient = class {
|
|
|
1341
2295
|
"Using anonymous user until identify(userId) is called."
|
|
1342
2296
|
);
|
|
1343
2297
|
}
|
|
2298
|
+
const validation = validateEventProperties(properties);
|
|
2299
|
+
if (s.debug.enabled && validation.warnings.length > 0) {
|
|
2300
|
+
for (const w of validation.warnings) {
|
|
2301
|
+
s.debug.emit(
|
|
2302
|
+
"sdk.property_coerced",
|
|
2303
|
+
`Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2304
|
+
{ eventName: name, key: w.key, kind: w.kind }
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
1344
2308
|
const enriched = { ...s.deviceInfo };
|
|
1345
2309
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
1346
2310
|
if (sessionId) enriched.sessionId = sessionId;
|
|
2311
|
+
const pageviewId = s.autoTracker?.currentPageviewId;
|
|
2312
|
+
if (pageviewId) enriched.pageviewId = pageviewId;
|
|
1347
2313
|
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1348
2314
|
if (acquisition) {
|
|
1349
2315
|
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
@@ -1351,14 +2317,31 @@ var CrossdeckClient = class {
|
|
|
1351
2317
|
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1352
2318
|
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1353
2319
|
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1354
|
-
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
2320
|
+
if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
|
|
2321
|
+
if (s.consent.marketing) {
|
|
2322
|
+
if (acquisition.gclid) enriched.gclid = acquisition.gclid;
|
|
2323
|
+
if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
|
|
2324
|
+
if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
|
|
2325
|
+
if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
|
|
2326
|
+
if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
|
|
2327
|
+
if (acquisition.twclid) enriched.twclid = acquisition.twclid;
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
const supers = s.superProps.getSuperProperties();
|
|
2331
|
+
for (const k of Object.keys(supers)) {
|
|
2332
|
+
if (!(k in enriched)) enriched[k] = supers[k];
|
|
1355
2333
|
}
|
|
1356
|
-
|
|
2334
|
+
const groupIds = s.superProps.getGroupIds();
|
|
2335
|
+
if (Object.keys(groupIds).length > 0) {
|
|
2336
|
+
enriched.$groups = groupIds;
|
|
2337
|
+
}
|
|
2338
|
+
Object.assign(enriched, validation.properties);
|
|
2339
|
+
const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
|
|
1357
2340
|
const event = {
|
|
1358
2341
|
eventId: this.mintEventId(),
|
|
1359
2342
|
name,
|
|
1360
2343
|
timestamp: Date.now(),
|
|
1361
|
-
properties:
|
|
2344
|
+
properties: finalProperties
|
|
1362
2345
|
};
|
|
1363
2346
|
Object.assign(event, this.identityHintForEvent());
|
|
1364
2347
|
s.events.enqueue(event);
|
|
@@ -1436,7 +2419,12 @@ var CrossdeckClient = class {
|
|
|
1436
2419
|
*/
|
|
1437
2420
|
async heartbeat() {
|
|
1438
2421
|
const s = this.requireStarted();
|
|
1439
|
-
|
|
2422
|
+
const result = await s.http.request("GET", "/sdk/heartbeat");
|
|
2423
|
+
if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
|
|
2424
|
+
s.lastServerTime = result.serverTime;
|
|
2425
|
+
s.lastClientTime = Date.now();
|
|
2426
|
+
}
|
|
2427
|
+
return result;
|
|
1440
2428
|
}
|
|
1441
2429
|
/**
|
|
1442
2430
|
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
@@ -1455,6 +2443,7 @@ var CrossdeckClient = class {
|
|
|
1455
2443
|
this.state.identity.reset();
|
|
1456
2444
|
this.state.entitlements.clear();
|
|
1457
2445
|
this.state.events.reset();
|
|
2446
|
+
this.state.superProps.clear();
|
|
1458
2447
|
this.state.developerUserId = null;
|
|
1459
2448
|
if (this.state.autoTracker) {
|
|
1460
2449
|
const tracker = new AutoTracker(
|
|
@@ -1482,17 +2471,21 @@ var CrossdeckClient = class {
|
|
|
1482
2471
|
developerUserId: null,
|
|
1483
2472
|
sdkVersion: null,
|
|
1484
2473
|
baseUrl: null,
|
|
1485
|
-
|
|
2474
|
+
clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
|
|
2475
|
+
entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
|
|
1486
2476
|
events: {
|
|
1487
2477
|
buffered: 0,
|
|
1488
2478
|
dropped: 0,
|
|
1489
2479
|
inFlight: 0,
|
|
1490
2480
|
lastFlushAt: 0,
|
|
1491
|
-
lastError: null
|
|
2481
|
+
lastError: null,
|
|
2482
|
+
consecutiveFailures: 0,
|
|
2483
|
+
nextRetryAt: null
|
|
1492
2484
|
}
|
|
1493
2485
|
};
|
|
1494
2486
|
}
|
|
1495
2487
|
const s = this.state;
|
|
2488
|
+
const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
|
|
1496
2489
|
return {
|
|
1497
2490
|
started: true,
|
|
1498
2491
|
anonymousId: s.identity.anonymousId,
|
|
@@ -1500,9 +2493,15 @@ var CrossdeckClient = class {
|
|
|
1500
2493
|
developerUserId: s.developerUserId,
|
|
1501
2494
|
sdkVersion: s.options.sdkVersion,
|
|
1502
2495
|
baseUrl: s.options.baseUrl,
|
|
2496
|
+
clock: {
|
|
2497
|
+
lastServerTime: s.lastServerTime,
|
|
2498
|
+
lastClientTime: s.lastClientTime,
|
|
2499
|
+
skewMs
|
|
2500
|
+
},
|
|
1503
2501
|
entitlements: {
|
|
1504
2502
|
count: s.entitlements.list().length,
|
|
1505
|
-
lastUpdated: s.entitlements.freshness
|
|
2503
|
+
lastUpdated: s.entitlements.freshness,
|
|
2504
|
+
listenerErrors: s.entitlements.listenerErrors
|
|
1506
2505
|
},
|
|
1507
2506
|
events: s.events.getStats()
|
|
1508
2507
|
};
|
|
@@ -1569,6 +2568,7 @@ function inferEnvFromKey(publicKey) {
|
|
|
1569
2568
|
}
|
|
1570
2569
|
function isLocalHostname() {
|
|
1571
2570
|
const w = globalThis.window;
|
|
2571
|
+
if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
|
|
1572
2572
|
const hostname = w?.location?.hostname;
|
|
1573
2573
|
if (!hostname) return false;
|
|
1574
2574
|
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
@@ -1581,7 +2581,13 @@ function isLocalHostname() {
|
|
|
1581
2581
|
}
|
|
1582
2582
|
function resolveAutoTrack(input) {
|
|
1583
2583
|
if (input === false) {
|
|
1584
|
-
return {
|
|
2584
|
+
return {
|
|
2585
|
+
sessions: false,
|
|
2586
|
+
pageViews: false,
|
|
2587
|
+
deviceInfo: false,
|
|
2588
|
+
clicks: false,
|
|
2589
|
+
webVitals: false
|
|
2590
|
+
};
|
|
1585
2591
|
}
|
|
1586
2592
|
if (input === void 0 || input === true) {
|
|
1587
2593
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1590,7 +2596,8 @@ function resolveAutoTrack(input) {
|
|
|
1590
2596
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1591
2597
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1592
2598
|
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1593
|
-
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
2599
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
|
|
2600
|
+
webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
|
|
1594
2601
|
};
|
|
1595
2602
|
}
|
|
1596
2603
|
function installUnloadFlush(onUnload) {
|