@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.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.
|
|
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;
|
|
@@ -74,25 +94,38 @@ var HttpClient = class {
|
|
|
74
94
|
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
75
95
|
Accept: "application/json"
|
|
76
96
|
};
|
|
97
|
+
if (options.idempotencyKey) {
|
|
98
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
99
|
+
}
|
|
77
100
|
let bodyInit;
|
|
78
101
|
if (options.body !== void 0) {
|
|
79
102
|
headers["Content-Type"] = "application/json";
|
|
80
103
|
bodyInit = JSON.stringify(options.body);
|
|
81
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
|
+
}
|
|
82
111
|
let response;
|
|
83
112
|
try {
|
|
84
113
|
response = await fetch(url, {
|
|
85
114
|
method,
|
|
86
115
|
headers,
|
|
87
116
|
body: bodyInit,
|
|
88
|
-
keepalive: options.keepalive === true
|
|
117
|
+
keepalive: options.keepalive === true,
|
|
118
|
+
signal: controller?.signal
|
|
89
119
|
});
|
|
90
120
|
} catch (err) {
|
|
121
|
+
const aborted = controller?.signal?.aborted === true;
|
|
91
122
|
throw new CrossdeckError({
|
|
92
123
|
type: "network_error",
|
|
93
|
-
code: "fetch_failed",
|
|
94
|
-
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"
|
|
95
126
|
});
|
|
127
|
+
} finally {
|
|
128
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
96
129
|
}
|
|
97
130
|
if (!response.ok) {
|
|
98
131
|
throw await crossdeckErrorFromResponse(response);
|
|
@@ -289,6 +322,7 @@ var EntitlementCache = class {
|
|
|
289
322
|
this.all = [];
|
|
290
323
|
this.lastUpdated = 0;
|
|
291
324
|
this.listeners = /* @__PURE__ */ new Set();
|
|
325
|
+
this.listenerErrorCount = 0;
|
|
292
326
|
}
|
|
293
327
|
/** Sync read — true iff the entitlement key is currently active. */
|
|
294
328
|
isEntitled(key) {
|
|
@@ -302,6 +336,15 @@ var EntitlementCache = class {
|
|
|
302
336
|
get freshness() {
|
|
303
337
|
return this.lastUpdated;
|
|
304
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
|
+
}
|
|
305
348
|
/**
|
|
306
349
|
* Replace the cache with a fresh server response. The backend already
|
|
307
350
|
* filters to active + env-matching, so we don't re-filter — just trust
|
|
@@ -355,11 +398,54 @@ var EntitlementCache = class {
|
|
|
355
398
|
try {
|
|
356
399
|
listener(snapshot);
|
|
357
400
|
} catch {
|
|
401
|
+
this.listenerErrorCount += 1;
|
|
358
402
|
}
|
|
359
403
|
}
|
|
360
404
|
}
|
|
361
405
|
};
|
|
362
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
|
+
|
|
363
449
|
// src/event-queue.ts
|
|
364
450
|
var HARD_BUFFER_CAP = 1e3;
|
|
365
451
|
var EventQueue = class {
|
|
@@ -372,6 +458,22 @@ var EventQueue = class {
|
|
|
372
458
|
this.lastError = null;
|
|
373
459
|
this.cancelTimer = null;
|
|
374
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
|
+
}
|
|
375
477
|
}
|
|
376
478
|
enqueue(event) {
|
|
377
479
|
this.buffer.push(event);
|
|
@@ -381,6 +483,8 @@ var EventQueue = class {
|
|
|
381
483
|
this.dropped += overflow;
|
|
382
484
|
this.cfg.onDrop?.(overflow);
|
|
383
485
|
}
|
|
486
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
487
|
+
this.persistent?.save(this.buffer);
|
|
384
488
|
if (this.buffer.length >= this.cfg.batchSize) {
|
|
385
489
|
void this.flush();
|
|
386
490
|
} else {
|
|
@@ -390,7 +494,7 @@ var EventQueue = class {
|
|
|
390
494
|
/**
|
|
391
495
|
* Flush the buffer to /v1/events. Resolves when the network call
|
|
392
496
|
* completes (success or failure). On failure, events stay in the
|
|
393
|
-
* buffer for the next
|
|
497
|
+
* buffer for the next scheduled retry.
|
|
394
498
|
*
|
|
395
499
|
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
396
500
|
* browser keeps the request alive past page unload. Use this for
|
|
@@ -399,25 +503,32 @@ var EventQueue = class {
|
|
|
399
503
|
async flush(options = {}) {
|
|
400
504
|
if (this.buffer.length === 0) return null;
|
|
401
505
|
this.cancelTimerIfSet();
|
|
506
|
+
this.nextRetryAt = null;
|
|
402
507
|
const batch = this.buffer.splice(0);
|
|
508
|
+
const batchId = this.mintBatchId();
|
|
403
509
|
this.inFlight += batch.length;
|
|
510
|
+
this.persistent?.save(this.buffer);
|
|
511
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
404
512
|
try {
|
|
405
513
|
const env = this.cfg.envelope();
|
|
406
514
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
407
515
|
body: {
|
|
408
516
|
// NorthStar §13.1 batch envelope. The backend validates these
|
|
409
|
-
// against the API-key-resolved app and rejects mismatches
|
|
410
|
-
// (env_mismatch).
|
|
517
|
+
// against the API-key-resolved app and rejects mismatches
|
|
518
|
+
// loudly (env_mismatch).
|
|
411
519
|
appId: env.appId,
|
|
412
520
|
environment: env.environment,
|
|
413
521
|
sdk: env.sdk,
|
|
414
522
|
events: batch
|
|
415
523
|
},
|
|
416
|
-
keepalive: options.keepalive === true
|
|
524
|
+
keepalive: options.keepalive === true,
|
|
525
|
+
idempotencyKey: batchId
|
|
417
526
|
});
|
|
418
527
|
this.lastFlushAt = Date.now();
|
|
419
528
|
this.lastError = null;
|
|
420
529
|
this.inFlight -= batch.length;
|
|
530
|
+
this.retry.recordSuccess();
|
|
531
|
+
this.persistent?.save(this.buffer);
|
|
421
532
|
if (!this.firstFlushFired) {
|
|
422
533
|
this.firstFlushFired = true;
|
|
423
534
|
this.cfg.onFirstFlushSuccess?.();
|
|
@@ -426,18 +537,33 @@ var EventQueue = class {
|
|
|
426
537
|
} catch (err) {
|
|
427
538
|
this.buffer.unshift(...batch);
|
|
428
539
|
this.inFlight -= batch.length;
|
|
429
|
-
|
|
430
|
-
this.
|
|
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
|
+
});
|
|
431
553
|
return null;
|
|
432
554
|
}
|
|
433
555
|
}
|
|
434
|
-
/** Cancel any pending timer and clear in-memory state. */
|
|
556
|
+
/** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
|
|
435
557
|
reset() {
|
|
436
558
|
this.cancelTimerIfSet();
|
|
559
|
+
this.nextRetryAt = null;
|
|
437
560
|
this.buffer = [];
|
|
438
561
|
this.dropped = 0;
|
|
439
562
|
this.inFlight = 0;
|
|
440
563
|
this.lastError = null;
|
|
564
|
+
this.retry.recordSuccess();
|
|
565
|
+
this.persistent?.clear();
|
|
566
|
+
this.cfg.onBufferChange?.(0);
|
|
441
567
|
}
|
|
442
568
|
getStats() {
|
|
443
569
|
return {
|
|
@@ -445,9 +571,12 @@ var EventQueue = class {
|
|
|
445
571
|
dropped: this.dropped,
|
|
446
572
|
inFlight: this.inFlight,
|
|
447
573
|
lastFlushAt: this.lastFlushAt,
|
|
448
|
-
lastError: this.lastError
|
|
574
|
+
lastError: this.lastError,
|
|
575
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
576
|
+
nextRetryAt: this.nextRetryAt
|
|
449
577
|
};
|
|
450
578
|
}
|
|
579
|
+
// ---------- internal scheduling ----------
|
|
451
580
|
scheduleIdleFlush() {
|
|
452
581
|
this.cancelTimerIfSet();
|
|
453
582
|
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
@@ -455,13 +584,31 @@ var EventQueue = class {
|
|
|
455
584
|
void this.flush();
|
|
456
585
|
}, this.cfg.intervalMs);
|
|
457
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
|
+
}
|
|
458
595
|
cancelTimerIfSet() {
|
|
459
596
|
if (this.cancelTimer) {
|
|
460
597
|
this.cancelTimer();
|
|
461
598
|
this.cancelTimer = null;
|
|
462
599
|
}
|
|
463
600
|
}
|
|
601
|
+
mintBatchId() {
|
|
602
|
+
return `batch_${Date.now().toString(36)}${randomChars(10)}`;
|
|
603
|
+
}
|
|
464
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
|
+
}
|
|
465
612
|
function defaultScheduler(fn, ms) {
|
|
466
613
|
const id = setTimeout(fn, ms);
|
|
467
614
|
if (typeof id.unref === "function") {
|
|
@@ -473,6 +620,87 @@ function defaultScheduler(fn, ms) {
|
|
|
473
620
|
return () => clearTimeout(id);
|
|
474
621
|
}
|
|
475
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
|
+
|
|
476
704
|
// src/storage.ts
|
|
477
705
|
var MemoryStorage = class {
|
|
478
706
|
constructor() {
|
|
@@ -663,7 +891,8 @@ var DEFAULT_AUTO_TRACK = {
|
|
|
663
891
|
sessions: true,
|
|
664
892
|
pageViews: true,
|
|
665
893
|
deviceInfo: true,
|
|
666
|
-
clicks: true
|
|
894
|
+
clicks: true,
|
|
895
|
+
webVitals: true
|
|
667
896
|
};
|
|
668
897
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
669
898
|
var EMPTY_ACQUISITION = {
|
|
@@ -672,7 +901,13 @@ var EMPTY_ACQUISITION = {
|
|
|
672
901
|
utm_campaign: "",
|
|
673
902
|
utm_content: "",
|
|
674
903
|
utm_term: "",
|
|
675
|
-
referrer: ""
|
|
904
|
+
referrer: "",
|
|
905
|
+
gclid: "",
|
|
906
|
+
fbclid: "",
|
|
907
|
+
msclkid: "",
|
|
908
|
+
ttclid: "",
|
|
909
|
+
li_fat_id: "",
|
|
910
|
+
twclid: ""
|
|
676
911
|
};
|
|
677
912
|
var AutoTracker = class {
|
|
678
913
|
constructor(cfg, track) {
|
|
@@ -680,6 +915,17 @@ var AutoTracker = class {
|
|
|
680
915
|
this.track = track;
|
|
681
916
|
this.session = null;
|
|
682
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;
|
|
683
929
|
}
|
|
684
930
|
install() {
|
|
685
931
|
if (!isBrowserSafe()) return;
|
|
@@ -710,6 +956,10 @@ var AutoTracker = class {
|
|
|
710
956
|
get currentSessionId() {
|
|
711
957
|
return this.session?.sessionId ?? null;
|
|
712
958
|
}
|
|
959
|
+
/** Stable per-page-view ID. Null before the first page.viewed has fired. */
|
|
960
|
+
get currentPageviewId() {
|
|
961
|
+
return this.pageviewId;
|
|
962
|
+
}
|
|
713
963
|
/**
|
|
714
964
|
* Per-session acquisition context — utm_* + referrer, captured once
|
|
715
965
|
* at session start. Returns empty strings when there's no session
|
|
@@ -790,7 +1040,9 @@ var AutoTracker = class {
|
|
|
790
1040
|
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
791
1041
|
lastFiredAt = now;
|
|
792
1042
|
lastFiredUrl = url;
|
|
1043
|
+
this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
|
|
793
1044
|
this.track("page.viewed", {
|
|
1045
|
+
pageviewId: this.pageviewId,
|
|
794
1046
|
path: loc.pathname,
|
|
795
1047
|
url,
|
|
796
1048
|
search: loc.search || void 0,
|
|
@@ -994,6 +1246,12 @@ function captureAcquisition() {
|
|
|
994
1246
|
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
995
1247
|
result.utm_content = params.get("utm_content") ?? "";
|
|
996
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") ?? "";
|
|
997
1255
|
} catch {
|
|
998
1256
|
}
|
|
999
1257
|
try {
|
|
@@ -1051,6 +1309,490 @@ function safeJson(obj) {
|
|
|
1051
1309
|
}
|
|
1052
1310
|
}
|
|
1053
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
|
+
|
|
1054
1796
|
// src/crossdeck.ts
|
|
1055
1797
|
var CrossdeckClient = class {
|
|
1056
1798
|
constructor() {
|
|
@@ -1140,6 +1882,13 @@ var CrossdeckClient = class {
|
|
|
1140
1882
|
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1141
1883
|
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
1142
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
|
+
}
|
|
1143
1892
|
const events = new EventQueue({
|
|
1144
1893
|
http,
|
|
1145
1894
|
batchSize: opts.eventFlushBatchSize,
|
|
@@ -1149,26 +1898,51 @@ var CrossdeckClient = class {
|
|
|
1149
1898
|
environment: opts.environment,
|
|
1150
1899
|
sdk: { name: SDK_NAME, version: opts.sdkVersion }
|
|
1151
1900
|
}),
|
|
1901
|
+
persistentStore: persistentEvents ?? void 0,
|
|
1152
1902
|
onFirstFlushSuccess: () => {
|
|
1153
1903
|
debug.emit(
|
|
1154
1904
|
"sdk.first_event_sent",
|
|
1155
1905
|
"First telemetry event received. View it in Live Events.",
|
|
1156
1906
|
{ appId: opts.appId, environment: opts.environment }
|
|
1157
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
|
+
);
|
|
1158
1915
|
}
|
|
1159
1916
|
});
|
|
1160
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
|
+
}
|
|
1161
1929
|
this.state = {
|
|
1162
1930
|
http,
|
|
1163
1931
|
identity,
|
|
1164
1932
|
entitlements,
|
|
1165
1933
|
events,
|
|
1166
1934
|
autoTracker: null,
|
|
1935
|
+
webVitals: null,
|
|
1936
|
+
superProps,
|
|
1937
|
+
consent,
|
|
1938
|
+
scrubPii: options.scrubPii !== false,
|
|
1167
1939
|
deviceInfo,
|
|
1168
1940
|
options: opts,
|
|
1169
1941
|
debug,
|
|
1170
1942
|
developerUserId: null,
|
|
1171
|
-
uninstallUnloadFlush: null
|
|
1943
|
+
uninstallUnloadFlush: null,
|
|
1944
|
+
lastServerTime: null,
|
|
1945
|
+
lastClientTime: null
|
|
1172
1946
|
};
|
|
1173
1947
|
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
1174
1948
|
appId: opts.appId,
|
|
@@ -1183,6 +1957,14 @@ var CrossdeckClient = class {
|
|
|
1183
1957
|
this.state.autoTracker = tracker;
|
|
1184
1958
|
tracker.install();
|
|
1185
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
|
+
}
|
|
1186
1968
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
1187
1969
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
1188
1970
|
});
|
|
@@ -1206,8 +1988,19 @@ var CrossdeckClient = class {
|
|
|
1206
1988
|
/**
|
|
1207
1989
|
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
1208
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
|
+
* });
|
|
1209
2002
|
*/
|
|
1210
|
-
async identify(userId,
|
|
2003
|
+
async identify(userId, options) {
|
|
1211
2004
|
const s = this.requireStarted();
|
|
1212
2005
|
if (!userId) {
|
|
1213
2006
|
throw new CrossdeckError({
|
|
@@ -1216,13 +2009,163 @@ var CrossdeckClient = class {
|
|
|
1216
2009
|
message: "identify(userId) requires a non-empty userId."
|
|
1217
2010
|
});
|
|
1218
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;
|
|
1219
2042
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
1220
|
-
body
|
|
2043
|
+
body
|
|
1221
2044
|
});
|
|
1222
2045
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
1223
2046
|
s.developerUserId = userId;
|
|
1224
2047
|
return result;
|
|
1225
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
|
+
}
|
|
1226
2169
|
/**
|
|
1227
2170
|
* Read the current customer's active entitlements from the server.
|
|
1228
2171
|
* Updates the local cache so subsequent isEntitled() calls answer
|
|
@@ -1300,6 +2243,17 @@ var CrossdeckClient = class {
|
|
|
1300
2243
|
message: "track(name) requires a non-empty name."
|
|
1301
2244
|
});
|
|
1302
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
|
+
}
|
|
1303
2257
|
if (s.debug.enabled && properties) {
|
|
1304
2258
|
const flagged = findSensitivePropertyKeys(properties);
|
|
1305
2259
|
if (flagged.length > 0) {
|
|
@@ -1316,9 +2270,21 @@ var CrossdeckClient = class {
|
|
|
1316
2270
|
"Using anonymous user until identify(userId) is called."
|
|
1317
2271
|
);
|
|
1318
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
|
+
}
|
|
1319
2283
|
const enriched = { ...s.deviceInfo };
|
|
1320
2284
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
1321
2285
|
if (sessionId) enriched.sessionId = sessionId;
|
|
2286
|
+
const pageviewId = s.autoTracker?.currentPageviewId;
|
|
2287
|
+
if (pageviewId) enriched.pageviewId = pageviewId;
|
|
1322
2288
|
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1323
2289
|
if (acquisition) {
|
|
1324
2290
|
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
@@ -1326,14 +2292,31 @@ var CrossdeckClient = class {
|
|
|
1326
2292
|
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1327
2293
|
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1328
2294
|
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1329
|
-
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];
|
|
1330
2308
|
}
|
|
1331
|
-
|
|
2309
|
+
const groupIds = s.superProps.getGroupIds();
|
|
2310
|
+
if (Object.keys(groupIds).length > 0) {
|
|
2311
|
+
enriched.$groups = groupIds;
|
|
2312
|
+
}
|
|
2313
|
+
Object.assign(enriched, validation.properties);
|
|
2314
|
+
const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
|
|
1332
2315
|
const event = {
|
|
1333
2316
|
eventId: this.mintEventId(),
|
|
1334
2317
|
name,
|
|
1335
2318
|
timestamp: Date.now(),
|
|
1336
|
-
properties:
|
|
2319
|
+
properties: finalProperties
|
|
1337
2320
|
};
|
|
1338
2321
|
Object.assign(event, this.identityHintForEvent());
|
|
1339
2322
|
s.events.enqueue(event);
|
|
@@ -1411,7 +2394,12 @@ var CrossdeckClient = class {
|
|
|
1411
2394
|
*/
|
|
1412
2395
|
async heartbeat() {
|
|
1413
2396
|
const s = this.requireStarted();
|
|
1414
|
-
|
|
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;
|
|
1415
2403
|
}
|
|
1416
2404
|
/**
|
|
1417
2405
|
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
@@ -1430,6 +2418,7 @@ var CrossdeckClient = class {
|
|
|
1430
2418
|
this.state.identity.reset();
|
|
1431
2419
|
this.state.entitlements.clear();
|
|
1432
2420
|
this.state.events.reset();
|
|
2421
|
+
this.state.superProps.clear();
|
|
1433
2422
|
this.state.developerUserId = null;
|
|
1434
2423
|
if (this.state.autoTracker) {
|
|
1435
2424
|
const tracker = new AutoTracker(
|
|
@@ -1457,17 +2446,21 @@ var CrossdeckClient = class {
|
|
|
1457
2446
|
developerUserId: null,
|
|
1458
2447
|
sdkVersion: null,
|
|
1459
2448
|
baseUrl: null,
|
|
1460
|
-
|
|
2449
|
+
clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
|
|
2450
|
+
entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
|
|
1461
2451
|
events: {
|
|
1462
2452
|
buffered: 0,
|
|
1463
2453
|
dropped: 0,
|
|
1464
2454
|
inFlight: 0,
|
|
1465
2455
|
lastFlushAt: 0,
|
|
1466
|
-
lastError: null
|
|
2456
|
+
lastError: null,
|
|
2457
|
+
consecutiveFailures: 0,
|
|
2458
|
+
nextRetryAt: null
|
|
1467
2459
|
}
|
|
1468
2460
|
};
|
|
1469
2461
|
}
|
|
1470
2462
|
const s = this.state;
|
|
2463
|
+
const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
|
|
1471
2464
|
return {
|
|
1472
2465
|
started: true,
|
|
1473
2466
|
anonymousId: s.identity.anonymousId,
|
|
@@ -1475,9 +2468,15 @@ var CrossdeckClient = class {
|
|
|
1475
2468
|
developerUserId: s.developerUserId,
|
|
1476
2469
|
sdkVersion: s.options.sdkVersion,
|
|
1477
2470
|
baseUrl: s.options.baseUrl,
|
|
2471
|
+
clock: {
|
|
2472
|
+
lastServerTime: s.lastServerTime,
|
|
2473
|
+
lastClientTime: s.lastClientTime,
|
|
2474
|
+
skewMs
|
|
2475
|
+
},
|
|
1478
2476
|
entitlements: {
|
|
1479
2477
|
count: s.entitlements.list().length,
|
|
1480
|
-
lastUpdated: s.entitlements.freshness
|
|
2478
|
+
lastUpdated: s.entitlements.freshness,
|
|
2479
|
+
listenerErrors: s.entitlements.listenerErrors
|
|
1481
2480
|
},
|
|
1482
2481
|
events: s.events.getStats()
|
|
1483
2482
|
};
|
|
@@ -1544,6 +2543,7 @@ function inferEnvFromKey(publicKey) {
|
|
|
1544
2543
|
}
|
|
1545
2544
|
function isLocalHostname() {
|
|
1546
2545
|
const w = globalThis.window;
|
|
2546
|
+
if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
|
|
1547
2547
|
const hostname = w?.location?.hostname;
|
|
1548
2548
|
if (!hostname) return false;
|
|
1549
2549
|
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
@@ -1556,7 +2556,13 @@ function isLocalHostname() {
|
|
|
1556
2556
|
}
|
|
1557
2557
|
function resolveAutoTrack(input) {
|
|
1558
2558
|
if (input === false) {
|
|
1559
|
-
return {
|
|
2559
|
+
return {
|
|
2560
|
+
sessions: false,
|
|
2561
|
+
pageViews: false,
|
|
2562
|
+
deviceInfo: false,
|
|
2563
|
+
clicks: false,
|
|
2564
|
+
webVitals: false
|
|
2565
|
+
};
|
|
1560
2566
|
}
|
|
1561
2567
|
if (input === void 0 || input === true) {
|
|
1562
2568
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1565,7 +2571,8 @@ function resolveAutoTrack(input) {
|
|
|
1565
2571
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1566
2572
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1567
2573
|
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1568
|
-
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
2574
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
|
|
2575
|
+
webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
|
|
1569
2576
|
};
|
|
1570
2577
|
}
|
|
1571
2578
|
function installUnloadFlush(onUnload) {
|