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