@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/index.mjs
CHANGED
|
@@ -7,11 +7,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
|
|
|
7
7
|
this.code = payload.code;
|
|
8
8
|
this.requestId = payload.requestId;
|
|
9
9
|
this.status = payload.status;
|
|
10
|
+
this.retryAfterMs = payload.retryAfterMs;
|
|
10
11
|
Object.setPrototypeOf(this, _CrossdeckError.prototype);
|
|
11
12
|
}
|
|
12
13
|
};
|
|
13
14
|
async function crossdeckErrorFromResponse(res) {
|
|
14
15
|
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
16
|
+
const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
|
|
15
17
|
let body;
|
|
16
18
|
try {
|
|
17
19
|
body = await res.json();
|
|
@@ -25,7 +27,8 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
25
27
|
code: envelope.code,
|
|
26
28
|
message: envelope.message ?? `HTTP ${res.status}`,
|
|
27
29
|
requestId: envelope.request_id ?? requestId,
|
|
28
|
-
status: res.status
|
|
30
|
+
status: res.status,
|
|
31
|
+
retryAfterMs
|
|
29
32
|
});
|
|
30
33
|
}
|
|
31
34
|
return new CrossdeckError({
|
|
@@ -33,9 +36,25 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
33
36
|
code: `http_${res.status}`,
|
|
34
37
|
message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
|
|
35
38
|
requestId,
|
|
36
|
-
status: res.status
|
|
39
|
+
status: res.status,
|
|
40
|
+
retryAfterMs
|
|
37
41
|
});
|
|
38
42
|
}
|
|
43
|
+
function parseRetryAfterHeader(value) {
|
|
44
|
+
if (!value) return void 0;
|
|
45
|
+
const trimmed = value.trim();
|
|
46
|
+
if (!trimmed) return void 0;
|
|
47
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
48
|
+
const secs = Number(trimmed);
|
|
49
|
+
if (!Number.isFinite(secs) || secs < 0) return void 0;
|
|
50
|
+
return Math.round(secs * 1e3);
|
|
51
|
+
}
|
|
52
|
+
if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
|
|
53
|
+
const target = Date.parse(trimmed);
|
|
54
|
+
if (!Number.isFinite(target)) return void 0;
|
|
55
|
+
const delta = target - Date.now();
|
|
56
|
+
return delta > 0 ? delta : 0;
|
|
57
|
+
}
|
|
39
58
|
function typeMapForStatus(status) {
|
|
40
59
|
if (status === 401) return "authentication_error";
|
|
41
60
|
if (status === 403) return "permission_error";
|
|
@@ -46,8 +65,9 @@ function typeMapForStatus(status) {
|
|
|
46
65
|
|
|
47
66
|
// src/http.ts
|
|
48
67
|
var SDK_NAME = "@cross-deck/web";
|
|
49
|
-
var SDK_VERSION = "0.
|
|
68
|
+
var SDK_VERSION = "1.0.0";
|
|
50
69
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
70
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
51
71
|
var HttpClient = class {
|
|
52
72
|
constructor(config) {
|
|
53
73
|
this.config = config;
|
|
@@ -71,25 +91,38 @@ var HttpClient = class {
|
|
|
71
91
|
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
72
92
|
Accept: "application/json"
|
|
73
93
|
};
|
|
94
|
+
if (options.idempotencyKey) {
|
|
95
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
96
|
+
}
|
|
74
97
|
let bodyInit;
|
|
75
98
|
if (options.body !== void 0) {
|
|
76
99
|
headers["Content-Type"] = "application/json";
|
|
77
100
|
bodyInit = JSON.stringify(options.body);
|
|
78
101
|
}
|
|
102
|
+
const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
103
|
+
const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
|
|
104
|
+
let timeoutHandle = null;
|
|
105
|
+
if (controller && effectiveTimeout > 0) {
|
|
106
|
+
timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
|
|
107
|
+
}
|
|
79
108
|
let response;
|
|
80
109
|
try {
|
|
81
110
|
response = await fetch(url, {
|
|
82
111
|
method,
|
|
83
112
|
headers,
|
|
84
113
|
body: bodyInit,
|
|
85
|
-
keepalive: options.keepalive === true
|
|
114
|
+
keepalive: options.keepalive === true,
|
|
115
|
+
signal: controller?.signal
|
|
86
116
|
});
|
|
87
117
|
} catch (err) {
|
|
118
|
+
const aborted = controller?.signal?.aborted === true;
|
|
88
119
|
throw new CrossdeckError({
|
|
89
120
|
type: "network_error",
|
|
90
|
-
code: "fetch_failed",
|
|
91
|
-
message: err instanceof Error ? err.message : "fetch failed"
|
|
121
|
+
code: aborted ? "request_timeout" : "fetch_failed",
|
|
122
|
+
message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
|
|
92
123
|
});
|
|
124
|
+
} finally {
|
|
125
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
93
126
|
}
|
|
94
127
|
if (!response.ok) {
|
|
95
128
|
throw await crossdeckErrorFromResponse(response);
|
|
@@ -286,6 +319,7 @@ var EntitlementCache = class {
|
|
|
286
319
|
this.all = [];
|
|
287
320
|
this.lastUpdated = 0;
|
|
288
321
|
this.listeners = /* @__PURE__ */ new Set();
|
|
322
|
+
this.listenerErrorCount = 0;
|
|
289
323
|
}
|
|
290
324
|
/** Sync read — true iff the entitlement key is currently active. */
|
|
291
325
|
isEntitled(key) {
|
|
@@ -299,6 +333,15 @@ var EntitlementCache = class {
|
|
|
299
333
|
get freshness() {
|
|
300
334
|
return this.lastUpdated;
|
|
301
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Cumulative count of listener invocations that threw. Listener errors
|
|
338
|
+
* are swallowed (a buggy consumer must not crash the SDK) but the
|
|
339
|
+
* counter lets diagnostics() surface "you have a broken subscriber"
|
|
340
|
+
* without putting the developer in a debug session.
|
|
341
|
+
*/
|
|
342
|
+
get listenerErrors() {
|
|
343
|
+
return this.listenerErrorCount;
|
|
344
|
+
}
|
|
302
345
|
/**
|
|
303
346
|
* Replace the cache with a fresh server response. The backend already
|
|
304
347
|
* filters to active + env-matching, so we don't re-filter — just trust
|
|
@@ -352,11 +395,54 @@ var EntitlementCache = class {
|
|
|
352
395
|
try {
|
|
353
396
|
listener(snapshot);
|
|
354
397
|
} catch {
|
|
398
|
+
this.listenerErrorCount += 1;
|
|
355
399
|
}
|
|
356
400
|
}
|
|
357
401
|
}
|
|
358
402
|
};
|
|
359
403
|
|
|
404
|
+
// src/retry-policy.ts
|
|
405
|
+
var DEFAULT_BASE = 1e3;
|
|
406
|
+
var DEFAULT_MAX = 6e4;
|
|
407
|
+
var DEFAULT_FACTOR = 2;
|
|
408
|
+
var DEFAULT_WARN = 8;
|
|
409
|
+
function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
|
|
410
|
+
const base = options.baseMs ?? DEFAULT_BASE;
|
|
411
|
+
const max = options.maxMs ?? DEFAULT_MAX;
|
|
412
|
+
const factor = options.factor ?? DEFAULT_FACTOR;
|
|
413
|
+
const safeAttempts = Math.min(attempts, 30);
|
|
414
|
+
const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
|
|
415
|
+
const jittered = ceiling * random();
|
|
416
|
+
if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
|
|
417
|
+
return Math.min(max, retryAfterMs);
|
|
418
|
+
}
|
|
419
|
+
return Math.max(0, Math.round(jittered));
|
|
420
|
+
}
|
|
421
|
+
var RetryPolicy = class {
|
|
422
|
+
constructor(options = {}) {
|
|
423
|
+
this.options = options;
|
|
424
|
+
this.attempts = 0;
|
|
425
|
+
}
|
|
426
|
+
/** How many consecutive failures since the last success. */
|
|
427
|
+
get consecutiveFailures() {
|
|
428
|
+
return this.attempts;
|
|
429
|
+
}
|
|
430
|
+
/** Whether we've crossed the failuresBeforeWarn threshold. */
|
|
431
|
+
get isWarning() {
|
|
432
|
+
return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
|
|
433
|
+
}
|
|
434
|
+
/** Schedule-time delay for the NEXT retry. Increments the counter. */
|
|
435
|
+
nextDelay(retryAfterMs, random = Math.random) {
|
|
436
|
+
const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
|
|
437
|
+
this.attempts += 1;
|
|
438
|
+
return delay;
|
|
439
|
+
}
|
|
440
|
+
/** Mark a successful flush — reset the counter. */
|
|
441
|
+
recordSuccess() {
|
|
442
|
+
this.attempts = 0;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
360
446
|
// src/event-queue.ts
|
|
361
447
|
var HARD_BUFFER_CAP = 1e3;
|
|
362
448
|
var EventQueue = class {
|
|
@@ -369,6 +455,22 @@ var EventQueue = class {
|
|
|
369
455
|
this.lastError = null;
|
|
370
456
|
this.cancelTimer = null;
|
|
371
457
|
this.firstFlushFired = false;
|
|
458
|
+
this.nextRetryAt = null;
|
|
459
|
+
this.retry = new RetryPolicy(cfg.retry ?? {});
|
|
460
|
+
this.persistent = cfg.persistentStore ?? null;
|
|
461
|
+
if (this.persistent) {
|
|
462
|
+
const restored = this.persistent.load();
|
|
463
|
+
if (restored.length > 0) {
|
|
464
|
+
if (restored.length > HARD_BUFFER_CAP) {
|
|
465
|
+
this.dropped += restored.length - HARD_BUFFER_CAP;
|
|
466
|
+
this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
|
|
467
|
+
} else {
|
|
468
|
+
this.buffer = restored;
|
|
469
|
+
}
|
|
470
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
471
|
+
this.scheduleIdleFlush();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
372
474
|
}
|
|
373
475
|
enqueue(event) {
|
|
374
476
|
this.buffer.push(event);
|
|
@@ -378,6 +480,8 @@ var EventQueue = class {
|
|
|
378
480
|
this.dropped += overflow;
|
|
379
481
|
this.cfg.onDrop?.(overflow);
|
|
380
482
|
}
|
|
483
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
484
|
+
this.persistent?.save(this.buffer);
|
|
381
485
|
if (this.buffer.length >= this.cfg.batchSize) {
|
|
382
486
|
void this.flush();
|
|
383
487
|
} else {
|
|
@@ -387,7 +491,7 @@ var EventQueue = class {
|
|
|
387
491
|
/**
|
|
388
492
|
* Flush the buffer to /v1/events. Resolves when the network call
|
|
389
493
|
* completes (success or failure). On failure, events stay in the
|
|
390
|
-
* buffer for the next
|
|
494
|
+
* buffer for the next scheduled retry.
|
|
391
495
|
*
|
|
392
496
|
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
393
497
|
* browser keeps the request alive past page unload. Use this for
|
|
@@ -396,25 +500,32 @@ var EventQueue = class {
|
|
|
396
500
|
async flush(options = {}) {
|
|
397
501
|
if (this.buffer.length === 0) return null;
|
|
398
502
|
this.cancelTimerIfSet();
|
|
503
|
+
this.nextRetryAt = null;
|
|
399
504
|
const batch = this.buffer.splice(0);
|
|
505
|
+
const batchId = this.mintBatchId();
|
|
400
506
|
this.inFlight += batch.length;
|
|
507
|
+
this.persistent?.save(this.buffer);
|
|
508
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
401
509
|
try {
|
|
402
510
|
const env = this.cfg.envelope();
|
|
403
511
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
404
512
|
body: {
|
|
405
513
|
// NorthStar §13.1 batch envelope. The backend validates these
|
|
406
|
-
// against the API-key-resolved app and rejects mismatches
|
|
407
|
-
// (env_mismatch).
|
|
514
|
+
// against the API-key-resolved app and rejects mismatches
|
|
515
|
+
// loudly (env_mismatch).
|
|
408
516
|
appId: env.appId,
|
|
409
517
|
environment: env.environment,
|
|
410
518
|
sdk: env.sdk,
|
|
411
519
|
events: batch
|
|
412
520
|
},
|
|
413
|
-
keepalive: options.keepalive === true
|
|
521
|
+
keepalive: options.keepalive === true,
|
|
522
|
+
idempotencyKey: batchId
|
|
414
523
|
});
|
|
415
524
|
this.lastFlushAt = Date.now();
|
|
416
525
|
this.lastError = null;
|
|
417
526
|
this.inFlight -= batch.length;
|
|
527
|
+
this.retry.recordSuccess();
|
|
528
|
+
this.persistent?.save(this.buffer);
|
|
418
529
|
if (!this.firstFlushFired) {
|
|
419
530
|
this.firstFlushFired = true;
|
|
420
531
|
this.cfg.onFirstFlushSuccess?.();
|
|
@@ -423,18 +534,33 @@ var EventQueue = class {
|
|
|
423
534
|
} catch (err) {
|
|
424
535
|
this.buffer.unshift(...batch);
|
|
425
536
|
this.inFlight -= batch.length;
|
|
426
|
-
|
|
427
|
-
this.
|
|
537
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
538
|
+
this.lastError = message;
|
|
539
|
+
this.persistent?.save(this.buffer);
|
|
540
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
541
|
+
const retryAfterMs = extractRetryAfterMs(err);
|
|
542
|
+
const delay = this.retry.nextDelay(retryAfterMs);
|
|
543
|
+
this.scheduleRetry(delay);
|
|
544
|
+
this.cfg.onRetryScheduled?.({
|
|
545
|
+
delayMs: delay,
|
|
546
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
547
|
+
retryAfterMs,
|
|
548
|
+
lastError: message
|
|
549
|
+
});
|
|
428
550
|
return null;
|
|
429
551
|
}
|
|
430
552
|
}
|
|
431
|
-
/** Cancel any pending timer and clear in-memory state. */
|
|
553
|
+
/** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
|
|
432
554
|
reset() {
|
|
433
555
|
this.cancelTimerIfSet();
|
|
556
|
+
this.nextRetryAt = null;
|
|
434
557
|
this.buffer = [];
|
|
435
558
|
this.dropped = 0;
|
|
436
559
|
this.inFlight = 0;
|
|
437
560
|
this.lastError = null;
|
|
561
|
+
this.retry.recordSuccess();
|
|
562
|
+
this.persistent?.clear();
|
|
563
|
+
this.cfg.onBufferChange?.(0);
|
|
438
564
|
}
|
|
439
565
|
getStats() {
|
|
440
566
|
return {
|
|
@@ -442,9 +568,12 @@ var EventQueue = class {
|
|
|
442
568
|
dropped: this.dropped,
|
|
443
569
|
inFlight: this.inFlight,
|
|
444
570
|
lastFlushAt: this.lastFlushAt,
|
|
445
|
-
lastError: this.lastError
|
|
571
|
+
lastError: this.lastError,
|
|
572
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
573
|
+
nextRetryAt: this.nextRetryAt
|
|
446
574
|
};
|
|
447
575
|
}
|
|
576
|
+
// ---------- internal scheduling ----------
|
|
448
577
|
scheduleIdleFlush() {
|
|
449
578
|
this.cancelTimerIfSet();
|
|
450
579
|
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
@@ -452,13 +581,31 @@ var EventQueue = class {
|
|
|
452
581
|
void this.flush();
|
|
453
582
|
}, this.cfg.intervalMs);
|
|
454
583
|
}
|
|
584
|
+
scheduleRetry(delayMs) {
|
|
585
|
+
this.cancelTimerIfSet();
|
|
586
|
+
this.nextRetryAt = Date.now() + delayMs;
|
|
587
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
588
|
+
this.cancelTimer = sched(() => {
|
|
589
|
+
void this.flush();
|
|
590
|
+
}, delayMs);
|
|
591
|
+
}
|
|
455
592
|
cancelTimerIfSet() {
|
|
456
593
|
if (this.cancelTimer) {
|
|
457
594
|
this.cancelTimer();
|
|
458
595
|
this.cancelTimer = null;
|
|
459
596
|
}
|
|
460
597
|
}
|
|
598
|
+
mintBatchId() {
|
|
599
|
+
return `batch_${Date.now().toString(36)}${randomChars(10)}`;
|
|
600
|
+
}
|
|
461
601
|
};
|
|
602
|
+
function extractRetryAfterMs(err) {
|
|
603
|
+
if (err && typeof err === "object" && "retryAfterMs" in err) {
|
|
604
|
+
const v = err.retryAfterMs;
|
|
605
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
|
|
606
|
+
}
|
|
607
|
+
return void 0;
|
|
608
|
+
}
|
|
462
609
|
function defaultScheduler(fn, ms) {
|
|
463
610
|
const id = setTimeout(fn, ms);
|
|
464
611
|
if (typeof id.unref === "function") {
|
|
@@ -470,6 +617,87 @@ function defaultScheduler(fn, ms) {
|
|
|
470
617
|
return () => clearTimeout(id);
|
|
471
618
|
}
|
|
472
619
|
|
|
620
|
+
// src/event-storage.ts
|
|
621
|
+
var PersistentEventStore = class {
|
|
622
|
+
constructor(options) {
|
|
623
|
+
this.options = options;
|
|
624
|
+
this.writeScheduled = false;
|
|
625
|
+
// Pending events captured on the most recent write request. We keep
|
|
626
|
+
// the latest snapshot ref so a debounced write always picks up the
|
|
627
|
+
// freshest buffer state.
|
|
628
|
+
this.pendingSnapshot = null;
|
|
629
|
+
this.key = `${options.prefix}queue.v1`;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Read the persisted queue on boot. Returns an empty array (with no
|
|
633
|
+
* warning) when nothing is stored, the blob is malformed, or storage
|
|
634
|
+
* is unavailable. Caller is responsible for treating duplicates from
|
|
635
|
+
* the persisted queue as the SAME events (eventId-based dedup).
|
|
636
|
+
*/
|
|
637
|
+
load() {
|
|
638
|
+
let raw;
|
|
639
|
+
try {
|
|
640
|
+
raw = this.options.storage.getItem(this.key);
|
|
641
|
+
} catch {
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
if (!raw) return [];
|
|
645
|
+
try {
|
|
646
|
+
const parsed = JSON.parse(raw);
|
|
647
|
+
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
return parsed.events;
|
|
651
|
+
} catch {
|
|
652
|
+
return [];
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Schedule a write of the current buffer. Debounced via microtask so
|
|
657
|
+
* a burst of enqueue() calls coalesces into one persistence write.
|
|
658
|
+
* Writes are best-effort: if storage throws (quota, private mode),
|
|
659
|
+
* we swallow and rely on the in-memory buffer.
|
|
660
|
+
*/
|
|
661
|
+
save(snapshot) {
|
|
662
|
+
this.pendingSnapshot = snapshot.slice();
|
|
663
|
+
if (this.writeScheduled) return;
|
|
664
|
+
this.writeScheduled = true;
|
|
665
|
+
queueMicrotask(() => this.flushWrite());
|
|
666
|
+
}
|
|
667
|
+
/** Synchronous variant for terminal flushes (pagehide / beforeunload). */
|
|
668
|
+
saveSync(snapshot) {
|
|
669
|
+
this.pendingSnapshot = snapshot.slice();
|
|
670
|
+
this.flushWrite();
|
|
671
|
+
}
|
|
672
|
+
/** Wipe the persisted blob. Used by reset() (logout). */
|
|
673
|
+
clear() {
|
|
674
|
+
this.pendingSnapshot = null;
|
|
675
|
+
this.writeScheduled = false;
|
|
676
|
+
try {
|
|
677
|
+
this.options.storage.removeItem(this.key);
|
|
678
|
+
} catch {
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
flushWrite() {
|
|
682
|
+
this.writeScheduled = false;
|
|
683
|
+
const snapshot = this.pendingSnapshot;
|
|
684
|
+
this.pendingSnapshot = null;
|
|
685
|
+
if (snapshot === null) return;
|
|
686
|
+
if (snapshot.length === 0) {
|
|
687
|
+
try {
|
|
688
|
+
this.options.storage.removeItem(this.key);
|
|
689
|
+
} catch {
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const blob = { version: 1, events: snapshot };
|
|
694
|
+
try {
|
|
695
|
+
this.options.storage.setItem(this.key, JSON.stringify(blob));
|
|
696
|
+
} catch {
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
473
701
|
// src/storage.ts
|
|
474
702
|
var MemoryStorage = class {
|
|
475
703
|
constructor() {
|
|
@@ -660,7 +888,9 @@ var DEFAULT_AUTO_TRACK = {
|
|
|
660
888
|
sessions: true,
|
|
661
889
|
pageViews: true,
|
|
662
890
|
deviceInfo: true,
|
|
663
|
-
clicks: true
|
|
891
|
+
clicks: true,
|
|
892
|
+
webVitals: true,
|
|
893
|
+
errors: true
|
|
664
894
|
};
|
|
665
895
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
666
896
|
var EMPTY_ACQUISITION = {
|
|
@@ -669,7 +899,13 @@ var EMPTY_ACQUISITION = {
|
|
|
669
899
|
utm_campaign: "",
|
|
670
900
|
utm_content: "",
|
|
671
901
|
utm_term: "",
|
|
672
|
-
referrer: ""
|
|
902
|
+
referrer: "",
|
|
903
|
+
gclid: "",
|
|
904
|
+
fbclid: "",
|
|
905
|
+
msclkid: "",
|
|
906
|
+
ttclid: "",
|
|
907
|
+
li_fat_id: "",
|
|
908
|
+
twclid: ""
|
|
673
909
|
};
|
|
674
910
|
var AutoTracker = class {
|
|
675
911
|
constructor(cfg, track) {
|
|
@@ -677,6 +913,17 @@ var AutoTracker = class {
|
|
|
677
913
|
this.track = track;
|
|
678
914
|
this.session = null;
|
|
679
915
|
this.cleanups = [];
|
|
916
|
+
/**
|
|
917
|
+
* Stable per-page-view identifier. Minted at every `page.viewed`
|
|
918
|
+
* emission and attached to every subsequent event until the next
|
|
919
|
+
* `page.viewed`. Lets dashboards correlate "user clicked X" to
|
|
920
|
+
* "user viewed page Y" without timestamp arithmetic — the canonical
|
|
921
|
+
* Mixpanel `$current_url` / Segment `pageId` pattern.
|
|
922
|
+
*
|
|
923
|
+
* Null until the first `page.viewed` fires (which happens at SDK
|
|
924
|
+
* install if `autoTrack.pageViews !== false`).
|
|
925
|
+
*/
|
|
926
|
+
this.pageviewId = null;
|
|
680
927
|
}
|
|
681
928
|
install() {
|
|
682
929
|
if (!isBrowserSafe()) return;
|
|
@@ -707,6 +954,10 @@ var AutoTracker = class {
|
|
|
707
954
|
get currentSessionId() {
|
|
708
955
|
return this.session?.sessionId ?? null;
|
|
709
956
|
}
|
|
957
|
+
/** Stable per-page-view ID. Null before the first page.viewed has fired. */
|
|
958
|
+
get currentPageviewId() {
|
|
959
|
+
return this.pageviewId;
|
|
960
|
+
}
|
|
710
961
|
/**
|
|
711
962
|
* Per-session acquisition context — utm_* + referrer, captured once
|
|
712
963
|
* at session start. Returns empty strings when there's no session
|
|
@@ -787,7 +1038,9 @@ var AutoTracker = class {
|
|
|
787
1038
|
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
788
1039
|
lastFiredAt = now;
|
|
789
1040
|
lastFiredUrl = url;
|
|
1041
|
+
this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
|
|
790
1042
|
this.track("page.viewed", {
|
|
1043
|
+
pageviewId: this.pageviewId,
|
|
791
1044
|
path: loc.pathname,
|
|
792
1045
|
url,
|
|
793
1046
|
search: loc.search || void 0,
|
|
@@ -991,6 +1244,12 @@ function captureAcquisition() {
|
|
|
991
1244
|
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
992
1245
|
result.utm_content = params.get("utm_content") ?? "";
|
|
993
1246
|
result.utm_term = params.get("utm_term") ?? "";
|
|
1247
|
+
result.gclid = params.get("gclid") ?? "";
|
|
1248
|
+
result.fbclid = params.get("fbclid") ?? "";
|
|
1249
|
+
result.msclkid = params.get("msclkid") ?? "";
|
|
1250
|
+
result.ttclid = params.get("ttclid") ?? "";
|
|
1251
|
+
result.li_fat_id = params.get("li_fat_id") ?? "";
|
|
1252
|
+
result.twclid = params.get("twclid") ?? "";
|
|
994
1253
|
} catch {
|
|
995
1254
|
}
|
|
996
1255
|
try {
|
|
@@ -1048,6 +1307,1025 @@ function safeJson(obj) {
|
|
|
1048
1307
|
}
|
|
1049
1308
|
}
|
|
1050
1309
|
|
|
1310
|
+
// src/event-validation.ts
|
|
1311
|
+
var DEFAULT_MAX_STRING = 1024;
|
|
1312
|
+
var DEFAULT_MAX_BYTES = 8 * 1024;
|
|
1313
|
+
var DEFAULT_MAX_DEPTH = 5;
|
|
1314
|
+
function validateEventProperties(input, options = {}) {
|
|
1315
|
+
const warnings = [];
|
|
1316
|
+
if (!input) return { properties: {}, warnings };
|
|
1317
|
+
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
|
|
1318
|
+
const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
|
|
1319
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1320
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1321
|
+
const visit = (value, key, depth) => {
|
|
1322
|
+
if (depth > maxDepth) {
|
|
1323
|
+
warnings.push({ kind: "depth_exceeded", key });
|
|
1324
|
+
return { keep: true, value: "[depth-exceeded]" };
|
|
1325
|
+
}
|
|
1326
|
+
if (value === null) return { keep: true, value: null };
|
|
1327
|
+
const t = typeof value;
|
|
1328
|
+
if (t === "string") {
|
|
1329
|
+
const s = value;
|
|
1330
|
+
if (s.length > maxStringLength) {
|
|
1331
|
+
warnings.push({ kind: "truncated_string", key });
|
|
1332
|
+
return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
|
|
1333
|
+
}
|
|
1334
|
+
return { keep: true, value: s };
|
|
1335
|
+
}
|
|
1336
|
+
if (t === "number") {
|
|
1337
|
+
if (!Number.isFinite(value)) {
|
|
1338
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1339
|
+
return { keep: true, value: null };
|
|
1340
|
+
}
|
|
1341
|
+
return { keep: true, value };
|
|
1342
|
+
}
|
|
1343
|
+
if (t === "boolean") return { keep: true, value };
|
|
1344
|
+
if (t === "bigint") {
|
|
1345
|
+
warnings.push({ kind: "coerced_bigint", key });
|
|
1346
|
+
return { keep: true, value: value.toString() };
|
|
1347
|
+
}
|
|
1348
|
+
if (t === "function") {
|
|
1349
|
+
warnings.push({ kind: "dropped_function", key });
|
|
1350
|
+
return { keep: false, value: void 0 };
|
|
1351
|
+
}
|
|
1352
|
+
if (t === "symbol") {
|
|
1353
|
+
warnings.push({ kind: "dropped_symbol", key });
|
|
1354
|
+
return { keep: false, value: void 0 };
|
|
1355
|
+
}
|
|
1356
|
+
if (t === "undefined") {
|
|
1357
|
+
warnings.push({ kind: "dropped_undefined", key });
|
|
1358
|
+
return { keep: false, value: void 0 };
|
|
1359
|
+
}
|
|
1360
|
+
if (value instanceof Date) {
|
|
1361
|
+
warnings.push({ kind: "coerced_date", key });
|
|
1362
|
+
const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
|
|
1363
|
+
return { keep: true, value: iso };
|
|
1364
|
+
}
|
|
1365
|
+
if (value instanceof Error) {
|
|
1366
|
+
warnings.push({ kind: "coerced_error", key });
|
|
1367
|
+
return {
|
|
1368
|
+
keep: true,
|
|
1369
|
+
value: {
|
|
1370
|
+
name: value.name,
|
|
1371
|
+
message: value.message,
|
|
1372
|
+
stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
if (value instanceof Map) {
|
|
1377
|
+
warnings.push({ kind: "coerced_map", key });
|
|
1378
|
+
const obj = {};
|
|
1379
|
+
for (const [k, v] of value.entries()) {
|
|
1380
|
+
const subKey = typeof k === "string" ? k : String(k);
|
|
1381
|
+
const result = visit(v, `${key}.${subKey}`, depth + 1);
|
|
1382
|
+
if (result.keep) obj[subKey] = result.value;
|
|
1383
|
+
}
|
|
1384
|
+
return { keep: true, value: obj };
|
|
1385
|
+
}
|
|
1386
|
+
if (value instanceof Set) {
|
|
1387
|
+
warnings.push({ kind: "coerced_set", key });
|
|
1388
|
+
const arr = [];
|
|
1389
|
+
let i = 0;
|
|
1390
|
+
for (const v of value.values()) {
|
|
1391
|
+
const result = visit(v, `${key}[${i}]`, depth + 1);
|
|
1392
|
+
if (result.keep) arr.push(result.value);
|
|
1393
|
+
i++;
|
|
1394
|
+
}
|
|
1395
|
+
return { keep: true, value: arr };
|
|
1396
|
+
}
|
|
1397
|
+
if (Array.isArray(value)) {
|
|
1398
|
+
if (seen.has(value)) {
|
|
1399
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1400
|
+
return { keep: true, value: "[circular]" };
|
|
1401
|
+
}
|
|
1402
|
+
seen.add(value);
|
|
1403
|
+
const out = [];
|
|
1404
|
+
for (let i = 0; i < value.length; i++) {
|
|
1405
|
+
const result = visit(value[i], `${key}[${i}]`, depth + 1);
|
|
1406
|
+
if (result.keep) out.push(result.value);
|
|
1407
|
+
}
|
|
1408
|
+
return { keep: true, value: out };
|
|
1409
|
+
}
|
|
1410
|
+
if (t === "object") {
|
|
1411
|
+
const obj = value;
|
|
1412
|
+
if (seen.has(obj)) {
|
|
1413
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1414
|
+
return { keep: true, value: "[circular]" };
|
|
1415
|
+
}
|
|
1416
|
+
seen.add(obj);
|
|
1417
|
+
const out = {};
|
|
1418
|
+
for (const k of Object.keys(obj)) {
|
|
1419
|
+
const result = visit(obj[k], `${key}.${k}`, depth + 1);
|
|
1420
|
+
if (result.keep) out[k] = result.value;
|
|
1421
|
+
}
|
|
1422
|
+
return { keep: true, value: out };
|
|
1423
|
+
}
|
|
1424
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1425
|
+
try {
|
|
1426
|
+
return { keep: true, value: String(value) };
|
|
1427
|
+
} catch {
|
|
1428
|
+
return { keep: false, value: void 0 };
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
const cleaned = {};
|
|
1432
|
+
for (const k of Object.keys(input)) {
|
|
1433
|
+
const result = visit(input[k], k, 0);
|
|
1434
|
+
if (result.keep) cleaned[k] = result.value;
|
|
1435
|
+
}
|
|
1436
|
+
const serialised = safeStringify(cleaned);
|
|
1437
|
+
if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
|
|
1438
|
+
warnings.push({ kind: "size_cap_exceeded", key: "*" });
|
|
1439
|
+
const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
|
|
1440
|
+
let currentSize = byteLength(serialised);
|
|
1441
|
+
for (const { k } of sizes) {
|
|
1442
|
+
if (currentSize <= maxBatchPropertyBytes) break;
|
|
1443
|
+
currentSize -= sizes.find((s) => s.k === k).size;
|
|
1444
|
+
delete cleaned[k];
|
|
1445
|
+
}
|
|
1446
|
+
cleaned.__truncated = true;
|
|
1447
|
+
}
|
|
1448
|
+
return { properties: cleaned, warnings };
|
|
1449
|
+
}
|
|
1450
|
+
function safeStringify(v) {
|
|
1451
|
+
try {
|
|
1452
|
+
return JSON.stringify(v) ?? null;
|
|
1453
|
+
} catch {
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
function byteLength(s) {
|
|
1458
|
+
if (typeof TextEncoder !== "undefined") {
|
|
1459
|
+
return new TextEncoder().encode(s).length;
|
|
1460
|
+
}
|
|
1461
|
+
return s.length * 4;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// src/super-properties.ts
|
|
1465
|
+
var KEY_SUPER = "super_props";
|
|
1466
|
+
var KEY_GROUPS = "groups";
|
|
1467
|
+
var SuperPropertyStore = class {
|
|
1468
|
+
constructor(storage, prefix) {
|
|
1469
|
+
this.storage = storage;
|
|
1470
|
+
this.prefix = prefix;
|
|
1471
|
+
this.superProps = {};
|
|
1472
|
+
this.groups = {};
|
|
1473
|
+
this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
|
|
1474
|
+
this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
|
|
1475
|
+
}
|
|
1476
|
+
// ---------- super properties ----------
|
|
1477
|
+
/**
|
|
1478
|
+
* Merge new keys into the super-property bag. Returns a snapshot of
|
|
1479
|
+
* the resulting bag. Values that are `null` are deleted (Mixpanel
|
|
1480
|
+
* semantics — explicit null = "stop tracking this key").
|
|
1481
|
+
*/
|
|
1482
|
+
register(props) {
|
|
1483
|
+
for (const [k, v] of Object.entries(props)) {
|
|
1484
|
+
if (v === null) {
|
|
1485
|
+
delete this.superProps[k];
|
|
1486
|
+
} else if (v !== void 0) {
|
|
1487
|
+
this.superProps[k] = v;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1491
|
+
return { ...this.superProps };
|
|
1492
|
+
}
|
|
1493
|
+
/** Remove a single super-property key. Idempotent. */
|
|
1494
|
+
unregister(key) {
|
|
1495
|
+
if (key in this.superProps) {
|
|
1496
|
+
delete this.superProps[key];
|
|
1497
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
/** Snapshot of the current super-property bag. */
|
|
1501
|
+
getSuperProperties() {
|
|
1502
|
+
return { ...this.superProps };
|
|
1503
|
+
}
|
|
1504
|
+
// ---------- groups ----------
|
|
1505
|
+
/**
|
|
1506
|
+
* Set a group membership. Passing `id: null` clears the membership
|
|
1507
|
+
* for that group type — the SDK stops attaching it to events.
|
|
1508
|
+
*/
|
|
1509
|
+
setGroup(type, id, traits) {
|
|
1510
|
+
if (id === null) {
|
|
1511
|
+
delete this.groups[type];
|
|
1512
|
+
} else {
|
|
1513
|
+
this.groups[type] = traits !== void 0 ? { id, traits } : { id };
|
|
1514
|
+
}
|
|
1515
|
+
writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Snapshot of the current groups map, keyed by group type. Returned
|
|
1519
|
+
* shape mirrors what the SDK attaches to every event as
|
|
1520
|
+
* `$groups.{type}`. The `traits` sub-object is the most-recent
|
|
1521
|
+
* traits payload passed to `setGroup` for that type; null when none.
|
|
1522
|
+
*/
|
|
1523
|
+
getGroups() {
|
|
1524
|
+
return JSON.parse(JSON.stringify(this.groups));
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* The flat `{ type: id }` projection used for event-attachment. Stable
|
|
1528
|
+
* for fast every-event merge — we don't want to JSON-clone on each
|
|
1529
|
+
* track() call.
|
|
1530
|
+
*/
|
|
1531
|
+
getGroupIds() {
|
|
1532
|
+
const out = {};
|
|
1533
|
+
for (const [type, info] of Object.entries(this.groups)) {
|
|
1534
|
+
out[type] = info.id;
|
|
1535
|
+
}
|
|
1536
|
+
return out;
|
|
1537
|
+
}
|
|
1538
|
+
/** Wipe both bags. Called by Crossdeck.reset() (logout). */
|
|
1539
|
+
clear() {
|
|
1540
|
+
this.superProps = {};
|
|
1541
|
+
this.groups = {};
|
|
1542
|
+
try {
|
|
1543
|
+
this.storage.removeItem(this.prefix + KEY_SUPER);
|
|
1544
|
+
} catch {
|
|
1545
|
+
}
|
|
1546
|
+
try {
|
|
1547
|
+
this.storage.removeItem(this.prefix + KEY_GROUPS);
|
|
1548
|
+
} catch {
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
function readJson(storage, key) {
|
|
1553
|
+
let raw;
|
|
1554
|
+
try {
|
|
1555
|
+
raw = storage.getItem(key);
|
|
1556
|
+
} catch {
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
1559
|
+
if (!raw) return null;
|
|
1560
|
+
try {
|
|
1561
|
+
return JSON.parse(raw);
|
|
1562
|
+
} catch {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
function writeJson(storage, key, value) {
|
|
1567
|
+
try {
|
|
1568
|
+
storage.setItem(key, JSON.stringify(value));
|
|
1569
|
+
} catch {
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// src/web-vitals.ts
|
|
1574
|
+
var WebVitalsTracker = class {
|
|
1575
|
+
constructor(cfg, report) {
|
|
1576
|
+
this.cfg = cfg;
|
|
1577
|
+
this.report = report;
|
|
1578
|
+
this.observers = [];
|
|
1579
|
+
this.flushed = /* @__PURE__ */ new Set();
|
|
1580
|
+
this.cls = 0;
|
|
1581
|
+
this.clsEntries = [];
|
|
1582
|
+
this.inp = 0;
|
|
1583
|
+
this.cleanups = [];
|
|
1584
|
+
}
|
|
1585
|
+
install() {
|
|
1586
|
+
if (!this.cfg.enabled) return;
|
|
1587
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
1588
|
+
if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
|
|
1589
|
+
const doc = globalThis.document;
|
|
1590
|
+
try {
|
|
1591
|
+
const navObserver = new PerformanceObserver((list) => {
|
|
1592
|
+
for (const entry of list.getEntries()) {
|
|
1593
|
+
const e = entry;
|
|
1594
|
+
if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
|
|
1595
|
+
this.flushed.add("ttfb");
|
|
1596
|
+
this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
navObserver.observe({ type: "navigation", buffered: true });
|
|
1601
|
+
this.observers.push(navObserver);
|
|
1602
|
+
} catch {
|
|
1603
|
+
}
|
|
1604
|
+
try {
|
|
1605
|
+
const paintObserver = new PerformanceObserver((list) => {
|
|
1606
|
+
for (const entry of list.getEntries()) {
|
|
1607
|
+
if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
|
|
1608
|
+
this.flushed.add("fcp");
|
|
1609
|
+
this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
paintObserver.observe({ type: "paint", buffered: true });
|
|
1614
|
+
this.observers.push(paintObserver);
|
|
1615
|
+
} catch {
|
|
1616
|
+
}
|
|
1617
|
+
let lcpValue = 0;
|
|
1618
|
+
try {
|
|
1619
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
1620
|
+
const entries = list.getEntries();
|
|
1621
|
+
const last = entries[entries.length - 1];
|
|
1622
|
+
if (last) lcpValue = last.startTime;
|
|
1623
|
+
});
|
|
1624
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
1625
|
+
this.observers.push(lcpObserver);
|
|
1626
|
+
} catch {
|
|
1627
|
+
}
|
|
1628
|
+
try {
|
|
1629
|
+
const clsObserver = new PerformanceObserver((list) => {
|
|
1630
|
+
for (const entry of list.getEntries()) {
|
|
1631
|
+
const e = entry;
|
|
1632
|
+
if (typeof e.value === "number" && !e.hadRecentInput) {
|
|
1633
|
+
this.cls += e.value;
|
|
1634
|
+
this.clsEntries.push(entry);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
1639
|
+
this.observers.push(clsObserver);
|
|
1640
|
+
} catch {
|
|
1641
|
+
}
|
|
1642
|
+
try {
|
|
1643
|
+
const eventObserver = new PerformanceObserver((list) => {
|
|
1644
|
+
for (const entry of list.getEntries()) {
|
|
1645
|
+
const e = entry;
|
|
1646
|
+
if (e.interactionId && e.duration > this.inp) {
|
|
1647
|
+
this.inp = e.duration;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
try {
|
|
1652
|
+
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
1653
|
+
} catch {
|
|
1654
|
+
eventObserver.observe({ type: "first-input", buffered: true });
|
|
1655
|
+
}
|
|
1656
|
+
this.observers.push(eventObserver);
|
|
1657
|
+
} catch {
|
|
1658
|
+
}
|
|
1659
|
+
const flush = () => {
|
|
1660
|
+
if (lcpValue > 0 && !this.flushed.has("lcp")) {
|
|
1661
|
+
this.flushed.add("lcp");
|
|
1662
|
+
this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
|
|
1663
|
+
}
|
|
1664
|
+
if (this.cls > 0 && !this.flushed.has("cls")) {
|
|
1665
|
+
this.flushed.add("cls");
|
|
1666
|
+
this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
|
|
1667
|
+
}
|
|
1668
|
+
if (this.inp > 0 && !this.flushed.has("inp")) {
|
|
1669
|
+
this.flushed.add("inp");
|
|
1670
|
+
this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
const onHidden = () => {
|
|
1674
|
+
if (doc.visibilityState === "hidden") flush();
|
|
1675
|
+
};
|
|
1676
|
+
doc.addEventListener("visibilitychange", onHidden);
|
|
1677
|
+
globalThis.window.addEventListener("pagehide", flush);
|
|
1678
|
+
this.cleanups.push(() => {
|
|
1679
|
+
doc.removeEventListener("visibilitychange", onHidden);
|
|
1680
|
+
globalThis.window.removeEventListener("pagehide", flush);
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
uninstall() {
|
|
1684
|
+
for (const o of this.observers) {
|
|
1685
|
+
try {
|
|
1686
|
+
o.disconnect();
|
|
1687
|
+
} catch {
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
this.observers = [];
|
|
1691
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1692
|
+
try {
|
|
1693
|
+
fn();
|
|
1694
|
+
} catch {
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
// src/consent.ts
|
|
1701
|
+
var ALL_GRANTED = {
|
|
1702
|
+
analytics: true,
|
|
1703
|
+
marketing: true,
|
|
1704
|
+
errors: true
|
|
1705
|
+
};
|
|
1706
|
+
var ConsentManager = class {
|
|
1707
|
+
constructor(options) {
|
|
1708
|
+
this.state = { ...ALL_GRANTED };
|
|
1709
|
+
this.dntDenied = false;
|
|
1710
|
+
if (options?.respectDnt && this.detectDnt()) {
|
|
1711
|
+
this.dntDenied = true;
|
|
1712
|
+
this.state = { analytics: false, marketing: false, errors: false };
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Merge new dimensions onto the current state. Returns the resulting
|
|
1717
|
+
* snapshot. DNT-derived denies cannot be flipped back on by a `set`
|
|
1718
|
+
* call — once the browser says "don't track", we don't track even if
|
|
1719
|
+
* the developer code disagrees. That's the contract.
|
|
1720
|
+
*/
|
|
1721
|
+
set(partial) {
|
|
1722
|
+
if (this.dntDenied) return { ...this.state };
|
|
1723
|
+
for (const k of Object.keys(partial)) {
|
|
1724
|
+
const v = partial[k];
|
|
1725
|
+
if (typeof v === "boolean") this.state[k] = v;
|
|
1726
|
+
}
|
|
1727
|
+
return { ...this.state };
|
|
1728
|
+
}
|
|
1729
|
+
/** Snapshot of the current state. */
|
|
1730
|
+
get() {
|
|
1731
|
+
return { ...this.state };
|
|
1732
|
+
}
|
|
1733
|
+
/** Convenience getters for hot paths. */
|
|
1734
|
+
get analytics() {
|
|
1735
|
+
return this.state.analytics;
|
|
1736
|
+
}
|
|
1737
|
+
get marketing() {
|
|
1738
|
+
return this.state.marketing;
|
|
1739
|
+
}
|
|
1740
|
+
get errors() {
|
|
1741
|
+
return this.state.errors;
|
|
1742
|
+
}
|
|
1743
|
+
/** True iff the constructor detected and applied DNT. */
|
|
1744
|
+
get isDntDenied() {
|
|
1745
|
+
return this.dntDenied;
|
|
1746
|
+
}
|
|
1747
|
+
detectDnt() {
|
|
1748
|
+
try {
|
|
1749
|
+
const nav = globalThis.navigator;
|
|
1750
|
+
if (!nav) return false;
|
|
1751
|
+
const sources = [
|
|
1752
|
+
nav.doNotTrack,
|
|
1753
|
+
nav.msDoNotTrack,
|
|
1754
|
+
globalThis.doNotTrack
|
|
1755
|
+
];
|
|
1756
|
+
return sources.some((v) => v === "1" || v === "yes");
|
|
1757
|
+
} catch {
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
1763
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
1764
|
+
var REPLACEMENT_EMAIL = "[email]";
|
|
1765
|
+
var REPLACEMENT_CARD = "[card]";
|
|
1766
|
+
function scrubPii(value) {
|
|
1767
|
+
if (!value) return value;
|
|
1768
|
+
let out = value;
|
|
1769
|
+
if (EMAIL_PATTERN.test(out)) {
|
|
1770
|
+
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
1771
|
+
}
|
|
1772
|
+
EMAIL_PATTERN.lastIndex = 0;
|
|
1773
|
+
if (CARD_PATTERN.test(out)) {
|
|
1774
|
+
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1775
|
+
}
|
|
1776
|
+
CARD_PATTERN.lastIndex = 0;
|
|
1777
|
+
return out;
|
|
1778
|
+
}
|
|
1779
|
+
function scrubPiiFromProperties(properties) {
|
|
1780
|
+
const out = {};
|
|
1781
|
+
for (const k of Object.keys(properties)) {
|
|
1782
|
+
const v = properties[k];
|
|
1783
|
+
if (typeof v === "string") {
|
|
1784
|
+
out[k] = scrubPii(v);
|
|
1785
|
+
} else if (Array.isArray(v)) {
|
|
1786
|
+
out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
|
|
1787
|
+
} else {
|
|
1788
|
+
out[k] = v;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return out;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// src/breadcrumbs.ts
|
|
1795
|
+
var BreadcrumbBuffer = class {
|
|
1796
|
+
constructor(maxSize = 50) {
|
|
1797
|
+
this.maxSize = maxSize;
|
|
1798
|
+
this.items = [];
|
|
1799
|
+
}
|
|
1800
|
+
add(crumb) {
|
|
1801
|
+
this.items.push(crumb);
|
|
1802
|
+
if (this.items.length > this.maxSize) {
|
|
1803
|
+
this.items.shift();
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
/** Defensive copy — caller can read freely without mutating buffer state. */
|
|
1807
|
+
snapshot() {
|
|
1808
|
+
return this.items.slice();
|
|
1809
|
+
}
|
|
1810
|
+
clear() {
|
|
1811
|
+
this.items = [];
|
|
1812
|
+
}
|
|
1813
|
+
get size() {
|
|
1814
|
+
return this.items.length;
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
|
|
1818
|
+
// src/stack-parser.ts
|
|
1819
|
+
function parseStack(stack) {
|
|
1820
|
+
if (!stack || typeof stack !== "string") return [];
|
|
1821
|
+
const lines = stack.split("\n");
|
|
1822
|
+
const frames = [];
|
|
1823
|
+
for (const line of lines) {
|
|
1824
|
+
const trimmed = line.trim();
|
|
1825
|
+
if (!trimmed) continue;
|
|
1826
|
+
const frame = parseLine(trimmed);
|
|
1827
|
+
if (frame) frames.push(frame);
|
|
1828
|
+
}
|
|
1829
|
+
return frames;
|
|
1830
|
+
}
|
|
1831
|
+
function parseLine(line) {
|
|
1832
|
+
let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
|
|
1833
|
+
if (m) {
|
|
1834
|
+
return buildFrame({
|
|
1835
|
+
function: m[1],
|
|
1836
|
+
filename: m[2],
|
|
1837
|
+
lineno: parseInt(m[3], 10),
|
|
1838
|
+
colno: parseInt(m[4], 10),
|
|
1839
|
+
raw: line
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
|
|
1843
|
+
if (m) {
|
|
1844
|
+
return buildFrame({
|
|
1845
|
+
function: "?",
|
|
1846
|
+
filename: m[1],
|
|
1847
|
+
lineno: parseInt(m[2], 10),
|
|
1848
|
+
colno: parseInt(m[3], 10),
|
|
1849
|
+
raw: line
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
|
|
1853
|
+
if (m) {
|
|
1854
|
+
return buildFrame({
|
|
1855
|
+
function: m[1] || "?",
|
|
1856
|
+
filename: m[2],
|
|
1857
|
+
lineno: parseInt(m[3], 10),
|
|
1858
|
+
colno: parseInt(m[4], 10),
|
|
1859
|
+
raw: line
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
if (/^\w*Error/.test(line) || !line.includes(":")) {
|
|
1863
|
+
return null;
|
|
1864
|
+
}
|
|
1865
|
+
return {
|
|
1866
|
+
function: "?",
|
|
1867
|
+
filename: "",
|
|
1868
|
+
lineno: 0,
|
|
1869
|
+
colno: 0,
|
|
1870
|
+
in_app: true,
|
|
1871
|
+
raw: line
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
function buildFrame(input) {
|
|
1875
|
+
return {
|
|
1876
|
+
function: input.function || "?",
|
|
1877
|
+
filename: input.filename,
|
|
1878
|
+
lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
|
|
1879
|
+
colno: Number.isFinite(input.colno) ? input.colno : 0,
|
|
1880
|
+
in_app: isInAppFrame(input.filename),
|
|
1881
|
+
raw: input.raw
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
function isInAppFrame(filename) {
|
|
1885
|
+
if (!filename) return true;
|
|
1886
|
+
if (/^(?:chrome|moz|safari|webkit)-extension:\/\//.test(filename)) return false;
|
|
1887
|
+
if (/\bcdn\.jsdelivr\.net\b/.test(filename)) return false;
|
|
1888
|
+
if (/\bunpkg\.com\b/.test(filename)) return false;
|
|
1889
|
+
if (/\bgoogletagmanager\.com\b/.test(filename)) return false;
|
|
1890
|
+
if (/\bgoogle-analytics\.com\b/.test(filename)) return false;
|
|
1891
|
+
if (/\b@cross-deck\/web\b/.test(filename)) return false;
|
|
1892
|
+
if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
|
|
1893
|
+
return true;
|
|
1894
|
+
}
|
|
1895
|
+
function fingerprintError(message, frames) {
|
|
1896
|
+
const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
|
|
1897
|
+
const key = [
|
|
1898
|
+
(message || "").slice(0, 200),
|
|
1899
|
+
...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
|
|
1900
|
+
].join("|");
|
|
1901
|
+
return djb2Hex(key);
|
|
1902
|
+
}
|
|
1903
|
+
function djb2Hex(input) {
|
|
1904
|
+
let h = 5381;
|
|
1905
|
+
for (let i = 0; i < input.length; i++) {
|
|
1906
|
+
h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
1907
|
+
}
|
|
1908
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// src/error-capture.ts
|
|
1912
|
+
var DEFAULT_ERROR_CAPTURE = {
|
|
1913
|
+
enabled: true,
|
|
1914
|
+
onError: true,
|
|
1915
|
+
onUnhandledRejection: true,
|
|
1916
|
+
wrapFetch: true,
|
|
1917
|
+
wrapXhr: true,
|
|
1918
|
+
captureConsole: false,
|
|
1919
|
+
ignoreErrors: [
|
|
1920
|
+
// Classic browser noise. These aren't application bugs.
|
|
1921
|
+
"ResizeObserver loop limit exceeded",
|
|
1922
|
+
"ResizeObserver loop completed with undelivered notifications",
|
|
1923
|
+
"Non-Error promise rejection captured",
|
|
1924
|
+
// Cross-origin script errors that the browser strips — no info,
|
|
1925
|
+
// no way to act on them, just noise.
|
|
1926
|
+
"Script error.",
|
|
1927
|
+
"Script error"
|
|
1928
|
+
],
|
|
1929
|
+
allowUrls: [],
|
|
1930
|
+
denyUrls: [
|
|
1931
|
+
// Common third-party extensions that pollute error streams.
|
|
1932
|
+
/^chrome-extension:\/\//,
|
|
1933
|
+
/^moz-extension:\/\//,
|
|
1934
|
+
/^safari-extension:\/\//,
|
|
1935
|
+
/^webkit-extension:\/\//,
|
|
1936
|
+
/^safari-web-extension:\/\//
|
|
1937
|
+
],
|
|
1938
|
+
sampleRate: 1,
|
|
1939
|
+
maxPerFingerprintPerMinute: 5,
|
|
1940
|
+
maxPerSession: 100
|
|
1941
|
+
};
|
|
1942
|
+
var ErrorTracker = class {
|
|
1943
|
+
constructor(opts) {
|
|
1944
|
+
this.opts = opts;
|
|
1945
|
+
this.installed = false;
|
|
1946
|
+
this.cleanups = [];
|
|
1947
|
+
this._reporting = false;
|
|
1948
|
+
this.sessionCount = 0;
|
|
1949
|
+
this.fingerprintWindow = /* @__PURE__ */ new Map();
|
|
1950
|
+
}
|
|
1951
|
+
install() {
|
|
1952
|
+
if (this.installed) return;
|
|
1953
|
+
if (!this.opts.config.enabled) return;
|
|
1954
|
+
if (typeof globalThis === "undefined" || !("window" in globalThis)) return;
|
|
1955
|
+
const w = globalThis.window;
|
|
1956
|
+
if (this.opts.config.onError) this.installOnErrorListener(w);
|
|
1957
|
+
if (this.opts.config.onUnhandledRejection) this.installRejectionListener(w);
|
|
1958
|
+
if (this.opts.config.wrapFetch) this.installFetchWrap(w);
|
|
1959
|
+
if (this.opts.config.wrapXhr) this.installXhrWrap(w);
|
|
1960
|
+
if (this.opts.config.captureConsole) this.installConsoleWrap();
|
|
1961
|
+
this.installed = true;
|
|
1962
|
+
}
|
|
1963
|
+
uninstall() {
|
|
1964
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1965
|
+
try {
|
|
1966
|
+
fn();
|
|
1967
|
+
} catch {
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
this.installed = false;
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Manual API. Either an Error instance or any unknown value (we
|
|
1974
|
+
* coerce). Returns silently — never throws.
|
|
1975
|
+
*/
|
|
1976
|
+
captureError(error, options) {
|
|
1977
|
+
if (!this.opts.isConsented()) return;
|
|
1978
|
+
try {
|
|
1979
|
+
const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
|
|
1980
|
+
if (options?.context) captured.context = { ...captured.context, ...options.context };
|
|
1981
|
+
if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
|
|
1982
|
+
this.maybeReport(captured);
|
|
1983
|
+
} catch {
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Capture a non-error event as an issue. For "we hit a soft-warning
|
|
1988
|
+
* code path" / "deprecated API used" kinds of signals. Pairs with
|
|
1989
|
+
* Sentry's captureMessage().
|
|
1990
|
+
*/
|
|
1991
|
+
captureMessage(message, level = "info") {
|
|
1992
|
+
if (!this.opts.isConsented()) return;
|
|
1993
|
+
try {
|
|
1994
|
+
const captured = {
|
|
1995
|
+
timestamp: Date.now(),
|
|
1996
|
+
kind: "error.message",
|
|
1997
|
+
level,
|
|
1998
|
+
message,
|
|
1999
|
+
errorType: null,
|
|
2000
|
+
frames: [],
|
|
2001
|
+
rawStack: null,
|
|
2002
|
+
filename: null,
|
|
2003
|
+
lineno: null,
|
|
2004
|
+
colno: null,
|
|
2005
|
+
fingerprint: fingerprintError(message, []),
|
|
2006
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2007
|
+
context: this.opts.getContext(),
|
|
2008
|
+
tags: this.opts.getTags()
|
|
2009
|
+
};
|
|
2010
|
+
this.maybeReport(captured);
|
|
2011
|
+
} catch {
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
// ============================================================
|
|
2015
|
+
// Listener installation
|
|
2016
|
+
// ============================================================
|
|
2017
|
+
installOnErrorListener(w) {
|
|
2018
|
+
const handler = (event) => {
|
|
2019
|
+
if (this._reporting) return;
|
|
2020
|
+
if (!this.opts.isConsented()) return;
|
|
2021
|
+
try {
|
|
2022
|
+
this._reporting = true;
|
|
2023
|
+
const captured = this.buildFromErrorEvent(event);
|
|
2024
|
+
this.maybeReport(captured);
|
|
2025
|
+
} catch {
|
|
2026
|
+
} finally {
|
|
2027
|
+
this._reporting = false;
|
|
2028
|
+
}
|
|
2029
|
+
};
|
|
2030
|
+
w.addEventListener("error", handler, true);
|
|
2031
|
+
this.cleanups.push(() => w.removeEventListener("error", handler, true));
|
|
2032
|
+
}
|
|
2033
|
+
installRejectionListener(w) {
|
|
2034
|
+
const handler = (event) => {
|
|
2035
|
+
if (this._reporting) return;
|
|
2036
|
+
if (!this.opts.isConsented()) return;
|
|
2037
|
+
try {
|
|
2038
|
+
this._reporting = true;
|
|
2039
|
+
const captured = this.buildFromUnknown(
|
|
2040
|
+
event.reason,
|
|
2041
|
+
"error.unhandledrejection",
|
|
2042
|
+
"error"
|
|
2043
|
+
);
|
|
2044
|
+
this.maybeReport(captured);
|
|
2045
|
+
} catch {
|
|
2046
|
+
} finally {
|
|
2047
|
+
this._reporting = false;
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
w.addEventListener("unhandledrejection", handler);
|
|
2051
|
+
this.cleanups.push(() => w.removeEventListener("unhandledrejection", handler));
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Wrap fetch() so failed HTTP requests get auto-captured. We do NOT
|
|
2055
|
+
* call this an "error" for 4xx (those are often expected — auth
|
|
2056
|
+
* required, validation failed). Only 5xx + network failures fire.
|
|
2057
|
+
*/
|
|
2058
|
+
installFetchWrap(w) {
|
|
2059
|
+
const origFetch = w.fetch?.bind(w);
|
|
2060
|
+
if (!origFetch) return;
|
|
2061
|
+
const wrapped = async (...args) => {
|
|
2062
|
+
const input = args[0];
|
|
2063
|
+
const init = args[1] ?? {};
|
|
2064
|
+
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
2065
|
+
const method = (init.method || "GET").toUpperCase();
|
|
2066
|
+
const start = Date.now();
|
|
2067
|
+
this.opts.breadcrumbs.add({
|
|
2068
|
+
timestamp: start,
|
|
2069
|
+
category: "http",
|
|
2070
|
+
message: `${method} ${url}`,
|
|
2071
|
+
data: { url, method }
|
|
2072
|
+
});
|
|
2073
|
+
try {
|
|
2074
|
+
const response = await origFetch(...args);
|
|
2075
|
+
if (response.status >= 500 && this.opts.isConsented()) {
|
|
2076
|
+
if (!url.includes("api.cross-deck.com")) {
|
|
2077
|
+
this.captureHttp({
|
|
2078
|
+
url,
|
|
2079
|
+
method,
|
|
2080
|
+
status: response.status,
|
|
2081
|
+
statusText: response.statusText
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
return response;
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
if (this.opts.isConsented() && !url.includes("api.cross-deck.com")) {
|
|
2088
|
+
this.captureHttp({
|
|
2089
|
+
url,
|
|
2090
|
+
method,
|
|
2091
|
+
status: 0,
|
|
2092
|
+
statusText: err instanceof Error ? err.message : "network error"
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
throw err;
|
|
2096
|
+
}
|
|
2097
|
+
};
|
|
2098
|
+
w.fetch = wrapped;
|
|
2099
|
+
this.cleanups.push(() => {
|
|
2100
|
+
if (w.fetch === wrapped) w.fetch = origFetch;
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Wrap XMLHttpRequest for legacy consumers (jQuery $.ajax under the
|
|
2105
|
+
* hood, older bundlers). Same capture semantics as fetch.
|
|
2106
|
+
*/
|
|
2107
|
+
installXhrWrap(w) {
|
|
2108
|
+
const xhrCtor = w.XMLHttpRequest;
|
|
2109
|
+
const proto = xhrCtor?.prototype;
|
|
2110
|
+
if (!proto) return;
|
|
2111
|
+
const origOpen = proto.open;
|
|
2112
|
+
const origSend = proto.send;
|
|
2113
|
+
const tracker = this;
|
|
2114
|
+
proto.open = function(method, url, ...rest) {
|
|
2115
|
+
this._cdMethod = method;
|
|
2116
|
+
this._cdUrl = url;
|
|
2117
|
+
return origOpen.apply(this, [method, url, ...rest]);
|
|
2118
|
+
};
|
|
2119
|
+
proto.send = function(body) {
|
|
2120
|
+
const xhr = this;
|
|
2121
|
+
const onLoad = () => {
|
|
2122
|
+
try {
|
|
2123
|
+
if (xhr.status >= 500 && tracker.opts.isConsented()) {
|
|
2124
|
+
const url = xhr._cdUrl ?? "";
|
|
2125
|
+
if (!url.includes("api.cross-deck.com")) {
|
|
2126
|
+
tracker.captureHttp({
|
|
2127
|
+
url,
|
|
2128
|
+
method: (xhr._cdMethod ?? "GET").toUpperCase(),
|
|
2129
|
+
status: xhr.status,
|
|
2130
|
+
statusText: xhr.statusText
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
} catch {
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
xhr.addEventListener("loadend", onLoad);
|
|
2138
|
+
return origSend.apply(this, [body ?? null]);
|
|
2139
|
+
};
|
|
2140
|
+
this.cleanups.push(() => {
|
|
2141
|
+
proto.open = origOpen;
|
|
2142
|
+
proto.send = origSend;
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
installConsoleWrap() {
|
|
2146
|
+
const console2 = globalThis.console;
|
|
2147
|
+
if (!console2) return;
|
|
2148
|
+
const orig = console2.error.bind(console2);
|
|
2149
|
+
console2.error = (...args) => {
|
|
2150
|
+
try {
|
|
2151
|
+
if (this.opts.isConsented()) {
|
|
2152
|
+
this.captureMessage(args.map((a) => safeStringify2(a)).join(" "), "error");
|
|
2153
|
+
}
|
|
2154
|
+
} catch {
|
|
2155
|
+
}
|
|
2156
|
+
return orig(...args);
|
|
2157
|
+
};
|
|
2158
|
+
this.cleanups.push(() => {
|
|
2159
|
+
console2.error = orig;
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
// ============================================================
|
|
2163
|
+
// Builders
|
|
2164
|
+
// ============================================================
|
|
2165
|
+
buildFromErrorEvent(event) {
|
|
2166
|
+
const err = event.error;
|
|
2167
|
+
const message = event.message || (err instanceof Error ? err.message : "Unknown error");
|
|
2168
|
+
const stack = err instanceof Error ? err.stack ?? null : null;
|
|
2169
|
+
const frames = parseStack(stack);
|
|
2170
|
+
return {
|
|
2171
|
+
timestamp: Date.now(),
|
|
2172
|
+
kind: "error.unhandled",
|
|
2173
|
+
level: "error",
|
|
2174
|
+
message: String(message).slice(0, 1024),
|
|
2175
|
+
errorType: err instanceof Error ? err.name : null,
|
|
2176
|
+
frames,
|
|
2177
|
+
rawStack: stack,
|
|
2178
|
+
filename: event.filename || null,
|
|
2179
|
+
lineno: typeof event.lineno === "number" ? event.lineno : null,
|
|
2180
|
+
colno: typeof event.colno === "number" ? event.colno : null,
|
|
2181
|
+
fingerprint: fingerprintError(message, frames),
|
|
2182
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2183
|
+
context: this.opts.getContext(),
|
|
2184
|
+
tags: this.opts.getTags()
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
buildFromUnknown(err, kind, level) {
|
|
2188
|
+
if (err instanceof Error) {
|
|
2189
|
+
const frames = parseStack(err.stack);
|
|
2190
|
+
return {
|
|
2191
|
+
timestamp: Date.now(),
|
|
2192
|
+
kind,
|
|
2193
|
+
level,
|
|
2194
|
+
message: String(err.message).slice(0, 1024),
|
|
2195
|
+
errorType: err.name,
|
|
2196
|
+
frames,
|
|
2197
|
+
rawStack: err.stack ?? null,
|
|
2198
|
+
filename: null,
|
|
2199
|
+
lineno: null,
|
|
2200
|
+
colno: null,
|
|
2201
|
+
fingerprint: fingerprintError(err.message, frames),
|
|
2202
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2203
|
+
context: this.opts.getContext(),
|
|
2204
|
+
tags: this.opts.getTags()
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
const message = safeStringify2(err).slice(0, 1024);
|
|
2208
|
+
return {
|
|
2209
|
+
timestamp: Date.now(),
|
|
2210
|
+
kind,
|
|
2211
|
+
level,
|
|
2212
|
+
message,
|
|
2213
|
+
errorType: null,
|
|
2214
|
+
frames: [],
|
|
2215
|
+
rawStack: null,
|
|
2216
|
+
filename: null,
|
|
2217
|
+
lineno: null,
|
|
2218
|
+
colno: null,
|
|
2219
|
+
fingerprint: fingerprintError(message, []),
|
|
2220
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2221
|
+
context: this.opts.getContext(),
|
|
2222
|
+
tags: this.opts.getTags()
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
captureHttp(info) {
|
|
2226
|
+
try {
|
|
2227
|
+
const message = `HTTP ${info.status} ${info.method} ${info.url}`;
|
|
2228
|
+
const captured = {
|
|
2229
|
+
timestamp: Date.now(),
|
|
2230
|
+
kind: "error.http",
|
|
2231
|
+
level: "error",
|
|
2232
|
+
message,
|
|
2233
|
+
errorType: `HTTPError`,
|
|
2234
|
+
frames: [],
|
|
2235
|
+
rawStack: null,
|
|
2236
|
+
filename: info.url,
|
|
2237
|
+
lineno: null,
|
|
2238
|
+
colno: null,
|
|
2239
|
+
fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
|
|
2240
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2241
|
+
context: this.opts.getContext(),
|
|
2242
|
+
tags: this.opts.getTags(),
|
|
2243
|
+
http: info
|
|
2244
|
+
};
|
|
2245
|
+
this.maybeReport(captured);
|
|
2246
|
+
} catch {
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
// ============================================================
|
|
2250
|
+
// Reporting pipeline — filter / sample / rate-limit / send
|
|
2251
|
+
// ============================================================
|
|
2252
|
+
maybeReport(err) {
|
|
2253
|
+
if (this.sessionCount >= this.opts.config.maxPerSession) return;
|
|
2254
|
+
if (this.shouldIgnore(err)) return;
|
|
2255
|
+
if (!this.passesUrlGate(err)) return;
|
|
2256
|
+
if (!this.passesSample(err)) return;
|
|
2257
|
+
if (!this.passesRateLimit(err)) return;
|
|
2258
|
+
let finalErr = err;
|
|
2259
|
+
if (this.opts.beforeSend) {
|
|
2260
|
+
try {
|
|
2261
|
+
finalErr = this.opts.beforeSend(err);
|
|
2262
|
+
} catch {
|
|
2263
|
+
finalErr = err;
|
|
2264
|
+
}
|
|
2265
|
+
if (!finalErr) return;
|
|
2266
|
+
}
|
|
2267
|
+
this.sessionCount += 1;
|
|
2268
|
+
try {
|
|
2269
|
+
this.opts.report(finalErr);
|
|
2270
|
+
} catch {
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
shouldIgnore(err) {
|
|
2274
|
+
for (const pat of this.opts.config.ignoreErrors) {
|
|
2275
|
+
if (typeof pat === "string" && err.message.includes(pat)) return true;
|
|
2276
|
+
if (pat instanceof RegExp && pat.test(err.message)) return true;
|
|
2277
|
+
}
|
|
2278
|
+
return false;
|
|
2279
|
+
}
|
|
2280
|
+
passesUrlGate(err) {
|
|
2281
|
+
const topFrame = err.frames.find((f) => f.filename) ?? null;
|
|
2282
|
+
const url = topFrame?.filename ?? err.filename ?? "";
|
|
2283
|
+
if (!url) return true;
|
|
2284
|
+
for (const pat of this.opts.config.denyUrls) {
|
|
2285
|
+
if (typeof pat === "string" && url.includes(pat)) return false;
|
|
2286
|
+
if (pat instanceof RegExp && pat.test(url)) return false;
|
|
2287
|
+
}
|
|
2288
|
+
if (this.opts.config.allowUrls.length > 0) {
|
|
2289
|
+
for (const pat of this.opts.config.allowUrls) {
|
|
2290
|
+
if (typeof pat === "string" && url.includes(pat)) return true;
|
|
2291
|
+
if (pat instanceof RegExp && pat.test(url)) return true;
|
|
2292
|
+
}
|
|
2293
|
+
return false;
|
|
2294
|
+
}
|
|
2295
|
+
return true;
|
|
2296
|
+
}
|
|
2297
|
+
passesSample(err) {
|
|
2298
|
+
if (this.opts.config.sampleRate >= 1) return true;
|
|
2299
|
+
if (this.opts.config.sampleRate <= 0) return false;
|
|
2300
|
+
const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
|
|
2301
|
+
return hashByte / 255 < this.opts.config.sampleRate;
|
|
2302
|
+
}
|
|
2303
|
+
passesRateLimit(err) {
|
|
2304
|
+
const windowMs = 6e4;
|
|
2305
|
+
const now = Date.now();
|
|
2306
|
+
const max = this.opts.config.maxPerFingerprintPerMinute;
|
|
2307
|
+
const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
|
|
2308
|
+
const fresh = arr.filter((t) => now - t < windowMs);
|
|
2309
|
+
if (fresh.length >= max) {
|
|
2310
|
+
this.fingerprintWindow.set(err.fingerprint, fresh);
|
|
2311
|
+
return false;
|
|
2312
|
+
}
|
|
2313
|
+
fresh.push(now);
|
|
2314
|
+
this.fingerprintWindow.set(err.fingerprint, fresh);
|
|
2315
|
+
return true;
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
function safeStringify2(v) {
|
|
2319
|
+
if (v == null) return String(v);
|
|
2320
|
+
if (typeof v === "string") return v;
|
|
2321
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
2322
|
+
try {
|
|
2323
|
+
return JSON.stringify(v);
|
|
2324
|
+
} catch {
|
|
2325
|
+
return Object.prototype.toString.call(v);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
|
|
1051
2329
|
// src/crossdeck.ts
|
|
1052
2330
|
var CrossdeckClient = class {
|
|
1053
2331
|
constructor() {
|
|
@@ -1137,6 +2415,13 @@ var CrossdeckClient = class {
|
|
|
1137
2415
|
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1138
2416
|
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
1139
2417
|
const entitlements = new EntitlementCache();
|
|
2418
|
+
const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
|
|
2419
|
+
if (persistentEvents) {
|
|
2420
|
+
debug.emit(
|
|
2421
|
+
"sdk.queue_restored",
|
|
2422
|
+
"Restored persisted event queue from a prior session."
|
|
2423
|
+
);
|
|
2424
|
+
}
|
|
1140
2425
|
const events = new EventQueue({
|
|
1141
2426
|
http,
|
|
1142
2427
|
batchSize: opts.eventFlushBatchSize,
|
|
@@ -1146,26 +2431,57 @@ var CrossdeckClient = class {
|
|
|
1146
2431
|
environment: opts.environment,
|
|
1147
2432
|
sdk: { name: SDK_NAME, version: opts.sdkVersion }
|
|
1148
2433
|
}),
|
|
2434
|
+
persistentStore: persistentEvents ?? void 0,
|
|
1149
2435
|
onFirstFlushSuccess: () => {
|
|
1150
2436
|
debug.emit(
|
|
1151
2437
|
"sdk.first_event_sent",
|
|
1152
2438
|
"First telemetry event received. View it in Live Events.",
|
|
1153
2439
|
{ appId: opts.appId, environment: opts.environment }
|
|
1154
2440
|
);
|
|
2441
|
+
},
|
|
2442
|
+
onRetryScheduled: (info) => {
|
|
2443
|
+
debug.emit(
|
|
2444
|
+
"sdk.flush_retry_scheduled",
|
|
2445
|
+
`Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
|
|
2446
|
+
{ ...info }
|
|
2447
|
+
);
|
|
1155
2448
|
}
|
|
1156
2449
|
});
|
|
1157
2450
|
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
2451
|
+
const superProps = new SuperPropertyStore(
|
|
2452
|
+
persistIdentity ? effectiveStorage : new MemoryStorage(),
|
|
2453
|
+
opts.storagePrefix
|
|
2454
|
+
);
|
|
2455
|
+
const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
|
|
2456
|
+
if (consent.isDntDenied) {
|
|
2457
|
+
debug.emit(
|
|
2458
|
+
"sdk.consent_dnt_applied",
|
|
2459
|
+
"Do Not Track detected \u2014 all tracking dimensions denied at init."
|
|
2460
|
+
);
|
|
2461
|
+
}
|
|
2462
|
+
const breadcrumbs = new BreadcrumbBuffer(50);
|
|
1158
2463
|
this.state = {
|
|
1159
2464
|
http,
|
|
1160
2465
|
identity,
|
|
1161
2466
|
entitlements,
|
|
1162
2467
|
events,
|
|
1163
2468
|
autoTracker: null,
|
|
2469
|
+
webVitals: null,
|
|
2470
|
+
errors: null,
|
|
2471
|
+
breadcrumbs,
|
|
2472
|
+
errorContext: {},
|
|
2473
|
+
errorTags: {},
|
|
2474
|
+
errorBeforeSend: null,
|
|
2475
|
+
superProps,
|
|
2476
|
+
consent,
|
|
2477
|
+
scrubPii: options.scrubPii !== false,
|
|
1164
2478
|
deviceInfo,
|
|
1165
2479
|
options: opts,
|
|
1166
2480
|
debug,
|
|
1167
2481
|
developerUserId: null,
|
|
1168
|
-
uninstallUnloadFlush: null
|
|
2482
|
+
uninstallUnloadFlush: null,
|
|
2483
|
+
lastServerTime: null,
|
|
2484
|
+
lastClientTime: null
|
|
1169
2485
|
};
|
|
1170
2486
|
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
1171
2487
|
appId: opts.appId,
|
|
@@ -1180,6 +2496,27 @@ var CrossdeckClient = class {
|
|
|
1180
2496
|
this.state.autoTracker = tracker;
|
|
1181
2497
|
tracker.install();
|
|
1182
2498
|
}
|
|
2499
|
+
if (autoTrack.webVitals) {
|
|
2500
|
+
const vitals = new WebVitalsTracker(
|
|
2501
|
+
{ enabled: true },
|
|
2502
|
+
(name, properties) => this.track(name, properties)
|
|
2503
|
+
);
|
|
2504
|
+
this.state.webVitals = vitals;
|
|
2505
|
+
vitals.install();
|
|
2506
|
+
}
|
|
2507
|
+
if (autoTrack.errors) {
|
|
2508
|
+
const tracker = new ErrorTracker({
|
|
2509
|
+
config: { ...DEFAULT_ERROR_CAPTURE, enabled: true },
|
|
2510
|
+
breadcrumbs,
|
|
2511
|
+
report: (err) => this.reportError(err),
|
|
2512
|
+
getContext: () => ({ ...this.state.errorContext }),
|
|
2513
|
+
getTags: () => ({ ...this.state.errorTags }),
|
|
2514
|
+
beforeSend: this.state.errorBeforeSend,
|
|
2515
|
+
isConsented: () => this.state.consent.errors
|
|
2516
|
+
});
|
|
2517
|
+
this.state.errors = tracker;
|
|
2518
|
+
tracker.install();
|
|
2519
|
+
}
|
|
1183
2520
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
1184
2521
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
1185
2522
|
});
|
|
@@ -1203,8 +2540,19 @@ var CrossdeckClient = class {
|
|
|
1203
2540
|
/**
|
|
1204
2541
|
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
1205
2542
|
* the resolved Crossdeck customer for follow-up calls.
|
|
2543
|
+
*
|
|
2544
|
+
* v0.9.0+ accepts an optional `traits` bag — profile data (name,
|
|
2545
|
+
* plan, signupDate, role) persisted on the Crossdeck customer record
|
|
2546
|
+
* and queryable from dashboards. Traits are sanitised through the
|
|
2547
|
+
* same validator that gates `track()` properties, so a `{ avatar:
|
|
2548
|
+
* <File>, onSave: () => {} }` payload can't corrupt the alias call.
|
|
2549
|
+
*
|
|
2550
|
+
* Crossdeck.identify("user_847", {
|
|
2551
|
+
* email: "wes@pinet.co.za",
|
|
2552
|
+
* traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
|
|
2553
|
+
* });
|
|
1206
2554
|
*/
|
|
1207
|
-
async identify(userId,
|
|
2555
|
+
async identify(userId, options) {
|
|
1208
2556
|
const s = this.requireStarted();
|
|
1209
2557
|
if (!userId) {
|
|
1210
2558
|
throw new CrossdeckError({
|
|
@@ -1213,13 +2561,265 @@ var CrossdeckClient = class {
|
|
|
1213
2561
|
message: "identify(userId) requires a non-empty userId."
|
|
1214
2562
|
});
|
|
1215
2563
|
}
|
|
2564
|
+
if (!s.consent.analytics) {
|
|
2565
|
+
s.debug.emit(
|
|
2566
|
+
"sdk.consent_denied",
|
|
2567
|
+
`identify() skipped \u2014 consent denied for analytics.`
|
|
2568
|
+
);
|
|
2569
|
+
return {
|
|
2570
|
+
object: "alias_result",
|
|
2571
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
|
|
2572
|
+
linked: [],
|
|
2573
|
+
mergePending: false,
|
|
2574
|
+
env: s.options.environment
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
|
|
2578
|
+
const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
|
|
2579
|
+
if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
|
|
2580
|
+
for (const w of traitsValidation.warnings) {
|
|
2581
|
+
s.debug.emit(
|
|
2582
|
+
"sdk.property_coerced",
|
|
2583
|
+
`identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2584
|
+
{ key: w.key, kind: w.kind }
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
const body = {
|
|
2589
|
+
userId,
|
|
2590
|
+
anonymousId: s.identity.anonymousId
|
|
2591
|
+
};
|
|
2592
|
+
if (options?.email) body.email = options.email;
|
|
2593
|
+
if (traits) body.traits = traits;
|
|
1216
2594
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
1217
|
-
body
|
|
2595
|
+
body
|
|
1218
2596
|
});
|
|
1219
2597
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
1220
2598
|
s.developerUserId = userId;
|
|
1221
2599
|
return result;
|
|
1222
2600
|
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Register super-properties — Mixpanel pattern. Once set, every
|
|
2603
|
+
* subsequent event of THIS SDK instance carries these keys on its
|
|
2604
|
+
* properties bag automatically.
|
|
2605
|
+
*
|
|
2606
|
+
* Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
|
|
2607
|
+
* Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
|
|
2608
|
+
*
|
|
2609
|
+
* Values that are `null` are deleted (the explicit "stop tracking
|
|
2610
|
+
* this key" idiom). Returns the resulting bag.
|
|
2611
|
+
*
|
|
2612
|
+
* Sanitised through `validateEventProperties` so a `{ avatar: File }`
|
|
2613
|
+
* payload can't poison the queue at flush time.
|
|
2614
|
+
*/
|
|
2615
|
+
register(properties) {
|
|
2616
|
+
const s = this.requireStarted();
|
|
2617
|
+
const validation = validateEventProperties(properties);
|
|
2618
|
+
return s.superProps.register(validation.properties);
|
|
2619
|
+
}
|
|
2620
|
+
/** Remove a single super-property key. Idempotent. */
|
|
2621
|
+
unregister(key) {
|
|
2622
|
+
const s = this.requireStarted();
|
|
2623
|
+
s.superProps.unregister(key);
|
|
2624
|
+
}
|
|
2625
|
+
/** Snapshot of the current super-property bag. */
|
|
2626
|
+
getSuperProperties() {
|
|
2627
|
+
if (!this.state) return {};
|
|
2628
|
+
return this.state.superProps.getSuperProperties();
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Associate the current user with a group (org, team, account, etc.).
|
|
2632
|
+
* Mixpanel / Segment "Group Analytics" pattern.
|
|
2633
|
+
*
|
|
2634
|
+
* Crossdeck.group("org", "acme_inc");
|
|
2635
|
+
* Crossdeck.group("team", "design", { headcount: 12 });
|
|
2636
|
+
*
|
|
2637
|
+
* Once set, every subsequent event carries `$groups.<type>: id` on
|
|
2638
|
+
* its properties bag, enabling B2B dashboards ("how is Acme using
|
|
2639
|
+
* the product"). Pass `id: null` to clear a group membership.
|
|
2640
|
+
*/
|
|
2641
|
+
group(type, id, traits) {
|
|
2642
|
+
const s = this.requireStarted();
|
|
2643
|
+
if (!type) {
|
|
2644
|
+
throw new CrossdeckError({
|
|
2645
|
+
type: "invalid_request_error",
|
|
2646
|
+
code: "missing_group_type",
|
|
2647
|
+
message: "group(type, id) requires a non-empty type."
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
|
|
2651
|
+
s.superProps.setGroup(type, id, sanitisedTraits);
|
|
2652
|
+
}
|
|
2653
|
+
/** Snapshot of the current groups map keyed by type. */
|
|
2654
|
+
getGroups() {
|
|
2655
|
+
if (!this.state) return {};
|
|
2656
|
+
return this.state.superProps.getGroups();
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Update consent state. Three independent dimensions:
|
|
2660
|
+
*
|
|
2661
|
+
* analytics — track() + identify() + auto-emissions
|
|
2662
|
+
* marketing — paid-traffic click IDs + referrer URL on events
|
|
2663
|
+
* errors — Web Vitals + (future) error reporting
|
|
2664
|
+
*
|
|
2665
|
+
* Each defaults to `true` (granted). Pass partial state — only the
|
|
2666
|
+
* keys you provide are changed.
|
|
2667
|
+
*
|
|
2668
|
+
* Crossdeck.consent({ analytics: false });
|
|
2669
|
+
* Crossdeck.consent({ marketing: true, errors: true });
|
|
2670
|
+
*
|
|
2671
|
+
* DNT-derived denies cannot be flipped back on; if the browser said
|
|
2672
|
+
* "don't track" we don't track even if the developer code disagrees.
|
|
2673
|
+
*/
|
|
2674
|
+
consent(state) {
|
|
2675
|
+
const s = this.requireStarted();
|
|
2676
|
+
const next = s.consent.set(state);
|
|
2677
|
+
s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
|
|
2678
|
+
return next;
|
|
2679
|
+
}
|
|
2680
|
+
/** Snapshot of the current consent state. */
|
|
2681
|
+
consentStatus() {
|
|
2682
|
+
if (!this.state) {
|
|
2683
|
+
return { analytics: true, marketing: true, errors: true };
|
|
2684
|
+
}
|
|
2685
|
+
return this.state.consent.get();
|
|
2686
|
+
}
|
|
2687
|
+
// ============================================================
|
|
2688
|
+
// Error capture surface (v1.0.0+)
|
|
2689
|
+
// ============================================================
|
|
2690
|
+
/**
|
|
2691
|
+
* Manually capture an error from a try/catch block.
|
|
2692
|
+
*
|
|
2693
|
+
* try { …risky… } catch (err) {
|
|
2694
|
+
* Crossdeck.captureError(err, { context: { plan: "pro" } });
|
|
2695
|
+
* }
|
|
2696
|
+
*
|
|
2697
|
+
* The error is shipped through the same event queue as analytics
|
|
2698
|
+
* (durable, retried, rate-limited per fingerprint). Sends are gated
|
|
2699
|
+
* by `consent.errors`. Returns silently — never throws, even if the
|
|
2700
|
+
* SDK isn't initialised yet.
|
|
2701
|
+
*/
|
|
2702
|
+
captureError(error, options) {
|
|
2703
|
+
if (!this.state?.errors) return;
|
|
2704
|
+
this.state.errors.captureError(error, options);
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Capture a non-error event you want to surface as an issue
|
|
2708
|
+
* ("deprecated path hit", "we entered the slow code path"). Sentry
|
|
2709
|
+
* captureMessage pattern. Returns silently if not initialised.
|
|
2710
|
+
*/
|
|
2711
|
+
captureMessage(message, level = "info") {
|
|
2712
|
+
if (!this.state?.errors) return;
|
|
2713
|
+
this.state.errors.captureMessage(message, level);
|
|
2714
|
+
}
|
|
2715
|
+
/**
|
|
2716
|
+
* Attach a tag to every subsequent error report. Tags are key/value
|
|
2717
|
+
* strings (Sentry pattern): `setTag("flow", "checkout")` → every
|
|
2718
|
+
* error from this point on carries `tags.flow === "checkout"`.
|
|
2719
|
+
*/
|
|
2720
|
+
setTag(key, value) {
|
|
2721
|
+
if (!this.state) return;
|
|
2722
|
+
this.state.errorTags[key] = value;
|
|
2723
|
+
}
|
|
2724
|
+
/** Bulk-set tags. Merges with existing tags. */
|
|
2725
|
+
setTags(tags) {
|
|
2726
|
+
if (!this.state) return;
|
|
2727
|
+
Object.assign(this.state.errorTags, tags);
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Attach a structured context blob to every subsequent error report.
|
|
2731
|
+
* Unlike tags (flat key/value), context is a named bag of arbitrary
|
|
2732
|
+
* data: `setContext("cart", { items: 3, total: 42.99 })`.
|
|
2733
|
+
*/
|
|
2734
|
+
setContext(name, data) {
|
|
2735
|
+
if (!this.state) return;
|
|
2736
|
+
this.state.errorContext[name] = data;
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Add a custom breadcrumb to the rolling buffer. Useful for marking
|
|
2740
|
+
* domain-meaningful moments ("user opened paywall") that aren't
|
|
2741
|
+
* already auto-captured. The buffer caps at 50 entries; old ones
|
|
2742
|
+
* evict.
|
|
2743
|
+
*/
|
|
2744
|
+
addBreadcrumb(crumb) {
|
|
2745
|
+
if (!this.state) return;
|
|
2746
|
+
this.state.breadcrumbs.add(crumb);
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* Install a pre-send hook for errors. Return null to drop, or a
|
|
2750
|
+
* modified CapturedError to scrub / rewrite. Sentry's beforeSend
|
|
2751
|
+
* pattern — the only way to redact app-specific PII (auth tokens
|
|
2752
|
+
* in URLs, etc.) before the report leaves the browser.
|
|
2753
|
+
*/
|
|
2754
|
+
setErrorBeforeSend(hook) {
|
|
2755
|
+
if (!this.state) return;
|
|
2756
|
+
this.state.errorBeforeSend = hook;
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* Internal: turn a CapturedError into a Crossdeck event and enqueue
|
|
2760
|
+
* it. Goes through the same queue / persistence / consent / scrub
|
|
2761
|
+
* pipeline as analytics events.
|
|
2762
|
+
*/
|
|
2763
|
+
reportError(err) {
|
|
2764
|
+
const properties = {
|
|
2765
|
+
// Identifiers
|
|
2766
|
+
fingerprint: err.fingerprint,
|
|
2767
|
+
level: err.level,
|
|
2768
|
+
// Error shape
|
|
2769
|
+
errorType: err.errorType,
|
|
2770
|
+
message: err.message,
|
|
2771
|
+
// Stack
|
|
2772
|
+
stack: err.rawStack ?? void 0,
|
|
2773
|
+
frames: err.frames,
|
|
2774
|
+
filename: err.filename ?? void 0,
|
|
2775
|
+
lineno: err.lineno ?? void 0,
|
|
2776
|
+
colno: err.colno ?? void 0,
|
|
2777
|
+
// Context
|
|
2778
|
+
tags: err.tags,
|
|
2779
|
+
context: err.context,
|
|
2780
|
+
breadcrumbs: err.breadcrumbs,
|
|
2781
|
+
// HTTP (only when applicable)
|
|
2782
|
+
http: err.http
|
|
2783
|
+
};
|
|
2784
|
+
for (const k of Object.keys(properties)) {
|
|
2785
|
+
if (properties[k] === void 0) delete properties[k];
|
|
2786
|
+
}
|
|
2787
|
+
this.track(err.kind, properties);
|
|
2788
|
+
}
|
|
2789
|
+
/**
|
|
2790
|
+
* GDPR/CCPA "right to be forgotten" — calls the backend's
|
|
2791
|
+
* /v1/identity/forget endpoint to schedule a server-side deletion of
|
|
2792
|
+
* the customer's events and profile, then wipes all local state
|
|
2793
|
+
* (identity, entitlements, queue, super-props, persistent stores).
|
|
2794
|
+
*
|
|
2795
|
+
* Idempotent. Safe to call when no identity has been established
|
|
2796
|
+
* (it just wipes the empty local state).
|
|
2797
|
+
*
|
|
2798
|
+
* After forget() resolves, the SDK is in the same shape as if the
|
|
2799
|
+
* developer had called `Crossdeck.reset()` — a fresh anonymousId is
|
|
2800
|
+
* minted and the next session is a brand new identity-graph entry.
|
|
2801
|
+
*/
|
|
2802
|
+
async forget() {
|
|
2803
|
+
const s = this.requireStarted();
|
|
2804
|
+
const identityQuery = this.identityQueryParams();
|
|
2805
|
+
try {
|
|
2806
|
+
await s.http.request("POST", "/identity/forget", {
|
|
2807
|
+
body: {
|
|
2808
|
+
// Send every identity hint we hold; the server resolves the
|
|
2809
|
+
// canonical customer record and queues deletion. Missing
|
|
2810
|
+
// endpoint (older backend) gracefully degrades — local state
|
|
2811
|
+
// still wipes via the reset() call below.
|
|
2812
|
+
...identityQuery
|
|
2813
|
+
}
|
|
2814
|
+
});
|
|
2815
|
+
} catch (err) {
|
|
2816
|
+
s.debug.emit(
|
|
2817
|
+
"sdk.consent_denied",
|
|
2818
|
+
`forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
|
|
2819
|
+
);
|
|
2820
|
+
}
|
|
2821
|
+
this.reset();
|
|
2822
|
+
}
|
|
1223
2823
|
/**
|
|
1224
2824
|
* Read the current customer's active entitlements from the server.
|
|
1225
2825
|
* Updates the local cache so subsequent isEntitled() calls answer
|
|
@@ -1297,6 +2897,18 @@ var CrossdeckClient = class {
|
|
|
1297
2897
|
message: "track(name) requires a non-empty name."
|
|
1298
2898
|
});
|
|
1299
2899
|
}
|
|
2900
|
+
const isError = name.startsWith("error.");
|
|
2901
|
+
const isWebVital = name.startsWith("webvitals.");
|
|
2902
|
+
const consentGateOk = isError || isWebVital ? s.consent.errors : s.consent.analytics;
|
|
2903
|
+
if (!consentGateOk) {
|
|
2904
|
+
if (s.debug.enabled) {
|
|
2905
|
+
s.debug.emit(
|
|
2906
|
+
"sdk.consent_denied",
|
|
2907
|
+
`Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
1300
2912
|
if (s.debug.enabled && properties) {
|
|
1301
2913
|
const flagged = findSensitivePropertyKeys(properties);
|
|
1302
2914
|
if (flagged.length > 0) {
|
|
@@ -1313,9 +2925,21 @@ var CrossdeckClient = class {
|
|
|
1313
2925
|
"Using anonymous user until identify(userId) is called."
|
|
1314
2926
|
);
|
|
1315
2927
|
}
|
|
2928
|
+
const validation = validateEventProperties(properties);
|
|
2929
|
+
if (s.debug.enabled && validation.warnings.length > 0) {
|
|
2930
|
+
for (const w of validation.warnings) {
|
|
2931
|
+
s.debug.emit(
|
|
2932
|
+
"sdk.property_coerced",
|
|
2933
|
+
`Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2934
|
+
{ eventName: name, key: w.key, kind: w.kind }
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
1316
2938
|
const enriched = { ...s.deviceInfo };
|
|
1317
2939
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
1318
2940
|
if (sessionId) enriched.sessionId = sessionId;
|
|
2941
|
+
const pageviewId = s.autoTracker?.currentPageviewId;
|
|
2942
|
+
if (pageviewId) enriched.pageviewId = pageviewId;
|
|
1319
2943
|
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1320
2944
|
if (acquisition) {
|
|
1321
2945
|
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
@@ -1323,17 +2947,46 @@ var CrossdeckClient = class {
|
|
|
1323
2947
|
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1324
2948
|
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1325
2949
|
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1326
|
-
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
2950
|
+
if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
|
|
2951
|
+
if (s.consent.marketing) {
|
|
2952
|
+
if (acquisition.gclid) enriched.gclid = acquisition.gclid;
|
|
2953
|
+
if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
|
|
2954
|
+
if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
|
|
2955
|
+
if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
|
|
2956
|
+
if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
|
|
2957
|
+
if (acquisition.twclid) enriched.twclid = acquisition.twclid;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
const supers = s.superProps.getSuperProperties();
|
|
2961
|
+
for (const k of Object.keys(supers)) {
|
|
2962
|
+
if (!(k in enriched)) enriched[k] = supers[k];
|
|
1327
2963
|
}
|
|
1328
|
-
|
|
2964
|
+
const groupIds = s.superProps.getGroupIds();
|
|
2965
|
+
if (Object.keys(groupIds).length > 0) {
|
|
2966
|
+
enriched.$groups = groupIds;
|
|
2967
|
+
}
|
|
2968
|
+
Object.assign(enriched, validation.properties);
|
|
2969
|
+
const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
|
|
1329
2970
|
const event = {
|
|
1330
2971
|
eventId: this.mintEventId(),
|
|
1331
2972
|
name,
|
|
1332
2973
|
timestamp: Date.now(),
|
|
1333
|
-
properties:
|
|
2974
|
+
properties: finalProperties
|
|
1334
2975
|
};
|
|
1335
2976
|
Object.assign(event, this.identityHintForEvent());
|
|
1336
2977
|
s.events.enqueue(event);
|
|
2978
|
+
if (!isError && !isWebVital) {
|
|
2979
|
+
const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
|
|
2980
|
+
s.breadcrumbs.add({
|
|
2981
|
+
timestamp: event.timestamp,
|
|
2982
|
+
category,
|
|
2983
|
+
message: name,
|
|
2984
|
+
// Strip the device-info / session bloat from the breadcrumb
|
|
2985
|
+
// payload — only the caller-supplied properties belong in
|
|
2986
|
+
// the user-readable trail.
|
|
2987
|
+
data: properties ? { ...properties } : void 0
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
1337
2990
|
}
|
|
1338
2991
|
/**
|
|
1339
2992
|
* Force-flush queued events. Useful to call from page-unload handlers.
|
|
@@ -1408,7 +3061,12 @@ var CrossdeckClient = class {
|
|
|
1408
3061
|
*/
|
|
1409
3062
|
async heartbeat() {
|
|
1410
3063
|
const s = this.requireStarted();
|
|
1411
|
-
|
|
3064
|
+
const result = await s.http.request("GET", "/sdk/heartbeat");
|
|
3065
|
+
if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
|
|
3066
|
+
s.lastServerTime = result.serverTime;
|
|
3067
|
+
s.lastClientTime = Date.now();
|
|
3068
|
+
}
|
|
3069
|
+
return result;
|
|
1412
3070
|
}
|
|
1413
3071
|
/**
|
|
1414
3072
|
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
@@ -1427,6 +3085,10 @@ var CrossdeckClient = class {
|
|
|
1427
3085
|
this.state.identity.reset();
|
|
1428
3086
|
this.state.entitlements.clear();
|
|
1429
3087
|
this.state.events.reset();
|
|
3088
|
+
this.state.superProps.clear();
|
|
3089
|
+
this.state.breadcrumbs.clear();
|
|
3090
|
+
this.state.errorContext = {};
|
|
3091
|
+
this.state.errorTags = {};
|
|
1430
3092
|
this.state.developerUserId = null;
|
|
1431
3093
|
if (this.state.autoTracker) {
|
|
1432
3094
|
const tracker = new AutoTracker(
|
|
@@ -1454,17 +3116,21 @@ var CrossdeckClient = class {
|
|
|
1454
3116
|
developerUserId: null,
|
|
1455
3117
|
sdkVersion: null,
|
|
1456
3118
|
baseUrl: null,
|
|
1457
|
-
|
|
3119
|
+
clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
|
|
3120
|
+
entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
|
|
1458
3121
|
events: {
|
|
1459
3122
|
buffered: 0,
|
|
1460
3123
|
dropped: 0,
|
|
1461
3124
|
inFlight: 0,
|
|
1462
3125
|
lastFlushAt: 0,
|
|
1463
|
-
lastError: null
|
|
3126
|
+
lastError: null,
|
|
3127
|
+
consecutiveFailures: 0,
|
|
3128
|
+
nextRetryAt: null
|
|
1464
3129
|
}
|
|
1465
3130
|
};
|
|
1466
3131
|
}
|
|
1467
3132
|
const s = this.state;
|
|
3133
|
+
const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
|
|
1468
3134
|
return {
|
|
1469
3135
|
started: true,
|
|
1470
3136
|
anonymousId: s.identity.anonymousId,
|
|
@@ -1472,9 +3138,15 @@ var CrossdeckClient = class {
|
|
|
1472
3138
|
developerUserId: s.developerUserId,
|
|
1473
3139
|
sdkVersion: s.options.sdkVersion,
|
|
1474
3140
|
baseUrl: s.options.baseUrl,
|
|
3141
|
+
clock: {
|
|
3142
|
+
lastServerTime: s.lastServerTime,
|
|
3143
|
+
lastClientTime: s.lastClientTime,
|
|
3144
|
+
skewMs
|
|
3145
|
+
},
|
|
1475
3146
|
entitlements: {
|
|
1476
3147
|
count: s.entitlements.list().length,
|
|
1477
|
-
lastUpdated: s.entitlements.freshness
|
|
3148
|
+
lastUpdated: s.entitlements.freshness,
|
|
3149
|
+
listenerErrors: s.entitlements.listenerErrors
|
|
1478
3150
|
},
|
|
1479
3151
|
events: s.events.getStats()
|
|
1480
3152
|
};
|
|
@@ -1541,6 +3213,7 @@ function inferEnvFromKey(publicKey) {
|
|
|
1541
3213
|
}
|
|
1542
3214
|
function isLocalHostname() {
|
|
1543
3215
|
const w = globalThis.window;
|
|
3216
|
+
if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
|
|
1544
3217
|
const hostname = w?.location?.hostname;
|
|
1545
3218
|
if (!hostname) return false;
|
|
1546
3219
|
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
@@ -1553,7 +3226,14 @@ function isLocalHostname() {
|
|
|
1553
3226
|
}
|
|
1554
3227
|
function resolveAutoTrack(input) {
|
|
1555
3228
|
if (input === false) {
|
|
1556
|
-
return {
|
|
3229
|
+
return {
|
|
3230
|
+
sessions: false,
|
|
3231
|
+
pageViews: false,
|
|
3232
|
+
deviceInfo: false,
|
|
3233
|
+
clicks: false,
|
|
3234
|
+
webVitals: false,
|
|
3235
|
+
errors: false
|
|
3236
|
+
};
|
|
1557
3237
|
}
|
|
1558
3238
|
if (input === void 0 || input === true) {
|
|
1559
3239
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1562,7 +3242,9 @@ function resolveAutoTrack(input) {
|
|
|
1562
3242
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1563
3243
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1564
3244
|
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1565
|
-
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
3245
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
|
|
3246
|
+
webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals,
|
|
3247
|
+
errors: input.errors ?? DEFAULT_AUTO_TRACK.errors
|
|
1566
3248
|
};
|
|
1567
3249
|
}
|
|
1568
3250
|
function installUnloadFlush(onUnload) {
|
|
@@ -1582,13 +3264,109 @@ function installUnloadFlush(onUnload) {
|
|
|
1582
3264
|
w.removeEventListener("beforeunload", onTerminal);
|
|
1583
3265
|
};
|
|
1584
3266
|
}
|
|
3267
|
+
|
|
3268
|
+
// src/error-codes.ts
|
|
3269
|
+
var CROSSDECK_ERROR_CODES = Object.freeze([
|
|
3270
|
+
// ----- Configuration -----
|
|
3271
|
+
{
|
|
3272
|
+
code: "invalid_public_key",
|
|
3273
|
+
type: "configuration_error",
|
|
3274
|
+
description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
|
|
3275
|
+
resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
|
|
3276
|
+
retryable: false
|
|
3277
|
+
},
|
|
3278
|
+
{
|
|
3279
|
+
code: "missing_app_id",
|
|
3280
|
+
type: "configuration_error",
|
|
3281
|
+
description: "Crossdeck.init() was called without an appId.",
|
|
3282
|
+
resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
|
|
3283
|
+
retryable: false
|
|
3284
|
+
},
|
|
3285
|
+
{
|
|
3286
|
+
code: "invalid_environment",
|
|
3287
|
+
type: "configuration_error",
|
|
3288
|
+
description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
|
|
3289
|
+
resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
|
|
3290
|
+
retryable: false
|
|
3291
|
+
},
|
|
3292
|
+
{
|
|
3293
|
+
code: "environment_mismatch",
|
|
3294
|
+
type: "configuration_error",
|
|
3295
|
+
description: "The publishable key's env prefix doesn't match the declared environment option.",
|
|
3296
|
+
resolution: "Either change `environment` to match the key prefix (cd_pub_test_ \u2194 sandbox, cd_pub_live_ \u2194 production), or swap the key for one minted in the right env.",
|
|
3297
|
+
retryable: false
|
|
3298
|
+
},
|
|
3299
|
+
{
|
|
3300
|
+
code: "not_initialized",
|
|
3301
|
+
type: "configuration_error",
|
|
3302
|
+
description: "An SDK method was called before Crossdeck.init().",
|
|
3303
|
+
resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
|
|
3304
|
+
retryable: false
|
|
3305
|
+
},
|
|
3306
|
+
// ----- Identify / track / purchase argument validation -----
|
|
3307
|
+
{
|
|
3308
|
+
code: "missing_user_id",
|
|
3309
|
+
type: "invalid_request_error",
|
|
3310
|
+
description: "identify() was called with an empty userId.",
|
|
3311
|
+
resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
|
|
3312
|
+
retryable: false
|
|
3313
|
+
},
|
|
3314
|
+
{
|
|
3315
|
+
code: "missing_event_name",
|
|
3316
|
+
type: "invalid_request_error",
|
|
3317
|
+
description: "track() was called without an event name.",
|
|
3318
|
+
resolution: "Pass a non-empty string as the first argument.",
|
|
3319
|
+
retryable: false
|
|
3320
|
+
},
|
|
3321
|
+
{
|
|
3322
|
+
code: "missing_group_type",
|
|
3323
|
+
type: "invalid_request_error",
|
|
3324
|
+
description: "group() was called without a group type.",
|
|
3325
|
+
resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
|
|
3326
|
+
retryable: false
|
|
3327
|
+
},
|
|
3328
|
+
{
|
|
3329
|
+
code: "missing_signed_transaction_info",
|
|
3330
|
+
type: "invalid_request_error",
|
|
3331
|
+
description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
|
|
3332
|
+
resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
|
|
3333
|
+
retryable: false
|
|
3334
|
+
},
|
|
3335
|
+
// ----- Network / transport -----
|
|
3336
|
+
{
|
|
3337
|
+
code: "fetch_failed",
|
|
3338
|
+
type: "network_error",
|
|
3339
|
+
description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
|
|
3340
|
+
resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
|
|
3341
|
+
retryable: true
|
|
3342
|
+
},
|
|
3343
|
+
{
|
|
3344
|
+
code: "request_timeout",
|
|
3345
|
+
type: "network_error",
|
|
3346
|
+
description: "A request was aborted after the configured timeoutMs (default 15s).",
|
|
3347
|
+
resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
|
|
3348
|
+
retryable: true
|
|
3349
|
+
},
|
|
3350
|
+
{
|
|
3351
|
+
code: "invalid_json_response",
|
|
3352
|
+
type: "internal_error",
|
|
3353
|
+
description: "The server returned a 2xx with an unparseable body.",
|
|
3354
|
+
resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
|
|
3355
|
+
retryable: true
|
|
3356
|
+
}
|
|
3357
|
+
]);
|
|
3358
|
+
function getErrorCode(code) {
|
|
3359
|
+
return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
|
|
3360
|
+
}
|
|
1585
3361
|
export {
|
|
3362
|
+
CROSSDECK_ERROR_CODES,
|
|
1586
3363
|
Crossdeck,
|
|
1587
3364
|
CrossdeckClient,
|
|
1588
3365
|
CrossdeckError,
|
|
1589
3366
|
DEFAULT_BASE_URL,
|
|
1590
3367
|
MemoryStorage,
|
|
1591
3368
|
SDK_NAME,
|
|
1592
|
-
SDK_VERSION
|
|
3369
|
+
SDK_VERSION,
|
|
3370
|
+
getErrorCode
|
|
1593
3371
|
};
|
|
1594
3372
|
//# sourceMappingURL=index.mjs.map
|