@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.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 = "1.0.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,9 @@ 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,
|
|
896
|
+
errors: true
|
|
667
897
|
};
|
|
668
898
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
669
899
|
var EMPTY_ACQUISITION = {
|
|
@@ -672,7 +902,13 @@ var EMPTY_ACQUISITION = {
|
|
|
672
902
|
utm_campaign: "",
|
|
673
903
|
utm_content: "",
|
|
674
904
|
utm_term: "",
|
|
675
|
-
referrer: ""
|
|
905
|
+
referrer: "",
|
|
906
|
+
gclid: "",
|
|
907
|
+
fbclid: "",
|
|
908
|
+
msclkid: "",
|
|
909
|
+
ttclid: "",
|
|
910
|
+
li_fat_id: "",
|
|
911
|
+
twclid: ""
|
|
676
912
|
};
|
|
677
913
|
var AutoTracker = class {
|
|
678
914
|
constructor(cfg, track) {
|
|
@@ -680,6 +916,17 @@ var AutoTracker = class {
|
|
|
680
916
|
this.track = track;
|
|
681
917
|
this.session = null;
|
|
682
918
|
this.cleanups = [];
|
|
919
|
+
/**
|
|
920
|
+
* Stable per-page-view identifier. Minted at every `page.viewed`
|
|
921
|
+
* emission and attached to every subsequent event until the next
|
|
922
|
+
* `page.viewed`. Lets dashboards correlate "user clicked X" to
|
|
923
|
+
* "user viewed page Y" without timestamp arithmetic — the canonical
|
|
924
|
+
* Mixpanel `$current_url` / Segment `pageId` pattern.
|
|
925
|
+
*
|
|
926
|
+
* Null until the first `page.viewed` fires (which happens at SDK
|
|
927
|
+
* install if `autoTrack.pageViews !== false`).
|
|
928
|
+
*/
|
|
929
|
+
this.pageviewId = null;
|
|
683
930
|
}
|
|
684
931
|
install() {
|
|
685
932
|
if (!isBrowserSafe()) return;
|
|
@@ -710,6 +957,10 @@ var AutoTracker = class {
|
|
|
710
957
|
get currentSessionId() {
|
|
711
958
|
return this.session?.sessionId ?? null;
|
|
712
959
|
}
|
|
960
|
+
/** Stable per-page-view ID. Null before the first page.viewed has fired. */
|
|
961
|
+
get currentPageviewId() {
|
|
962
|
+
return this.pageviewId;
|
|
963
|
+
}
|
|
713
964
|
/**
|
|
714
965
|
* Per-session acquisition context — utm_* + referrer, captured once
|
|
715
966
|
* at session start. Returns empty strings when there's no session
|
|
@@ -790,7 +1041,9 @@ var AutoTracker = class {
|
|
|
790
1041
|
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
791
1042
|
lastFiredAt = now;
|
|
792
1043
|
lastFiredUrl = url;
|
|
1044
|
+
this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
|
|
793
1045
|
this.track("page.viewed", {
|
|
1046
|
+
pageviewId: this.pageviewId,
|
|
794
1047
|
path: loc.pathname,
|
|
795
1048
|
url,
|
|
796
1049
|
search: loc.search || void 0,
|
|
@@ -994,6 +1247,12 @@ function captureAcquisition() {
|
|
|
994
1247
|
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
995
1248
|
result.utm_content = params.get("utm_content") ?? "";
|
|
996
1249
|
result.utm_term = params.get("utm_term") ?? "";
|
|
1250
|
+
result.gclid = params.get("gclid") ?? "";
|
|
1251
|
+
result.fbclid = params.get("fbclid") ?? "";
|
|
1252
|
+
result.msclkid = params.get("msclkid") ?? "";
|
|
1253
|
+
result.ttclid = params.get("ttclid") ?? "";
|
|
1254
|
+
result.li_fat_id = params.get("li_fat_id") ?? "";
|
|
1255
|
+
result.twclid = params.get("twclid") ?? "";
|
|
997
1256
|
} catch {
|
|
998
1257
|
}
|
|
999
1258
|
try {
|
|
@@ -1051,6 +1310,1025 @@ function safeJson(obj) {
|
|
|
1051
1310
|
}
|
|
1052
1311
|
}
|
|
1053
1312
|
|
|
1313
|
+
// src/event-validation.ts
|
|
1314
|
+
var DEFAULT_MAX_STRING = 1024;
|
|
1315
|
+
var DEFAULT_MAX_BYTES = 8 * 1024;
|
|
1316
|
+
var DEFAULT_MAX_DEPTH = 5;
|
|
1317
|
+
function validateEventProperties(input, options = {}) {
|
|
1318
|
+
const warnings = [];
|
|
1319
|
+
if (!input) return { properties: {}, warnings };
|
|
1320
|
+
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
|
|
1321
|
+
const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
|
|
1322
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1323
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1324
|
+
const visit = (value, key, depth) => {
|
|
1325
|
+
if (depth > maxDepth) {
|
|
1326
|
+
warnings.push({ kind: "depth_exceeded", key });
|
|
1327
|
+
return { keep: true, value: "[depth-exceeded]" };
|
|
1328
|
+
}
|
|
1329
|
+
if (value === null) return { keep: true, value: null };
|
|
1330
|
+
const t = typeof value;
|
|
1331
|
+
if (t === "string") {
|
|
1332
|
+
const s = value;
|
|
1333
|
+
if (s.length > maxStringLength) {
|
|
1334
|
+
warnings.push({ kind: "truncated_string", key });
|
|
1335
|
+
return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
|
|
1336
|
+
}
|
|
1337
|
+
return { keep: true, value: s };
|
|
1338
|
+
}
|
|
1339
|
+
if (t === "number") {
|
|
1340
|
+
if (!Number.isFinite(value)) {
|
|
1341
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1342
|
+
return { keep: true, value: null };
|
|
1343
|
+
}
|
|
1344
|
+
return { keep: true, value };
|
|
1345
|
+
}
|
|
1346
|
+
if (t === "boolean") return { keep: true, value };
|
|
1347
|
+
if (t === "bigint") {
|
|
1348
|
+
warnings.push({ kind: "coerced_bigint", key });
|
|
1349
|
+
return { keep: true, value: value.toString() };
|
|
1350
|
+
}
|
|
1351
|
+
if (t === "function") {
|
|
1352
|
+
warnings.push({ kind: "dropped_function", key });
|
|
1353
|
+
return { keep: false, value: void 0 };
|
|
1354
|
+
}
|
|
1355
|
+
if (t === "symbol") {
|
|
1356
|
+
warnings.push({ kind: "dropped_symbol", key });
|
|
1357
|
+
return { keep: false, value: void 0 };
|
|
1358
|
+
}
|
|
1359
|
+
if (t === "undefined") {
|
|
1360
|
+
warnings.push({ kind: "dropped_undefined", key });
|
|
1361
|
+
return { keep: false, value: void 0 };
|
|
1362
|
+
}
|
|
1363
|
+
if (value instanceof Date) {
|
|
1364
|
+
warnings.push({ kind: "coerced_date", key });
|
|
1365
|
+
const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
|
|
1366
|
+
return { keep: true, value: iso };
|
|
1367
|
+
}
|
|
1368
|
+
if (value instanceof Error) {
|
|
1369
|
+
warnings.push({ kind: "coerced_error", key });
|
|
1370
|
+
return {
|
|
1371
|
+
keep: true,
|
|
1372
|
+
value: {
|
|
1373
|
+
name: value.name,
|
|
1374
|
+
message: value.message,
|
|
1375
|
+
stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
if (value instanceof Map) {
|
|
1380
|
+
warnings.push({ kind: "coerced_map", key });
|
|
1381
|
+
const obj = {};
|
|
1382
|
+
for (const [k, v] of value.entries()) {
|
|
1383
|
+
const subKey = typeof k === "string" ? k : String(k);
|
|
1384
|
+
const result = visit(v, `${key}.${subKey}`, depth + 1);
|
|
1385
|
+
if (result.keep) obj[subKey] = result.value;
|
|
1386
|
+
}
|
|
1387
|
+
return { keep: true, value: obj };
|
|
1388
|
+
}
|
|
1389
|
+
if (value instanceof Set) {
|
|
1390
|
+
warnings.push({ kind: "coerced_set", key });
|
|
1391
|
+
const arr = [];
|
|
1392
|
+
let i = 0;
|
|
1393
|
+
for (const v of value.values()) {
|
|
1394
|
+
const result = visit(v, `${key}[${i}]`, depth + 1);
|
|
1395
|
+
if (result.keep) arr.push(result.value);
|
|
1396
|
+
i++;
|
|
1397
|
+
}
|
|
1398
|
+
return { keep: true, value: arr };
|
|
1399
|
+
}
|
|
1400
|
+
if (Array.isArray(value)) {
|
|
1401
|
+
if (seen.has(value)) {
|
|
1402
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1403
|
+
return { keep: true, value: "[circular]" };
|
|
1404
|
+
}
|
|
1405
|
+
seen.add(value);
|
|
1406
|
+
const out = [];
|
|
1407
|
+
for (let i = 0; i < value.length; i++) {
|
|
1408
|
+
const result = visit(value[i], `${key}[${i}]`, depth + 1);
|
|
1409
|
+
if (result.keep) out.push(result.value);
|
|
1410
|
+
}
|
|
1411
|
+
return { keep: true, value: out };
|
|
1412
|
+
}
|
|
1413
|
+
if (t === "object") {
|
|
1414
|
+
const obj = value;
|
|
1415
|
+
if (seen.has(obj)) {
|
|
1416
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1417
|
+
return { keep: true, value: "[circular]" };
|
|
1418
|
+
}
|
|
1419
|
+
seen.add(obj);
|
|
1420
|
+
const out = {};
|
|
1421
|
+
for (const k of Object.keys(obj)) {
|
|
1422
|
+
const result = visit(obj[k], `${key}.${k}`, depth + 1);
|
|
1423
|
+
if (result.keep) out[k] = result.value;
|
|
1424
|
+
}
|
|
1425
|
+
return { keep: true, value: out };
|
|
1426
|
+
}
|
|
1427
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1428
|
+
try {
|
|
1429
|
+
return { keep: true, value: String(value) };
|
|
1430
|
+
} catch {
|
|
1431
|
+
return { keep: false, value: void 0 };
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
const cleaned = {};
|
|
1435
|
+
for (const k of Object.keys(input)) {
|
|
1436
|
+
const result = visit(input[k], k, 0);
|
|
1437
|
+
if (result.keep) cleaned[k] = result.value;
|
|
1438
|
+
}
|
|
1439
|
+
const serialised = safeStringify(cleaned);
|
|
1440
|
+
if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
|
|
1441
|
+
warnings.push({ kind: "size_cap_exceeded", key: "*" });
|
|
1442
|
+
const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
|
|
1443
|
+
let currentSize = byteLength(serialised);
|
|
1444
|
+
for (const { k } of sizes) {
|
|
1445
|
+
if (currentSize <= maxBatchPropertyBytes) break;
|
|
1446
|
+
currentSize -= sizes.find((s) => s.k === k).size;
|
|
1447
|
+
delete cleaned[k];
|
|
1448
|
+
}
|
|
1449
|
+
cleaned.__truncated = true;
|
|
1450
|
+
}
|
|
1451
|
+
return { properties: cleaned, warnings };
|
|
1452
|
+
}
|
|
1453
|
+
function safeStringify(v) {
|
|
1454
|
+
try {
|
|
1455
|
+
return JSON.stringify(v) ?? null;
|
|
1456
|
+
} catch {
|
|
1457
|
+
return null;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
function byteLength(s) {
|
|
1461
|
+
if (typeof TextEncoder !== "undefined") {
|
|
1462
|
+
return new TextEncoder().encode(s).length;
|
|
1463
|
+
}
|
|
1464
|
+
return s.length * 4;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// src/super-properties.ts
|
|
1468
|
+
var KEY_SUPER = "super_props";
|
|
1469
|
+
var KEY_GROUPS = "groups";
|
|
1470
|
+
var SuperPropertyStore = class {
|
|
1471
|
+
constructor(storage, prefix) {
|
|
1472
|
+
this.storage = storage;
|
|
1473
|
+
this.prefix = prefix;
|
|
1474
|
+
this.superProps = {};
|
|
1475
|
+
this.groups = {};
|
|
1476
|
+
this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
|
|
1477
|
+
this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
|
|
1478
|
+
}
|
|
1479
|
+
// ---------- super properties ----------
|
|
1480
|
+
/**
|
|
1481
|
+
* Merge new keys into the super-property bag. Returns a snapshot of
|
|
1482
|
+
* the resulting bag. Values that are `null` are deleted (Mixpanel
|
|
1483
|
+
* semantics — explicit null = "stop tracking this key").
|
|
1484
|
+
*/
|
|
1485
|
+
register(props) {
|
|
1486
|
+
for (const [k, v] of Object.entries(props)) {
|
|
1487
|
+
if (v === null) {
|
|
1488
|
+
delete this.superProps[k];
|
|
1489
|
+
} else if (v !== void 0) {
|
|
1490
|
+
this.superProps[k] = v;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1494
|
+
return { ...this.superProps };
|
|
1495
|
+
}
|
|
1496
|
+
/** Remove a single super-property key. Idempotent. */
|
|
1497
|
+
unregister(key) {
|
|
1498
|
+
if (key in this.superProps) {
|
|
1499
|
+
delete this.superProps[key];
|
|
1500
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
/** Snapshot of the current super-property bag. */
|
|
1504
|
+
getSuperProperties() {
|
|
1505
|
+
return { ...this.superProps };
|
|
1506
|
+
}
|
|
1507
|
+
// ---------- groups ----------
|
|
1508
|
+
/**
|
|
1509
|
+
* Set a group membership. Passing `id: null` clears the membership
|
|
1510
|
+
* for that group type — the SDK stops attaching it to events.
|
|
1511
|
+
*/
|
|
1512
|
+
setGroup(type, id, traits) {
|
|
1513
|
+
if (id === null) {
|
|
1514
|
+
delete this.groups[type];
|
|
1515
|
+
} else {
|
|
1516
|
+
this.groups[type] = traits !== void 0 ? { id, traits } : { id };
|
|
1517
|
+
}
|
|
1518
|
+
writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Snapshot of the current groups map, keyed by group type. Returned
|
|
1522
|
+
* shape mirrors what the SDK attaches to every event as
|
|
1523
|
+
* `$groups.{type}`. The `traits` sub-object is the most-recent
|
|
1524
|
+
* traits payload passed to `setGroup` for that type; null when none.
|
|
1525
|
+
*/
|
|
1526
|
+
getGroups() {
|
|
1527
|
+
return JSON.parse(JSON.stringify(this.groups));
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* The flat `{ type: id }` projection used for event-attachment. Stable
|
|
1531
|
+
* for fast every-event merge — we don't want to JSON-clone on each
|
|
1532
|
+
* track() call.
|
|
1533
|
+
*/
|
|
1534
|
+
getGroupIds() {
|
|
1535
|
+
const out = {};
|
|
1536
|
+
for (const [type, info] of Object.entries(this.groups)) {
|
|
1537
|
+
out[type] = info.id;
|
|
1538
|
+
}
|
|
1539
|
+
return out;
|
|
1540
|
+
}
|
|
1541
|
+
/** Wipe both bags. Called by Crossdeck.reset() (logout). */
|
|
1542
|
+
clear() {
|
|
1543
|
+
this.superProps = {};
|
|
1544
|
+
this.groups = {};
|
|
1545
|
+
try {
|
|
1546
|
+
this.storage.removeItem(this.prefix + KEY_SUPER);
|
|
1547
|
+
} catch {
|
|
1548
|
+
}
|
|
1549
|
+
try {
|
|
1550
|
+
this.storage.removeItem(this.prefix + KEY_GROUPS);
|
|
1551
|
+
} catch {
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
function readJson(storage, key) {
|
|
1556
|
+
let raw;
|
|
1557
|
+
try {
|
|
1558
|
+
raw = storage.getItem(key);
|
|
1559
|
+
} catch {
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
if (!raw) return null;
|
|
1563
|
+
try {
|
|
1564
|
+
return JSON.parse(raw);
|
|
1565
|
+
} catch {
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
function writeJson(storage, key, value) {
|
|
1570
|
+
try {
|
|
1571
|
+
storage.setItem(key, JSON.stringify(value));
|
|
1572
|
+
} catch {
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/web-vitals.ts
|
|
1577
|
+
var WebVitalsTracker = class {
|
|
1578
|
+
constructor(cfg, report) {
|
|
1579
|
+
this.cfg = cfg;
|
|
1580
|
+
this.report = report;
|
|
1581
|
+
this.observers = [];
|
|
1582
|
+
this.flushed = /* @__PURE__ */ new Set();
|
|
1583
|
+
this.cls = 0;
|
|
1584
|
+
this.clsEntries = [];
|
|
1585
|
+
this.inp = 0;
|
|
1586
|
+
this.cleanups = [];
|
|
1587
|
+
}
|
|
1588
|
+
install() {
|
|
1589
|
+
if (!this.cfg.enabled) return;
|
|
1590
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
1591
|
+
if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
|
|
1592
|
+
const doc = globalThis.document;
|
|
1593
|
+
try {
|
|
1594
|
+
const navObserver = new PerformanceObserver((list) => {
|
|
1595
|
+
for (const entry of list.getEntries()) {
|
|
1596
|
+
const e = entry;
|
|
1597
|
+
if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
|
|
1598
|
+
this.flushed.add("ttfb");
|
|
1599
|
+
this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
navObserver.observe({ type: "navigation", buffered: true });
|
|
1604
|
+
this.observers.push(navObserver);
|
|
1605
|
+
} catch {
|
|
1606
|
+
}
|
|
1607
|
+
try {
|
|
1608
|
+
const paintObserver = new PerformanceObserver((list) => {
|
|
1609
|
+
for (const entry of list.getEntries()) {
|
|
1610
|
+
if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
|
|
1611
|
+
this.flushed.add("fcp");
|
|
1612
|
+
this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
});
|
|
1616
|
+
paintObserver.observe({ type: "paint", buffered: true });
|
|
1617
|
+
this.observers.push(paintObserver);
|
|
1618
|
+
} catch {
|
|
1619
|
+
}
|
|
1620
|
+
let lcpValue = 0;
|
|
1621
|
+
try {
|
|
1622
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
1623
|
+
const entries = list.getEntries();
|
|
1624
|
+
const last = entries[entries.length - 1];
|
|
1625
|
+
if (last) lcpValue = last.startTime;
|
|
1626
|
+
});
|
|
1627
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
1628
|
+
this.observers.push(lcpObserver);
|
|
1629
|
+
} catch {
|
|
1630
|
+
}
|
|
1631
|
+
try {
|
|
1632
|
+
const clsObserver = new PerformanceObserver((list) => {
|
|
1633
|
+
for (const entry of list.getEntries()) {
|
|
1634
|
+
const e = entry;
|
|
1635
|
+
if (typeof e.value === "number" && !e.hadRecentInput) {
|
|
1636
|
+
this.cls += e.value;
|
|
1637
|
+
this.clsEntries.push(entry);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
1642
|
+
this.observers.push(clsObserver);
|
|
1643
|
+
} catch {
|
|
1644
|
+
}
|
|
1645
|
+
try {
|
|
1646
|
+
const eventObserver = new PerformanceObserver((list) => {
|
|
1647
|
+
for (const entry of list.getEntries()) {
|
|
1648
|
+
const e = entry;
|
|
1649
|
+
if (e.interactionId && e.duration > this.inp) {
|
|
1650
|
+
this.inp = e.duration;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
try {
|
|
1655
|
+
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
1656
|
+
} catch {
|
|
1657
|
+
eventObserver.observe({ type: "first-input", buffered: true });
|
|
1658
|
+
}
|
|
1659
|
+
this.observers.push(eventObserver);
|
|
1660
|
+
} catch {
|
|
1661
|
+
}
|
|
1662
|
+
const flush = () => {
|
|
1663
|
+
if (lcpValue > 0 && !this.flushed.has("lcp")) {
|
|
1664
|
+
this.flushed.add("lcp");
|
|
1665
|
+
this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
|
|
1666
|
+
}
|
|
1667
|
+
if (this.cls > 0 && !this.flushed.has("cls")) {
|
|
1668
|
+
this.flushed.add("cls");
|
|
1669
|
+
this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
|
|
1670
|
+
}
|
|
1671
|
+
if (this.inp > 0 && !this.flushed.has("inp")) {
|
|
1672
|
+
this.flushed.add("inp");
|
|
1673
|
+
this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
const onHidden = () => {
|
|
1677
|
+
if (doc.visibilityState === "hidden") flush();
|
|
1678
|
+
};
|
|
1679
|
+
doc.addEventListener("visibilitychange", onHidden);
|
|
1680
|
+
globalThis.window.addEventListener("pagehide", flush);
|
|
1681
|
+
this.cleanups.push(() => {
|
|
1682
|
+
doc.removeEventListener("visibilitychange", onHidden);
|
|
1683
|
+
globalThis.window.removeEventListener("pagehide", flush);
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
uninstall() {
|
|
1687
|
+
for (const o of this.observers) {
|
|
1688
|
+
try {
|
|
1689
|
+
o.disconnect();
|
|
1690
|
+
} catch {
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
this.observers = [];
|
|
1694
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1695
|
+
try {
|
|
1696
|
+
fn();
|
|
1697
|
+
} catch {
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
// src/consent.ts
|
|
1704
|
+
var ALL_GRANTED = {
|
|
1705
|
+
analytics: true,
|
|
1706
|
+
marketing: true,
|
|
1707
|
+
errors: true
|
|
1708
|
+
};
|
|
1709
|
+
var ConsentManager = class {
|
|
1710
|
+
constructor(options) {
|
|
1711
|
+
this.state = { ...ALL_GRANTED };
|
|
1712
|
+
this.dntDenied = false;
|
|
1713
|
+
if (options?.respectDnt && this.detectDnt()) {
|
|
1714
|
+
this.dntDenied = true;
|
|
1715
|
+
this.state = { analytics: false, marketing: false, errors: false };
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Merge new dimensions onto the current state. Returns the resulting
|
|
1720
|
+
* snapshot. DNT-derived denies cannot be flipped back on by a `set`
|
|
1721
|
+
* call — once the browser says "don't track", we don't track even if
|
|
1722
|
+
* the developer code disagrees. That's the contract.
|
|
1723
|
+
*/
|
|
1724
|
+
set(partial) {
|
|
1725
|
+
if (this.dntDenied) return { ...this.state };
|
|
1726
|
+
for (const k of Object.keys(partial)) {
|
|
1727
|
+
const v = partial[k];
|
|
1728
|
+
if (typeof v === "boolean") this.state[k] = v;
|
|
1729
|
+
}
|
|
1730
|
+
return { ...this.state };
|
|
1731
|
+
}
|
|
1732
|
+
/** Snapshot of the current state. */
|
|
1733
|
+
get() {
|
|
1734
|
+
return { ...this.state };
|
|
1735
|
+
}
|
|
1736
|
+
/** Convenience getters for hot paths. */
|
|
1737
|
+
get analytics() {
|
|
1738
|
+
return this.state.analytics;
|
|
1739
|
+
}
|
|
1740
|
+
get marketing() {
|
|
1741
|
+
return this.state.marketing;
|
|
1742
|
+
}
|
|
1743
|
+
get errors() {
|
|
1744
|
+
return this.state.errors;
|
|
1745
|
+
}
|
|
1746
|
+
/** True iff the constructor detected and applied DNT. */
|
|
1747
|
+
get isDntDenied() {
|
|
1748
|
+
return this.dntDenied;
|
|
1749
|
+
}
|
|
1750
|
+
detectDnt() {
|
|
1751
|
+
try {
|
|
1752
|
+
const nav = globalThis.navigator;
|
|
1753
|
+
if (!nav) return false;
|
|
1754
|
+
const sources = [
|
|
1755
|
+
nav.doNotTrack,
|
|
1756
|
+
nav.msDoNotTrack,
|
|
1757
|
+
globalThis.doNotTrack
|
|
1758
|
+
];
|
|
1759
|
+
return sources.some((v) => v === "1" || v === "yes");
|
|
1760
|
+
} catch {
|
|
1761
|
+
return false;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1765
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
1766
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
1767
|
+
var REPLACEMENT_EMAIL = "[email]";
|
|
1768
|
+
var REPLACEMENT_CARD = "[card]";
|
|
1769
|
+
function scrubPii(value) {
|
|
1770
|
+
if (!value) return value;
|
|
1771
|
+
let out = value;
|
|
1772
|
+
if (EMAIL_PATTERN.test(out)) {
|
|
1773
|
+
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
1774
|
+
}
|
|
1775
|
+
EMAIL_PATTERN.lastIndex = 0;
|
|
1776
|
+
if (CARD_PATTERN.test(out)) {
|
|
1777
|
+
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1778
|
+
}
|
|
1779
|
+
CARD_PATTERN.lastIndex = 0;
|
|
1780
|
+
return out;
|
|
1781
|
+
}
|
|
1782
|
+
function scrubPiiFromProperties(properties) {
|
|
1783
|
+
const out = {};
|
|
1784
|
+
for (const k of Object.keys(properties)) {
|
|
1785
|
+
const v = properties[k];
|
|
1786
|
+
if (typeof v === "string") {
|
|
1787
|
+
out[k] = scrubPii(v);
|
|
1788
|
+
} else if (Array.isArray(v)) {
|
|
1789
|
+
out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
|
|
1790
|
+
} else {
|
|
1791
|
+
out[k] = v;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return out;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// src/breadcrumbs.ts
|
|
1798
|
+
var BreadcrumbBuffer = class {
|
|
1799
|
+
constructor(maxSize = 50) {
|
|
1800
|
+
this.maxSize = maxSize;
|
|
1801
|
+
this.items = [];
|
|
1802
|
+
}
|
|
1803
|
+
add(crumb) {
|
|
1804
|
+
this.items.push(crumb);
|
|
1805
|
+
if (this.items.length > this.maxSize) {
|
|
1806
|
+
this.items.shift();
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
/** Defensive copy — caller can read freely without mutating buffer state. */
|
|
1810
|
+
snapshot() {
|
|
1811
|
+
return this.items.slice();
|
|
1812
|
+
}
|
|
1813
|
+
clear() {
|
|
1814
|
+
this.items = [];
|
|
1815
|
+
}
|
|
1816
|
+
get size() {
|
|
1817
|
+
return this.items.length;
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
// src/stack-parser.ts
|
|
1822
|
+
function parseStack(stack) {
|
|
1823
|
+
if (!stack || typeof stack !== "string") return [];
|
|
1824
|
+
const lines = stack.split("\n");
|
|
1825
|
+
const frames = [];
|
|
1826
|
+
for (const line of lines) {
|
|
1827
|
+
const trimmed = line.trim();
|
|
1828
|
+
if (!trimmed) continue;
|
|
1829
|
+
const frame = parseLine(trimmed);
|
|
1830
|
+
if (frame) frames.push(frame);
|
|
1831
|
+
}
|
|
1832
|
+
return frames;
|
|
1833
|
+
}
|
|
1834
|
+
function parseLine(line) {
|
|
1835
|
+
let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
|
|
1836
|
+
if (m) {
|
|
1837
|
+
return buildFrame({
|
|
1838
|
+
function: m[1],
|
|
1839
|
+
filename: m[2],
|
|
1840
|
+
lineno: parseInt(m[3], 10),
|
|
1841
|
+
colno: parseInt(m[4], 10),
|
|
1842
|
+
raw: line
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
|
|
1846
|
+
if (m) {
|
|
1847
|
+
return buildFrame({
|
|
1848
|
+
function: "?",
|
|
1849
|
+
filename: m[1],
|
|
1850
|
+
lineno: parseInt(m[2], 10),
|
|
1851
|
+
colno: parseInt(m[3], 10),
|
|
1852
|
+
raw: line
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
|
|
1856
|
+
if (m) {
|
|
1857
|
+
return buildFrame({
|
|
1858
|
+
function: m[1] || "?",
|
|
1859
|
+
filename: m[2],
|
|
1860
|
+
lineno: parseInt(m[3], 10),
|
|
1861
|
+
colno: parseInt(m[4], 10),
|
|
1862
|
+
raw: line
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
if (/^\w*Error/.test(line) || !line.includes(":")) {
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1868
|
+
return {
|
|
1869
|
+
function: "?",
|
|
1870
|
+
filename: "",
|
|
1871
|
+
lineno: 0,
|
|
1872
|
+
colno: 0,
|
|
1873
|
+
in_app: true,
|
|
1874
|
+
raw: line
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
function buildFrame(input) {
|
|
1878
|
+
return {
|
|
1879
|
+
function: input.function || "?",
|
|
1880
|
+
filename: input.filename,
|
|
1881
|
+
lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
|
|
1882
|
+
colno: Number.isFinite(input.colno) ? input.colno : 0,
|
|
1883
|
+
in_app: isInAppFrame(input.filename),
|
|
1884
|
+
raw: input.raw
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
function isInAppFrame(filename) {
|
|
1888
|
+
if (!filename) return true;
|
|
1889
|
+
if (/^(?:chrome|moz|safari|webkit)-extension:\/\//.test(filename)) return false;
|
|
1890
|
+
if (/\bcdn\.jsdelivr\.net\b/.test(filename)) return false;
|
|
1891
|
+
if (/\bunpkg\.com\b/.test(filename)) return false;
|
|
1892
|
+
if (/\bgoogletagmanager\.com\b/.test(filename)) return false;
|
|
1893
|
+
if (/\bgoogle-analytics\.com\b/.test(filename)) return false;
|
|
1894
|
+
if (/\b@cross-deck\/web\b/.test(filename)) return false;
|
|
1895
|
+
if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
|
|
1896
|
+
return true;
|
|
1897
|
+
}
|
|
1898
|
+
function fingerprintError(message, frames) {
|
|
1899
|
+
const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
|
|
1900
|
+
const key = [
|
|
1901
|
+
(message || "").slice(0, 200),
|
|
1902
|
+
...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
|
|
1903
|
+
].join("|");
|
|
1904
|
+
return djb2Hex(key);
|
|
1905
|
+
}
|
|
1906
|
+
function djb2Hex(input) {
|
|
1907
|
+
let h = 5381;
|
|
1908
|
+
for (let i = 0; i < input.length; i++) {
|
|
1909
|
+
h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
1910
|
+
}
|
|
1911
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// src/error-capture.ts
|
|
1915
|
+
var DEFAULT_ERROR_CAPTURE = {
|
|
1916
|
+
enabled: true,
|
|
1917
|
+
onError: true,
|
|
1918
|
+
onUnhandledRejection: true,
|
|
1919
|
+
wrapFetch: true,
|
|
1920
|
+
wrapXhr: true,
|
|
1921
|
+
captureConsole: false,
|
|
1922
|
+
ignoreErrors: [
|
|
1923
|
+
// Classic browser noise. These aren't application bugs.
|
|
1924
|
+
"ResizeObserver loop limit exceeded",
|
|
1925
|
+
"ResizeObserver loop completed with undelivered notifications",
|
|
1926
|
+
"Non-Error promise rejection captured",
|
|
1927
|
+
// Cross-origin script errors that the browser strips — no info,
|
|
1928
|
+
// no way to act on them, just noise.
|
|
1929
|
+
"Script error.",
|
|
1930
|
+
"Script error"
|
|
1931
|
+
],
|
|
1932
|
+
allowUrls: [],
|
|
1933
|
+
denyUrls: [
|
|
1934
|
+
// Common third-party extensions that pollute error streams.
|
|
1935
|
+
/^chrome-extension:\/\//,
|
|
1936
|
+
/^moz-extension:\/\//,
|
|
1937
|
+
/^safari-extension:\/\//,
|
|
1938
|
+
/^webkit-extension:\/\//,
|
|
1939
|
+
/^safari-web-extension:\/\//
|
|
1940
|
+
],
|
|
1941
|
+
sampleRate: 1,
|
|
1942
|
+
maxPerFingerprintPerMinute: 5,
|
|
1943
|
+
maxPerSession: 100
|
|
1944
|
+
};
|
|
1945
|
+
var ErrorTracker = class {
|
|
1946
|
+
constructor(opts) {
|
|
1947
|
+
this.opts = opts;
|
|
1948
|
+
this.installed = false;
|
|
1949
|
+
this.cleanups = [];
|
|
1950
|
+
this._reporting = false;
|
|
1951
|
+
this.sessionCount = 0;
|
|
1952
|
+
this.fingerprintWindow = /* @__PURE__ */ new Map();
|
|
1953
|
+
}
|
|
1954
|
+
install() {
|
|
1955
|
+
if (this.installed) return;
|
|
1956
|
+
if (!this.opts.config.enabled) return;
|
|
1957
|
+
if (typeof globalThis === "undefined" || !("window" in globalThis)) return;
|
|
1958
|
+
const w = globalThis.window;
|
|
1959
|
+
if (this.opts.config.onError) this.installOnErrorListener(w);
|
|
1960
|
+
if (this.opts.config.onUnhandledRejection) this.installRejectionListener(w);
|
|
1961
|
+
if (this.opts.config.wrapFetch) this.installFetchWrap(w);
|
|
1962
|
+
if (this.opts.config.wrapXhr) this.installXhrWrap(w);
|
|
1963
|
+
if (this.opts.config.captureConsole) this.installConsoleWrap();
|
|
1964
|
+
this.installed = true;
|
|
1965
|
+
}
|
|
1966
|
+
uninstall() {
|
|
1967
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1968
|
+
try {
|
|
1969
|
+
fn();
|
|
1970
|
+
} catch {
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
this.installed = false;
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Manual API. Either an Error instance or any unknown value (we
|
|
1977
|
+
* coerce). Returns silently — never throws.
|
|
1978
|
+
*/
|
|
1979
|
+
captureError(error, options) {
|
|
1980
|
+
if (!this.opts.isConsented()) return;
|
|
1981
|
+
try {
|
|
1982
|
+
const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
|
|
1983
|
+
if (options?.context) captured.context = { ...captured.context, ...options.context };
|
|
1984
|
+
if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
|
|
1985
|
+
this.maybeReport(captured);
|
|
1986
|
+
} catch {
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Capture a non-error event as an issue. For "we hit a soft-warning
|
|
1991
|
+
* code path" / "deprecated API used" kinds of signals. Pairs with
|
|
1992
|
+
* Sentry's captureMessage().
|
|
1993
|
+
*/
|
|
1994
|
+
captureMessage(message, level = "info") {
|
|
1995
|
+
if (!this.opts.isConsented()) return;
|
|
1996
|
+
try {
|
|
1997
|
+
const captured = {
|
|
1998
|
+
timestamp: Date.now(),
|
|
1999
|
+
kind: "error.message",
|
|
2000
|
+
level,
|
|
2001
|
+
message,
|
|
2002
|
+
errorType: null,
|
|
2003
|
+
frames: [],
|
|
2004
|
+
rawStack: null,
|
|
2005
|
+
filename: null,
|
|
2006
|
+
lineno: null,
|
|
2007
|
+
colno: null,
|
|
2008
|
+
fingerprint: fingerprintError(message, []),
|
|
2009
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2010
|
+
context: this.opts.getContext(),
|
|
2011
|
+
tags: this.opts.getTags()
|
|
2012
|
+
};
|
|
2013
|
+
this.maybeReport(captured);
|
|
2014
|
+
} catch {
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
// ============================================================
|
|
2018
|
+
// Listener installation
|
|
2019
|
+
// ============================================================
|
|
2020
|
+
installOnErrorListener(w) {
|
|
2021
|
+
const handler = (event) => {
|
|
2022
|
+
if (this._reporting) return;
|
|
2023
|
+
if (!this.opts.isConsented()) return;
|
|
2024
|
+
try {
|
|
2025
|
+
this._reporting = true;
|
|
2026
|
+
const captured = this.buildFromErrorEvent(event);
|
|
2027
|
+
this.maybeReport(captured);
|
|
2028
|
+
} catch {
|
|
2029
|
+
} finally {
|
|
2030
|
+
this._reporting = false;
|
|
2031
|
+
}
|
|
2032
|
+
};
|
|
2033
|
+
w.addEventListener("error", handler, true);
|
|
2034
|
+
this.cleanups.push(() => w.removeEventListener("error", handler, true));
|
|
2035
|
+
}
|
|
2036
|
+
installRejectionListener(w) {
|
|
2037
|
+
const handler = (event) => {
|
|
2038
|
+
if (this._reporting) return;
|
|
2039
|
+
if (!this.opts.isConsented()) return;
|
|
2040
|
+
try {
|
|
2041
|
+
this._reporting = true;
|
|
2042
|
+
const captured = this.buildFromUnknown(
|
|
2043
|
+
event.reason,
|
|
2044
|
+
"error.unhandledrejection",
|
|
2045
|
+
"error"
|
|
2046
|
+
);
|
|
2047
|
+
this.maybeReport(captured);
|
|
2048
|
+
} catch {
|
|
2049
|
+
} finally {
|
|
2050
|
+
this._reporting = false;
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
w.addEventListener("unhandledrejection", handler);
|
|
2054
|
+
this.cleanups.push(() => w.removeEventListener("unhandledrejection", handler));
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Wrap fetch() so failed HTTP requests get auto-captured. We do NOT
|
|
2058
|
+
* call this an "error" for 4xx (those are often expected — auth
|
|
2059
|
+
* required, validation failed). Only 5xx + network failures fire.
|
|
2060
|
+
*/
|
|
2061
|
+
installFetchWrap(w) {
|
|
2062
|
+
const origFetch = w.fetch?.bind(w);
|
|
2063
|
+
if (!origFetch) return;
|
|
2064
|
+
const wrapped = async (...args) => {
|
|
2065
|
+
const input = args[0];
|
|
2066
|
+
const init = args[1] ?? {};
|
|
2067
|
+
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
2068
|
+
const method = (init.method || "GET").toUpperCase();
|
|
2069
|
+
const start = Date.now();
|
|
2070
|
+
this.opts.breadcrumbs.add({
|
|
2071
|
+
timestamp: start,
|
|
2072
|
+
category: "http",
|
|
2073
|
+
message: `${method} ${url}`,
|
|
2074
|
+
data: { url, method }
|
|
2075
|
+
});
|
|
2076
|
+
try {
|
|
2077
|
+
const response = await origFetch(...args);
|
|
2078
|
+
if (response.status >= 500 && this.opts.isConsented()) {
|
|
2079
|
+
if (!url.includes("api.cross-deck.com")) {
|
|
2080
|
+
this.captureHttp({
|
|
2081
|
+
url,
|
|
2082
|
+
method,
|
|
2083
|
+
status: response.status,
|
|
2084
|
+
statusText: response.statusText
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
return response;
|
|
2089
|
+
} catch (err) {
|
|
2090
|
+
if (this.opts.isConsented() && !url.includes("api.cross-deck.com")) {
|
|
2091
|
+
this.captureHttp({
|
|
2092
|
+
url,
|
|
2093
|
+
method,
|
|
2094
|
+
status: 0,
|
|
2095
|
+
statusText: err instanceof Error ? err.message : "network error"
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
throw err;
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
w.fetch = wrapped;
|
|
2102
|
+
this.cleanups.push(() => {
|
|
2103
|
+
if (w.fetch === wrapped) w.fetch = origFetch;
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* Wrap XMLHttpRequest for legacy consumers (jQuery $.ajax under the
|
|
2108
|
+
* hood, older bundlers). Same capture semantics as fetch.
|
|
2109
|
+
*/
|
|
2110
|
+
installXhrWrap(w) {
|
|
2111
|
+
const xhrCtor = w.XMLHttpRequest;
|
|
2112
|
+
const proto = xhrCtor?.prototype;
|
|
2113
|
+
if (!proto) return;
|
|
2114
|
+
const origOpen = proto.open;
|
|
2115
|
+
const origSend = proto.send;
|
|
2116
|
+
const tracker = this;
|
|
2117
|
+
proto.open = function(method, url, ...rest) {
|
|
2118
|
+
this._cdMethod = method;
|
|
2119
|
+
this._cdUrl = url;
|
|
2120
|
+
return origOpen.apply(this, [method, url, ...rest]);
|
|
2121
|
+
};
|
|
2122
|
+
proto.send = function(body) {
|
|
2123
|
+
const xhr = this;
|
|
2124
|
+
const onLoad = () => {
|
|
2125
|
+
try {
|
|
2126
|
+
if (xhr.status >= 500 && tracker.opts.isConsented()) {
|
|
2127
|
+
const url = xhr._cdUrl ?? "";
|
|
2128
|
+
if (!url.includes("api.cross-deck.com")) {
|
|
2129
|
+
tracker.captureHttp({
|
|
2130
|
+
url,
|
|
2131
|
+
method: (xhr._cdMethod ?? "GET").toUpperCase(),
|
|
2132
|
+
status: xhr.status,
|
|
2133
|
+
statusText: xhr.statusText
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
} catch {
|
|
2138
|
+
}
|
|
2139
|
+
};
|
|
2140
|
+
xhr.addEventListener("loadend", onLoad);
|
|
2141
|
+
return origSend.apply(this, [body ?? null]);
|
|
2142
|
+
};
|
|
2143
|
+
this.cleanups.push(() => {
|
|
2144
|
+
proto.open = origOpen;
|
|
2145
|
+
proto.send = origSend;
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
installConsoleWrap() {
|
|
2149
|
+
const console2 = globalThis.console;
|
|
2150
|
+
if (!console2) return;
|
|
2151
|
+
const orig = console2.error.bind(console2);
|
|
2152
|
+
console2.error = (...args) => {
|
|
2153
|
+
try {
|
|
2154
|
+
if (this.opts.isConsented()) {
|
|
2155
|
+
this.captureMessage(args.map((a) => safeStringify2(a)).join(" "), "error");
|
|
2156
|
+
}
|
|
2157
|
+
} catch {
|
|
2158
|
+
}
|
|
2159
|
+
return orig(...args);
|
|
2160
|
+
};
|
|
2161
|
+
this.cleanups.push(() => {
|
|
2162
|
+
console2.error = orig;
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
// ============================================================
|
|
2166
|
+
// Builders
|
|
2167
|
+
// ============================================================
|
|
2168
|
+
buildFromErrorEvent(event) {
|
|
2169
|
+
const err = event.error;
|
|
2170
|
+
const message = event.message || (err instanceof Error ? err.message : "Unknown error");
|
|
2171
|
+
const stack = err instanceof Error ? err.stack ?? null : null;
|
|
2172
|
+
const frames = parseStack(stack);
|
|
2173
|
+
return {
|
|
2174
|
+
timestamp: Date.now(),
|
|
2175
|
+
kind: "error.unhandled",
|
|
2176
|
+
level: "error",
|
|
2177
|
+
message: String(message).slice(0, 1024),
|
|
2178
|
+
errorType: err instanceof Error ? err.name : null,
|
|
2179
|
+
frames,
|
|
2180
|
+
rawStack: stack,
|
|
2181
|
+
filename: event.filename || null,
|
|
2182
|
+
lineno: typeof event.lineno === "number" ? event.lineno : null,
|
|
2183
|
+
colno: typeof event.colno === "number" ? event.colno : null,
|
|
2184
|
+
fingerprint: fingerprintError(message, frames),
|
|
2185
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2186
|
+
context: this.opts.getContext(),
|
|
2187
|
+
tags: this.opts.getTags()
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
buildFromUnknown(err, kind, level) {
|
|
2191
|
+
if (err instanceof Error) {
|
|
2192
|
+
const frames = parseStack(err.stack);
|
|
2193
|
+
return {
|
|
2194
|
+
timestamp: Date.now(),
|
|
2195
|
+
kind,
|
|
2196
|
+
level,
|
|
2197
|
+
message: String(err.message).slice(0, 1024),
|
|
2198
|
+
errorType: err.name,
|
|
2199
|
+
frames,
|
|
2200
|
+
rawStack: err.stack ?? null,
|
|
2201
|
+
filename: null,
|
|
2202
|
+
lineno: null,
|
|
2203
|
+
colno: null,
|
|
2204
|
+
fingerprint: fingerprintError(err.message, frames),
|
|
2205
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2206
|
+
context: this.opts.getContext(),
|
|
2207
|
+
tags: this.opts.getTags()
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
const message = safeStringify2(err).slice(0, 1024);
|
|
2211
|
+
return {
|
|
2212
|
+
timestamp: Date.now(),
|
|
2213
|
+
kind,
|
|
2214
|
+
level,
|
|
2215
|
+
message,
|
|
2216
|
+
errorType: null,
|
|
2217
|
+
frames: [],
|
|
2218
|
+
rawStack: null,
|
|
2219
|
+
filename: null,
|
|
2220
|
+
lineno: null,
|
|
2221
|
+
colno: null,
|
|
2222
|
+
fingerprint: fingerprintError(message, []),
|
|
2223
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2224
|
+
context: this.opts.getContext(),
|
|
2225
|
+
tags: this.opts.getTags()
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
captureHttp(info) {
|
|
2229
|
+
try {
|
|
2230
|
+
const message = `HTTP ${info.status} ${info.method} ${info.url}`;
|
|
2231
|
+
const captured = {
|
|
2232
|
+
timestamp: Date.now(),
|
|
2233
|
+
kind: "error.http",
|
|
2234
|
+
level: "error",
|
|
2235
|
+
message,
|
|
2236
|
+
errorType: `HTTPError`,
|
|
2237
|
+
frames: [],
|
|
2238
|
+
rawStack: null,
|
|
2239
|
+
filename: info.url,
|
|
2240
|
+
lineno: null,
|
|
2241
|
+
colno: null,
|
|
2242
|
+
fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
|
|
2243
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2244
|
+
context: this.opts.getContext(),
|
|
2245
|
+
tags: this.opts.getTags(),
|
|
2246
|
+
http: info
|
|
2247
|
+
};
|
|
2248
|
+
this.maybeReport(captured);
|
|
2249
|
+
} catch {
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
// ============================================================
|
|
2253
|
+
// Reporting pipeline — filter / sample / rate-limit / send
|
|
2254
|
+
// ============================================================
|
|
2255
|
+
maybeReport(err) {
|
|
2256
|
+
if (this.sessionCount >= this.opts.config.maxPerSession) return;
|
|
2257
|
+
if (this.shouldIgnore(err)) return;
|
|
2258
|
+
if (!this.passesUrlGate(err)) return;
|
|
2259
|
+
if (!this.passesSample(err)) return;
|
|
2260
|
+
if (!this.passesRateLimit(err)) return;
|
|
2261
|
+
let finalErr = err;
|
|
2262
|
+
if (this.opts.beforeSend) {
|
|
2263
|
+
try {
|
|
2264
|
+
finalErr = this.opts.beforeSend(err);
|
|
2265
|
+
} catch {
|
|
2266
|
+
finalErr = err;
|
|
2267
|
+
}
|
|
2268
|
+
if (!finalErr) return;
|
|
2269
|
+
}
|
|
2270
|
+
this.sessionCount += 1;
|
|
2271
|
+
try {
|
|
2272
|
+
this.opts.report(finalErr);
|
|
2273
|
+
} catch {
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
shouldIgnore(err) {
|
|
2277
|
+
for (const pat of this.opts.config.ignoreErrors) {
|
|
2278
|
+
if (typeof pat === "string" && err.message.includes(pat)) return true;
|
|
2279
|
+
if (pat instanceof RegExp && pat.test(err.message)) return true;
|
|
2280
|
+
}
|
|
2281
|
+
return false;
|
|
2282
|
+
}
|
|
2283
|
+
passesUrlGate(err) {
|
|
2284
|
+
const topFrame = err.frames.find((f) => f.filename) ?? null;
|
|
2285
|
+
const url = topFrame?.filename ?? err.filename ?? "";
|
|
2286
|
+
if (!url) return true;
|
|
2287
|
+
for (const pat of this.opts.config.denyUrls) {
|
|
2288
|
+
if (typeof pat === "string" && url.includes(pat)) return false;
|
|
2289
|
+
if (pat instanceof RegExp && pat.test(url)) return false;
|
|
2290
|
+
}
|
|
2291
|
+
if (this.opts.config.allowUrls.length > 0) {
|
|
2292
|
+
for (const pat of this.opts.config.allowUrls) {
|
|
2293
|
+
if (typeof pat === "string" && url.includes(pat)) return true;
|
|
2294
|
+
if (pat instanceof RegExp && pat.test(url)) return true;
|
|
2295
|
+
}
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2298
|
+
return true;
|
|
2299
|
+
}
|
|
2300
|
+
passesSample(err) {
|
|
2301
|
+
if (this.opts.config.sampleRate >= 1) return true;
|
|
2302
|
+
if (this.opts.config.sampleRate <= 0) return false;
|
|
2303
|
+
const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
|
|
2304
|
+
return hashByte / 255 < this.opts.config.sampleRate;
|
|
2305
|
+
}
|
|
2306
|
+
passesRateLimit(err) {
|
|
2307
|
+
const windowMs = 6e4;
|
|
2308
|
+
const now = Date.now();
|
|
2309
|
+
const max = this.opts.config.maxPerFingerprintPerMinute;
|
|
2310
|
+
const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
|
|
2311
|
+
const fresh = arr.filter((t) => now - t < windowMs);
|
|
2312
|
+
if (fresh.length >= max) {
|
|
2313
|
+
this.fingerprintWindow.set(err.fingerprint, fresh);
|
|
2314
|
+
return false;
|
|
2315
|
+
}
|
|
2316
|
+
fresh.push(now);
|
|
2317
|
+
this.fingerprintWindow.set(err.fingerprint, fresh);
|
|
2318
|
+
return true;
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
function safeStringify2(v) {
|
|
2322
|
+
if (v == null) return String(v);
|
|
2323
|
+
if (typeof v === "string") return v;
|
|
2324
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
2325
|
+
try {
|
|
2326
|
+
return JSON.stringify(v);
|
|
2327
|
+
} catch {
|
|
2328
|
+
return Object.prototype.toString.call(v);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
1054
2332
|
// src/crossdeck.ts
|
|
1055
2333
|
var CrossdeckClient = class {
|
|
1056
2334
|
constructor() {
|
|
@@ -1140,6 +2418,13 @@ var CrossdeckClient = class {
|
|
|
1140
2418
|
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1141
2419
|
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
1142
2420
|
const entitlements = new EntitlementCache();
|
|
2421
|
+
const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
|
|
2422
|
+
if (persistentEvents) {
|
|
2423
|
+
debug.emit(
|
|
2424
|
+
"sdk.queue_restored",
|
|
2425
|
+
"Restored persisted event queue from a prior session."
|
|
2426
|
+
);
|
|
2427
|
+
}
|
|
1143
2428
|
const events = new EventQueue({
|
|
1144
2429
|
http,
|
|
1145
2430
|
batchSize: opts.eventFlushBatchSize,
|
|
@@ -1149,26 +2434,57 @@ var CrossdeckClient = class {
|
|
|
1149
2434
|
environment: opts.environment,
|
|
1150
2435
|
sdk: { name: SDK_NAME, version: opts.sdkVersion }
|
|
1151
2436
|
}),
|
|
2437
|
+
persistentStore: persistentEvents ?? void 0,
|
|
1152
2438
|
onFirstFlushSuccess: () => {
|
|
1153
2439
|
debug.emit(
|
|
1154
2440
|
"sdk.first_event_sent",
|
|
1155
2441
|
"First telemetry event received. View it in Live Events.",
|
|
1156
2442
|
{ appId: opts.appId, environment: opts.environment }
|
|
1157
2443
|
);
|
|
2444
|
+
},
|
|
2445
|
+
onRetryScheduled: (info) => {
|
|
2446
|
+
debug.emit(
|
|
2447
|
+
"sdk.flush_retry_scheduled",
|
|
2448
|
+
`Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
|
|
2449
|
+
{ ...info }
|
|
2450
|
+
);
|
|
1158
2451
|
}
|
|
1159
2452
|
});
|
|
1160
2453
|
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
2454
|
+
const superProps = new SuperPropertyStore(
|
|
2455
|
+
persistIdentity ? effectiveStorage : new MemoryStorage(),
|
|
2456
|
+
opts.storagePrefix
|
|
2457
|
+
);
|
|
2458
|
+
const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
|
|
2459
|
+
if (consent.isDntDenied) {
|
|
2460
|
+
debug.emit(
|
|
2461
|
+
"sdk.consent_dnt_applied",
|
|
2462
|
+
"Do Not Track detected \u2014 all tracking dimensions denied at init."
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
const breadcrumbs = new BreadcrumbBuffer(50);
|
|
1161
2466
|
this.state = {
|
|
1162
2467
|
http,
|
|
1163
2468
|
identity,
|
|
1164
2469
|
entitlements,
|
|
1165
2470
|
events,
|
|
1166
2471
|
autoTracker: null,
|
|
2472
|
+
webVitals: null,
|
|
2473
|
+
errors: null,
|
|
2474
|
+
breadcrumbs,
|
|
2475
|
+
errorContext: {},
|
|
2476
|
+
errorTags: {},
|
|
2477
|
+
errorBeforeSend: null,
|
|
2478
|
+
superProps,
|
|
2479
|
+
consent,
|
|
2480
|
+
scrubPii: options.scrubPii !== false,
|
|
1167
2481
|
deviceInfo,
|
|
1168
2482
|
options: opts,
|
|
1169
2483
|
debug,
|
|
1170
2484
|
developerUserId: null,
|
|
1171
|
-
uninstallUnloadFlush: null
|
|
2485
|
+
uninstallUnloadFlush: null,
|
|
2486
|
+
lastServerTime: null,
|
|
2487
|
+
lastClientTime: null
|
|
1172
2488
|
};
|
|
1173
2489
|
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
1174
2490
|
appId: opts.appId,
|
|
@@ -1183,6 +2499,27 @@ var CrossdeckClient = class {
|
|
|
1183
2499
|
this.state.autoTracker = tracker;
|
|
1184
2500
|
tracker.install();
|
|
1185
2501
|
}
|
|
2502
|
+
if (autoTrack.webVitals) {
|
|
2503
|
+
const vitals = new WebVitalsTracker(
|
|
2504
|
+
{ enabled: true },
|
|
2505
|
+
(name, properties) => this.track(name, properties)
|
|
2506
|
+
);
|
|
2507
|
+
this.state.webVitals = vitals;
|
|
2508
|
+
vitals.install();
|
|
2509
|
+
}
|
|
2510
|
+
if (autoTrack.errors) {
|
|
2511
|
+
const tracker = new ErrorTracker({
|
|
2512
|
+
config: { ...DEFAULT_ERROR_CAPTURE, enabled: true },
|
|
2513
|
+
breadcrumbs,
|
|
2514
|
+
report: (err) => this.reportError(err),
|
|
2515
|
+
getContext: () => ({ ...this.state.errorContext }),
|
|
2516
|
+
getTags: () => ({ ...this.state.errorTags }),
|
|
2517
|
+
beforeSend: this.state.errorBeforeSend,
|
|
2518
|
+
isConsented: () => this.state.consent.errors
|
|
2519
|
+
});
|
|
2520
|
+
this.state.errors = tracker;
|
|
2521
|
+
tracker.install();
|
|
2522
|
+
}
|
|
1186
2523
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
1187
2524
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
1188
2525
|
});
|
|
@@ -1206,8 +2543,19 @@ var CrossdeckClient = class {
|
|
|
1206
2543
|
/**
|
|
1207
2544
|
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
1208
2545
|
* the resolved Crossdeck customer for follow-up calls.
|
|
2546
|
+
*
|
|
2547
|
+
* v0.9.0+ accepts an optional `traits` bag — profile data (name,
|
|
2548
|
+
* plan, signupDate, role) persisted on the Crossdeck customer record
|
|
2549
|
+
* and queryable from dashboards. Traits are sanitised through the
|
|
2550
|
+
* same validator that gates `track()` properties, so a `{ avatar:
|
|
2551
|
+
* <File>, onSave: () => {} }` payload can't corrupt the alias call.
|
|
2552
|
+
*
|
|
2553
|
+
* Crossdeck.identify("user_847", {
|
|
2554
|
+
* email: "wes@pinet.co.za",
|
|
2555
|
+
* traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
|
|
2556
|
+
* });
|
|
1209
2557
|
*/
|
|
1210
|
-
async identify(userId,
|
|
2558
|
+
async identify(userId, options) {
|
|
1211
2559
|
const s = this.requireStarted();
|
|
1212
2560
|
if (!userId) {
|
|
1213
2561
|
throw new CrossdeckError({
|
|
@@ -1216,13 +2564,265 @@ var CrossdeckClient = class {
|
|
|
1216
2564
|
message: "identify(userId) requires a non-empty userId."
|
|
1217
2565
|
});
|
|
1218
2566
|
}
|
|
2567
|
+
if (!s.consent.analytics) {
|
|
2568
|
+
s.debug.emit(
|
|
2569
|
+
"sdk.consent_denied",
|
|
2570
|
+
`identify() skipped \u2014 consent denied for analytics.`
|
|
2571
|
+
);
|
|
2572
|
+
return {
|
|
2573
|
+
object: "alias_result",
|
|
2574
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
|
|
2575
|
+
linked: [],
|
|
2576
|
+
mergePending: false,
|
|
2577
|
+
env: s.options.environment
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
|
|
2581
|
+
const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
|
|
2582
|
+
if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
|
|
2583
|
+
for (const w of traitsValidation.warnings) {
|
|
2584
|
+
s.debug.emit(
|
|
2585
|
+
"sdk.property_coerced",
|
|
2586
|
+
`identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2587
|
+
{ key: w.key, kind: w.kind }
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
const body = {
|
|
2592
|
+
userId,
|
|
2593
|
+
anonymousId: s.identity.anonymousId
|
|
2594
|
+
};
|
|
2595
|
+
if (options?.email) body.email = options.email;
|
|
2596
|
+
if (traits) body.traits = traits;
|
|
1219
2597
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
1220
|
-
body
|
|
2598
|
+
body
|
|
1221
2599
|
});
|
|
1222
2600
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
1223
2601
|
s.developerUserId = userId;
|
|
1224
2602
|
return result;
|
|
1225
2603
|
}
|
|
2604
|
+
/**
|
|
2605
|
+
* Register super-properties — Mixpanel pattern. Once set, every
|
|
2606
|
+
* subsequent event of THIS SDK instance carries these keys on its
|
|
2607
|
+
* properties bag automatically.
|
|
2608
|
+
*
|
|
2609
|
+
* Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
|
|
2610
|
+
* Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
|
|
2611
|
+
*
|
|
2612
|
+
* Values that are `null` are deleted (the explicit "stop tracking
|
|
2613
|
+
* this key" idiom). Returns the resulting bag.
|
|
2614
|
+
*
|
|
2615
|
+
* Sanitised through `validateEventProperties` so a `{ avatar: File }`
|
|
2616
|
+
* payload can't poison the queue at flush time.
|
|
2617
|
+
*/
|
|
2618
|
+
register(properties) {
|
|
2619
|
+
const s = this.requireStarted();
|
|
2620
|
+
const validation = validateEventProperties(properties);
|
|
2621
|
+
return s.superProps.register(validation.properties);
|
|
2622
|
+
}
|
|
2623
|
+
/** Remove a single super-property key. Idempotent. */
|
|
2624
|
+
unregister(key) {
|
|
2625
|
+
const s = this.requireStarted();
|
|
2626
|
+
s.superProps.unregister(key);
|
|
2627
|
+
}
|
|
2628
|
+
/** Snapshot of the current super-property bag. */
|
|
2629
|
+
getSuperProperties() {
|
|
2630
|
+
if (!this.state) return {};
|
|
2631
|
+
return this.state.superProps.getSuperProperties();
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Associate the current user with a group (org, team, account, etc.).
|
|
2635
|
+
* Mixpanel / Segment "Group Analytics" pattern.
|
|
2636
|
+
*
|
|
2637
|
+
* Crossdeck.group("org", "acme_inc");
|
|
2638
|
+
* Crossdeck.group("team", "design", { headcount: 12 });
|
|
2639
|
+
*
|
|
2640
|
+
* Once set, every subsequent event carries `$groups.<type>: id` on
|
|
2641
|
+
* its properties bag, enabling B2B dashboards ("how is Acme using
|
|
2642
|
+
* the product"). Pass `id: null` to clear a group membership.
|
|
2643
|
+
*/
|
|
2644
|
+
group(type, id, traits) {
|
|
2645
|
+
const s = this.requireStarted();
|
|
2646
|
+
if (!type) {
|
|
2647
|
+
throw new CrossdeckError({
|
|
2648
|
+
type: "invalid_request_error",
|
|
2649
|
+
code: "missing_group_type",
|
|
2650
|
+
message: "group(type, id) requires a non-empty type."
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
|
|
2654
|
+
s.superProps.setGroup(type, id, sanitisedTraits);
|
|
2655
|
+
}
|
|
2656
|
+
/** Snapshot of the current groups map keyed by type. */
|
|
2657
|
+
getGroups() {
|
|
2658
|
+
if (!this.state) return {};
|
|
2659
|
+
return this.state.superProps.getGroups();
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* Update consent state. Three independent dimensions:
|
|
2663
|
+
*
|
|
2664
|
+
* analytics — track() + identify() + auto-emissions
|
|
2665
|
+
* marketing — paid-traffic click IDs + referrer URL on events
|
|
2666
|
+
* errors — Web Vitals + (future) error reporting
|
|
2667
|
+
*
|
|
2668
|
+
* Each defaults to `true` (granted). Pass partial state — only the
|
|
2669
|
+
* keys you provide are changed.
|
|
2670
|
+
*
|
|
2671
|
+
* Crossdeck.consent({ analytics: false });
|
|
2672
|
+
* Crossdeck.consent({ marketing: true, errors: true });
|
|
2673
|
+
*
|
|
2674
|
+
* DNT-derived denies cannot be flipped back on; if the browser said
|
|
2675
|
+
* "don't track" we don't track even if the developer code disagrees.
|
|
2676
|
+
*/
|
|
2677
|
+
consent(state) {
|
|
2678
|
+
const s = this.requireStarted();
|
|
2679
|
+
const next = s.consent.set(state);
|
|
2680
|
+
s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
|
|
2681
|
+
return next;
|
|
2682
|
+
}
|
|
2683
|
+
/** Snapshot of the current consent state. */
|
|
2684
|
+
consentStatus() {
|
|
2685
|
+
if (!this.state) {
|
|
2686
|
+
return { analytics: true, marketing: true, errors: true };
|
|
2687
|
+
}
|
|
2688
|
+
return this.state.consent.get();
|
|
2689
|
+
}
|
|
2690
|
+
// ============================================================
|
|
2691
|
+
// Error capture surface (v1.0.0+)
|
|
2692
|
+
// ============================================================
|
|
2693
|
+
/**
|
|
2694
|
+
* Manually capture an error from a try/catch block.
|
|
2695
|
+
*
|
|
2696
|
+
* try { …risky… } catch (err) {
|
|
2697
|
+
* Crossdeck.captureError(err, { context: { plan: "pro" } });
|
|
2698
|
+
* }
|
|
2699
|
+
*
|
|
2700
|
+
* The error is shipped through the same event queue as analytics
|
|
2701
|
+
* (durable, retried, rate-limited per fingerprint). Sends are gated
|
|
2702
|
+
* by `consent.errors`. Returns silently — never throws, even if the
|
|
2703
|
+
* SDK isn't initialised yet.
|
|
2704
|
+
*/
|
|
2705
|
+
captureError(error, options) {
|
|
2706
|
+
if (!this.state?.errors) return;
|
|
2707
|
+
this.state.errors.captureError(error, options);
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Capture a non-error event you want to surface as an issue
|
|
2711
|
+
* ("deprecated path hit", "we entered the slow code path"). Sentry
|
|
2712
|
+
* captureMessage pattern. Returns silently if not initialised.
|
|
2713
|
+
*/
|
|
2714
|
+
captureMessage(message, level = "info") {
|
|
2715
|
+
if (!this.state?.errors) return;
|
|
2716
|
+
this.state.errors.captureMessage(message, level);
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Attach a tag to every subsequent error report. Tags are key/value
|
|
2720
|
+
* strings (Sentry pattern): `setTag("flow", "checkout")` → every
|
|
2721
|
+
* error from this point on carries `tags.flow === "checkout"`.
|
|
2722
|
+
*/
|
|
2723
|
+
setTag(key, value) {
|
|
2724
|
+
if (!this.state) return;
|
|
2725
|
+
this.state.errorTags[key] = value;
|
|
2726
|
+
}
|
|
2727
|
+
/** Bulk-set tags. Merges with existing tags. */
|
|
2728
|
+
setTags(tags) {
|
|
2729
|
+
if (!this.state) return;
|
|
2730
|
+
Object.assign(this.state.errorTags, tags);
|
|
2731
|
+
}
|
|
2732
|
+
/**
|
|
2733
|
+
* Attach a structured context blob to every subsequent error report.
|
|
2734
|
+
* Unlike tags (flat key/value), context is a named bag of arbitrary
|
|
2735
|
+
* data: `setContext("cart", { items: 3, total: 42.99 })`.
|
|
2736
|
+
*/
|
|
2737
|
+
setContext(name, data) {
|
|
2738
|
+
if (!this.state) return;
|
|
2739
|
+
this.state.errorContext[name] = data;
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* Add a custom breadcrumb to the rolling buffer. Useful for marking
|
|
2743
|
+
* domain-meaningful moments ("user opened paywall") that aren't
|
|
2744
|
+
* already auto-captured. The buffer caps at 50 entries; old ones
|
|
2745
|
+
* evict.
|
|
2746
|
+
*/
|
|
2747
|
+
addBreadcrumb(crumb) {
|
|
2748
|
+
if (!this.state) return;
|
|
2749
|
+
this.state.breadcrumbs.add(crumb);
|
|
2750
|
+
}
|
|
2751
|
+
/**
|
|
2752
|
+
* Install a pre-send hook for errors. Return null to drop, or a
|
|
2753
|
+
* modified CapturedError to scrub / rewrite. Sentry's beforeSend
|
|
2754
|
+
* pattern — the only way to redact app-specific PII (auth tokens
|
|
2755
|
+
* in URLs, etc.) before the report leaves the browser.
|
|
2756
|
+
*/
|
|
2757
|
+
setErrorBeforeSend(hook) {
|
|
2758
|
+
if (!this.state) return;
|
|
2759
|
+
this.state.errorBeforeSend = hook;
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Internal: turn a CapturedError into a Crossdeck event and enqueue
|
|
2763
|
+
* it. Goes through the same queue / persistence / consent / scrub
|
|
2764
|
+
* pipeline as analytics events.
|
|
2765
|
+
*/
|
|
2766
|
+
reportError(err) {
|
|
2767
|
+
const properties = {
|
|
2768
|
+
// Identifiers
|
|
2769
|
+
fingerprint: err.fingerprint,
|
|
2770
|
+
level: err.level,
|
|
2771
|
+
// Error shape
|
|
2772
|
+
errorType: err.errorType,
|
|
2773
|
+
message: err.message,
|
|
2774
|
+
// Stack
|
|
2775
|
+
stack: err.rawStack ?? void 0,
|
|
2776
|
+
frames: err.frames,
|
|
2777
|
+
filename: err.filename ?? void 0,
|
|
2778
|
+
lineno: err.lineno ?? void 0,
|
|
2779
|
+
colno: err.colno ?? void 0,
|
|
2780
|
+
// Context
|
|
2781
|
+
tags: err.tags,
|
|
2782
|
+
context: err.context,
|
|
2783
|
+
breadcrumbs: err.breadcrumbs,
|
|
2784
|
+
// HTTP (only when applicable)
|
|
2785
|
+
http: err.http
|
|
2786
|
+
};
|
|
2787
|
+
for (const k of Object.keys(properties)) {
|
|
2788
|
+
if (properties[k] === void 0) delete properties[k];
|
|
2789
|
+
}
|
|
2790
|
+
this.track(err.kind, properties);
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* GDPR/CCPA "right to be forgotten" — calls the backend's
|
|
2794
|
+
* /v1/identity/forget endpoint to schedule a server-side deletion of
|
|
2795
|
+
* the customer's events and profile, then wipes all local state
|
|
2796
|
+
* (identity, entitlements, queue, super-props, persistent stores).
|
|
2797
|
+
*
|
|
2798
|
+
* Idempotent. Safe to call when no identity has been established
|
|
2799
|
+
* (it just wipes the empty local state).
|
|
2800
|
+
*
|
|
2801
|
+
* After forget() resolves, the SDK is in the same shape as if the
|
|
2802
|
+
* developer had called `Crossdeck.reset()` — a fresh anonymousId is
|
|
2803
|
+
* minted and the next session is a brand new identity-graph entry.
|
|
2804
|
+
*/
|
|
2805
|
+
async forget() {
|
|
2806
|
+
const s = this.requireStarted();
|
|
2807
|
+
const identityQuery = this.identityQueryParams();
|
|
2808
|
+
try {
|
|
2809
|
+
await s.http.request("POST", "/identity/forget", {
|
|
2810
|
+
body: {
|
|
2811
|
+
// Send every identity hint we hold; the server resolves the
|
|
2812
|
+
// canonical customer record and queues deletion. Missing
|
|
2813
|
+
// endpoint (older backend) gracefully degrades — local state
|
|
2814
|
+
// still wipes via the reset() call below.
|
|
2815
|
+
...identityQuery
|
|
2816
|
+
}
|
|
2817
|
+
});
|
|
2818
|
+
} catch (err) {
|
|
2819
|
+
s.debug.emit(
|
|
2820
|
+
"sdk.consent_denied",
|
|
2821
|
+
`forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
this.reset();
|
|
2825
|
+
}
|
|
1226
2826
|
/**
|
|
1227
2827
|
* Read the current customer's active entitlements from the server.
|
|
1228
2828
|
* Updates the local cache so subsequent isEntitled() calls answer
|
|
@@ -1300,6 +2900,18 @@ var CrossdeckClient = class {
|
|
|
1300
2900
|
message: "track(name) requires a non-empty name."
|
|
1301
2901
|
});
|
|
1302
2902
|
}
|
|
2903
|
+
const isError = name.startsWith("error.");
|
|
2904
|
+
const isWebVital = name.startsWith("webvitals.");
|
|
2905
|
+
const consentGateOk = isError || isWebVital ? s.consent.errors : s.consent.analytics;
|
|
2906
|
+
if (!consentGateOk) {
|
|
2907
|
+
if (s.debug.enabled) {
|
|
2908
|
+
s.debug.emit(
|
|
2909
|
+
"sdk.consent_denied",
|
|
2910
|
+
`Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
|
|
2911
|
+
);
|
|
2912
|
+
}
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
1303
2915
|
if (s.debug.enabled && properties) {
|
|
1304
2916
|
const flagged = findSensitivePropertyKeys(properties);
|
|
1305
2917
|
if (flagged.length > 0) {
|
|
@@ -1316,9 +2928,21 @@ var CrossdeckClient = class {
|
|
|
1316
2928
|
"Using anonymous user until identify(userId) is called."
|
|
1317
2929
|
);
|
|
1318
2930
|
}
|
|
2931
|
+
const validation = validateEventProperties(properties);
|
|
2932
|
+
if (s.debug.enabled && validation.warnings.length > 0) {
|
|
2933
|
+
for (const w of validation.warnings) {
|
|
2934
|
+
s.debug.emit(
|
|
2935
|
+
"sdk.property_coerced",
|
|
2936
|
+
`Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2937
|
+
{ eventName: name, key: w.key, kind: w.kind }
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
1319
2941
|
const enriched = { ...s.deviceInfo };
|
|
1320
2942
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
1321
2943
|
if (sessionId) enriched.sessionId = sessionId;
|
|
2944
|
+
const pageviewId = s.autoTracker?.currentPageviewId;
|
|
2945
|
+
if (pageviewId) enriched.pageviewId = pageviewId;
|
|
1322
2946
|
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1323
2947
|
if (acquisition) {
|
|
1324
2948
|
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
@@ -1326,17 +2950,46 @@ var CrossdeckClient = class {
|
|
|
1326
2950
|
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1327
2951
|
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1328
2952
|
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1329
|
-
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
2953
|
+
if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
|
|
2954
|
+
if (s.consent.marketing) {
|
|
2955
|
+
if (acquisition.gclid) enriched.gclid = acquisition.gclid;
|
|
2956
|
+
if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
|
|
2957
|
+
if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
|
|
2958
|
+
if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
|
|
2959
|
+
if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
|
|
2960
|
+
if (acquisition.twclid) enriched.twclid = acquisition.twclid;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
const supers = s.superProps.getSuperProperties();
|
|
2964
|
+
for (const k of Object.keys(supers)) {
|
|
2965
|
+
if (!(k in enriched)) enriched[k] = supers[k];
|
|
2966
|
+
}
|
|
2967
|
+
const groupIds = s.superProps.getGroupIds();
|
|
2968
|
+
if (Object.keys(groupIds).length > 0) {
|
|
2969
|
+
enriched.$groups = groupIds;
|
|
1330
2970
|
}
|
|
1331
|
-
|
|
2971
|
+
Object.assign(enriched, validation.properties);
|
|
2972
|
+
const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
|
|
1332
2973
|
const event = {
|
|
1333
2974
|
eventId: this.mintEventId(),
|
|
1334
2975
|
name,
|
|
1335
2976
|
timestamp: Date.now(),
|
|
1336
|
-
properties:
|
|
2977
|
+
properties: finalProperties
|
|
1337
2978
|
};
|
|
1338
2979
|
Object.assign(event, this.identityHintForEvent());
|
|
1339
2980
|
s.events.enqueue(event);
|
|
2981
|
+
if (!isError && !isWebVital) {
|
|
2982
|
+
const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
|
|
2983
|
+
s.breadcrumbs.add({
|
|
2984
|
+
timestamp: event.timestamp,
|
|
2985
|
+
category,
|
|
2986
|
+
message: name,
|
|
2987
|
+
// Strip the device-info / session bloat from the breadcrumb
|
|
2988
|
+
// payload — only the caller-supplied properties belong in
|
|
2989
|
+
// the user-readable trail.
|
|
2990
|
+
data: properties ? { ...properties } : void 0
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
1340
2993
|
}
|
|
1341
2994
|
/**
|
|
1342
2995
|
* Force-flush queued events. Useful to call from page-unload handlers.
|
|
@@ -1411,7 +3064,12 @@ var CrossdeckClient = class {
|
|
|
1411
3064
|
*/
|
|
1412
3065
|
async heartbeat() {
|
|
1413
3066
|
const s = this.requireStarted();
|
|
1414
|
-
|
|
3067
|
+
const result = await s.http.request("GET", "/sdk/heartbeat");
|
|
3068
|
+
if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
|
|
3069
|
+
s.lastServerTime = result.serverTime;
|
|
3070
|
+
s.lastClientTime = Date.now();
|
|
3071
|
+
}
|
|
3072
|
+
return result;
|
|
1415
3073
|
}
|
|
1416
3074
|
/**
|
|
1417
3075
|
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
@@ -1430,6 +3088,10 @@ var CrossdeckClient = class {
|
|
|
1430
3088
|
this.state.identity.reset();
|
|
1431
3089
|
this.state.entitlements.clear();
|
|
1432
3090
|
this.state.events.reset();
|
|
3091
|
+
this.state.superProps.clear();
|
|
3092
|
+
this.state.breadcrumbs.clear();
|
|
3093
|
+
this.state.errorContext = {};
|
|
3094
|
+
this.state.errorTags = {};
|
|
1433
3095
|
this.state.developerUserId = null;
|
|
1434
3096
|
if (this.state.autoTracker) {
|
|
1435
3097
|
const tracker = new AutoTracker(
|
|
@@ -1457,17 +3119,21 @@ var CrossdeckClient = class {
|
|
|
1457
3119
|
developerUserId: null,
|
|
1458
3120
|
sdkVersion: null,
|
|
1459
3121
|
baseUrl: null,
|
|
1460
|
-
|
|
3122
|
+
clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
|
|
3123
|
+
entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
|
|
1461
3124
|
events: {
|
|
1462
3125
|
buffered: 0,
|
|
1463
3126
|
dropped: 0,
|
|
1464
3127
|
inFlight: 0,
|
|
1465
3128
|
lastFlushAt: 0,
|
|
1466
|
-
lastError: null
|
|
3129
|
+
lastError: null,
|
|
3130
|
+
consecutiveFailures: 0,
|
|
3131
|
+
nextRetryAt: null
|
|
1467
3132
|
}
|
|
1468
3133
|
};
|
|
1469
3134
|
}
|
|
1470
3135
|
const s = this.state;
|
|
3136
|
+
const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
|
|
1471
3137
|
return {
|
|
1472
3138
|
started: true,
|
|
1473
3139
|
anonymousId: s.identity.anonymousId,
|
|
@@ -1475,9 +3141,15 @@ var CrossdeckClient = class {
|
|
|
1475
3141
|
developerUserId: s.developerUserId,
|
|
1476
3142
|
sdkVersion: s.options.sdkVersion,
|
|
1477
3143
|
baseUrl: s.options.baseUrl,
|
|
3144
|
+
clock: {
|
|
3145
|
+
lastServerTime: s.lastServerTime,
|
|
3146
|
+
lastClientTime: s.lastClientTime,
|
|
3147
|
+
skewMs
|
|
3148
|
+
},
|
|
1478
3149
|
entitlements: {
|
|
1479
3150
|
count: s.entitlements.list().length,
|
|
1480
|
-
lastUpdated: s.entitlements.freshness
|
|
3151
|
+
lastUpdated: s.entitlements.freshness,
|
|
3152
|
+
listenerErrors: s.entitlements.listenerErrors
|
|
1481
3153
|
},
|
|
1482
3154
|
events: s.events.getStats()
|
|
1483
3155
|
};
|
|
@@ -1544,6 +3216,7 @@ function inferEnvFromKey(publicKey) {
|
|
|
1544
3216
|
}
|
|
1545
3217
|
function isLocalHostname() {
|
|
1546
3218
|
const w = globalThis.window;
|
|
3219
|
+
if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
|
|
1547
3220
|
const hostname = w?.location?.hostname;
|
|
1548
3221
|
if (!hostname) return false;
|
|
1549
3222
|
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
@@ -1556,7 +3229,14 @@ function isLocalHostname() {
|
|
|
1556
3229
|
}
|
|
1557
3230
|
function resolveAutoTrack(input) {
|
|
1558
3231
|
if (input === false) {
|
|
1559
|
-
return {
|
|
3232
|
+
return {
|
|
3233
|
+
sessions: false,
|
|
3234
|
+
pageViews: false,
|
|
3235
|
+
deviceInfo: false,
|
|
3236
|
+
clicks: false,
|
|
3237
|
+
webVitals: false,
|
|
3238
|
+
errors: false
|
|
3239
|
+
};
|
|
1560
3240
|
}
|
|
1561
3241
|
if (input === void 0 || input === true) {
|
|
1562
3242
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1565,7 +3245,9 @@ function resolveAutoTrack(input) {
|
|
|
1565
3245
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1566
3246
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1567
3247
|
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1568
|
-
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
3248
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
|
|
3249
|
+
webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals,
|
|
3250
|
+
errors: input.errors ?? DEFAULT_AUTO_TRACK.errors
|
|
1569
3251
|
};
|
|
1570
3252
|
}
|
|
1571
3253
|
function installUnloadFlush(onUnload) {
|