@datalyr/web 1.7.2 → 1.7.4
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/dist/attribution.d.ts +7 -0
- package/dist/attribution.d.ts.map +1 -1
- package/dist/auto-identify.d.ts +8 -0
- package/dist/auto-identify.d.ts.map +1 -1
- package/dist/container.d.ts +7 -0
- package/dist/container.d.ts.map +1 -1
- package/dist/datalyr.cjs.js +815 -225
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +815 -225
- package/dist/datalyr.esm.js.map +1 -1
- package/dist/datalyr.esm.min.js +1 -1
- package/dist/datalyr.esm.min.js.map +1 -1
- package/dist/datalyr.js +815 -225
- package/dist/datalyr.js.map +1 -1
- package/dist/datalyr.min.js +1 -1
- package/dist/datalyr.min.js.map +1 -1
- package/dist/identity.d.ts +22 -1
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.test.d.ts +6 -0
- package/dist/identity.test.d.ts.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/queue.d.ts +36 -15
- package/dist/queue.d.ts.map +1 -1
- package/dist/storage.d.ts +11 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.test.d.ts +6 -0
- package/dist/storage.test.d.ts.map +1 -0
- package/dist/stripe-links.test.d.ts +2 -0
- package/dist/stripe-links.test.d.ts.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +10 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.test.d.ts +5 -0
- package/dist/utils.test.d.ts.map +1 -0
- package/package.json +2 -1
package/dist/datalyr.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @datalyr/web v1.7.
|
|
2
|
+
* @datalyr/web v1.7.4
|
|
3
3
|
* Datalyr Web SDK - Modern attribution tracking for web applications
|
|
4
4
|
* (c) 2026 Datalyr Inc.
|
|
5
5
|
* Released under the MIT License
|
|
@@ -294,6 +294,42 @@ var Datalyr = (function (exports) {
|
|
|
294
294
|
return defaultValue;
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Get a value as a RAW string, skipping the JSON.parse that get() applies.
|
|
299
|
+
*
|
|
300
|
+
* FSR-17: get() blindly JSON.parses, so a stored user_id like '12345' comes back as
|
|
301
|
+
* the NUMBER 12345 after reload, and a 16+-digit snowflake id ('1234567890123456789')
|
|
302
|
+
* is silently precision-corrupted (→ 1234567890123456800) — permanent identity
|
|
303
|
+
* fragmentation. Identifiers (dl_user_id, dl_anonymous_id, dl_auto_identified_email)
|
|
304
|
+
* must round-trip as the exact string they were stored as; use this for them.
|
|
305
|
+
*/
|
|
306
|
+
getString(key, defaultValue = null) {
|
|
307
|
+
const fullKey = this.prefix + key;
|
|
308
|
+
try {
|
|
309
|
+
const value = this.storage
|
|
310
|
+
? this.storage.getItem(fullKey)
|
|
311
|
+
: (this.memory.has(fullKey) ? this.memory.get(fullKey) : null);
|
|
312
|
+
if (value === null || value === undefined)
|
|
313
|
+
return defaultValue;
|
|
314
|
+
// Values written by set() with a string are stored raw (unquoted); return as-is.
|
|
315
|
+
// A value written as JSON (quoted string) is unwrapped so callers always get the
|
|
316
|
+
// logical string, never a quoted one.
|
|
317
|
+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
|
|
318
|
+
try {
|
|
319
|
+
const parsed = JSON.parse(value);
|
|
320
|
+
if (typeof parsed === 'string')
|
|
321
|
+
return parsed;
|
|
322
|
+
}
|
|
323
|
+
catch (_a) {
|
|
324
|
+
// fall through to raw
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
catch (_b) {
|
|
330
|
+
return defaultValue;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
297
333
|
set(key, value) {
|
|
298
334
|
const fullKey = this.prefix + key;
|
|
299
335
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
@@ -474,19 +510,32 @@ var Datalyr = (function (exports) {
|
|
|
474
510
|
this.secure = options.secure || 'auto';
|
|
475
511
|
}
|
|
476
512
|
get(name) {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
513
|
+
// FSR-56: parse document.cookie into name=value pairs and return the FIRST match.
|
|
514
|
+
// The old `split('; ' + name + '=')` returned null whenever the same cookie name
|
|
515
|
+
// appeared more than once (3+ parts) — extremely common for ad cookies set at both
|
|
516
|
+
// the registrable domain and a subdomain (_fbp/_fbc/_ga), and producible by this SDK
|
|
517
|
+
// itself (the __dl_visitor_id host-only fallback). That caused real _fbp/_fbc to be
|
|
518
|
+
// ignored and re-synthesized, and the CC bridge cookie read to silently fail.
|
|
519
|
+
// Browsers list the most specific (longer path / host) cookie first, so first wins.
|
|
520
|
+
const cookieString = typeof document !== 'undefined' ? document.cookie : '';
|
|
521
|
+
if (!cookieString)
|
|
522
|
+
return null;
|
|
523
|
+
for (const pair of cookieString.split(';')) {
|
|
524
|
+
const trimmed = pair.trim();
|
|
525
|
+
const eq = trimmed.indexOf('=');
|
|
526
|
+
if (eq === -1)
|
|
527
|
+
continue;
|
|
528
|
+
if (trimmed.slice(0, eq) !== name)
|
|
529
|
+
continue;
|
|
530
|
+
const rawValue = trimmed.slice(eq + 1);
|
|
531
|
+
if (!rawValue)
|
|
532
|
+
return null;
|
|
533
|
+
try {
|
|
534
|
+
return decodeURIComponent(rawValue);
|
|
535
|
+
}
|
|
536
|
+
catch (_a) {
|
|
537
|
+
// Return raw value if decoding fails (backwards compatibility)
|
|
538
|
+
return rawValue;
|
|
490
539
|
}
|
|
491
540
|
}
|
|
492
541
|
return null;
|
|
@@ -663,6 +712,35 @@ var Datalyr = (function (exports) {
|
|
|
663
712
|
}
|
|
664
713
|
return params;
|
|
665
714
|
}
|
|
715
|
+
// FSR-57: credential detection that matches whole TOKENS (after splitting a key on
|
|
716
|
+
// camelCase + separators), not substrings. The old substring regex deleted innocent
|
|
717
|
+
// keys — `author`/`authority` (contains 'auth'), `passenger_count`/`passport_number`
|
|
718
|
+
// (contains 'pass'), `session_type`/`cookie_consent` — silently dropping customer event
|
|
719
|
+
// properties. 'session' and 'cookie' are intentionally absent: they're not credentials.
|
|
720
|
+
// Single words that are unambiguous as whole tokens:
|
|
721
|
+
const SENSITIVE_KEY_WORDS = new Set([
|
|
722
|
+
'pass', 'password', 'passwd', 'pwd',
|
|
723
|
+
'secret', 'token', 'auth', 'authorization',
|
|
724
|
+
'bearer', 'signature', 'cvv', 'cvc', 'ssn'
|
|
725
|
+
]);
|
|
726
|
+
// Compound credentials that tokenize into innocent-looking parts (api_key → api+key)
|
|
727
|
+
// — matched against the separator-stripped key so api_key / apiKey / access_token all hit.
|
|
728
|
+
const SENSITIVE_KEY_COMPOUNDS = [
|
|
729
|
+
'apikey', 'accesskey', 'secretkey', 'privatekey', 'publickey',
|
|
730
|
+
'accesstoken', 'refreshtoken', 'idtoken', 'clientsecret',
|
|
731
|
+
'creditcard', 'cardnumber'
|
|
732
|
+
];
|
|
733
|
+
function isSensitiveKey(key) {
|
|
734
|
+
const words = key
|
|
735
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
736
|
+
.split(/[\s._\-]+/)
|
|
737
|
+
.map(w => w.toLowerCase())
|
|
738
|
+
.filter(Boolean);
|
|
739
|
+
if (words.some(w => SENSITIVE_KEY_WORDS.has(w)))
|
|
740
|
+
return true;
|
|
741
|
+
const normalized = key.toLowerCase().replace(/[\s._\-]+/g, '');
|
|
742
|
+
return SENSITIVE_KEY_COMPOUNDS.some(c => normalized.includes(c));
|
|
743
|
+
}
|
|
666
744
|
/**
|
|
667
745
|
* Sanitize event data (remove sensitive keys, DOM elements, functions)
|
|
668
746
|
*/
|
|
@@ -684,11 +762,10 @@ var Datalyr = (function (exports) {
|
|
|
684
762
|
// Handle objects
|
|
685
763
|
if (typeof data === 'object') {
|
|
686
764
|
const sanitized = {};
|
|
687
|
-
const sensitiveKeys = /pass|pwd|token|secret|auth|bearer|session|cookie|signature|api[-_]?key|private[-_]?key|access[-_]?token|refresh[-_]?token/i;
|
|
688
765
|
for (const key in data) {
|
|
689
766
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
690
|
-
// Skip sensitive keys
|
|
691
|
-
if (
|
|
767
|
+
// Skip sensitive keys (whole-token match — see SENSITIVE_KEY_WORDS)
|
|
768
|
+
if (isSensitiveKey(key)) {
|
|
692
769
|
continue;
|
|
693
770
|
}
|
|
694
771
|
// Sanitize value recursively
|
|
@@ -703,9 +780,14 @@ var Datalyr = (function (exports) {
|
|
|
703
780
|
if (data.length > 1000) {
|
|
704
781
|
return data.slice(0, 1000) + '...[truncated]';
|
|
705
782
|
}
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
783
|
+
// FSR-57: redact only UNAMBIGUOUS JWTs — base64url '{"...' header always starts with
|
|
784
|
+
// 'eyJ'. The old rules redacted ANY 3-dot-segment string ('www.google.com' → so every
|
|
785
|
+
// 3-label referrer_host became '[Redacted]'; '1.7.1' semver too) and ANY 32+-char hex
|
|
786
|
+
// string (md5/order IDs, pre-hashed emails, and — critically — 32-hex customer user
|
|
787
|
+
// IDs on the $alias path, which silently wrote user_id='[Redacted]' into
|
|
788
|
+
// visitor_user_links, collapsing those users). Credential-bearing KEYS are already
|
|
789
|
+
// dropped above, so the bare-hex value heuristic was net-negative and is removed.
|
|
790
|
+
if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(data)) { // JWT
|
|
709
791
|
return '[Redacted]';
|
|
710
792
|
}
|
|
711
793
|
return data;
|
|
@@ -759,60 +841,79 @@ var Datalyr = (function (exports) {
|
|
|
759
841
|
return navigator.globalPrivacyControl === true ||
|
|
760
842
|
window.globalPrivacyControl === true;
|
|
761
843
|
}
|
|
844
|
+
// Common two-part TLDs (country-specific registries) where the registrable domain is
|
|
845
|
+
// the last THREE labels (e.g. shop.example.co.uk → example.co.uk). Not a full public
|
|
846
|
+
// suffix list, but covers the markets we see; CookieStorage.getAutoDomain() probes
|
|
847
|
+
// dynamically for the rest.
|
|
848
|
+
const TWO_PART_TLDS = new Set([
|
|
849
|
+
// United Kingdom
|
|
850
|
+
'co.uk', 'org.uk', 'net.uk', 'ac.uk', 'gov.uk', 'me.uk',
|
|
851
|
+
// Australia
|
|
852
|
+
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au',
|
|
853
|
+
// New Zealand
|
|
854
|
+
'co.nz', 'net.nz', 'org.nz', 'govt.nz',
|
|
855
|
+
// Japan
|
|
856
|
+
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp',
|
|
857
|
+
// India
|
|
858
|
+
'co.in', 'net.in', 'org.in', 'gov.in', 'ac.in',
|
|
859
|
+
// South Africa
|
|
860
|
+
'co.za', 'net.za', 'org.za', 'gov.za',
|
|
861
|
+
// Brazil
|
|
862
|
+
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br',
|
|
863
|
+
// South Korea
|
|
864
|
+
'co.kr', 'ne.kr', 'or.kr', 'go.kr', 'ac.kr',
|
|
865
|
+
// China
|
|
866
|
+
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
|
|
867
|
+
// Other Asia-Pacific
|
|
868
|
+
'co.id', 'co.th', 'com.sg', 'com.my', 'com.ph', 'com.vn',
|
|
869
|
+
'com.tw', 'com.hk',
|
|
870
|
+
// Latin America
|
|
871
|
+
'com.mx', 'com.ar', 'com.co', 'com.pe', 'com.cl',
|
|
872
|
+
// Europe
|
|
873
|
+
'co.il', 'co.at', 'co.hu', 'co.pl'
|
|
874
|
+
]);
|
|
762
875
|
/**
|
|
763
|
-
*
|
|
876
|
+
* Registrable domain (eTLD+1) for a hostname, honoring two-part ccTLDs.
|
|
877
|
+
* shop.example.co.uk → example.co.uk
|
|
878
|
+
* www.example.com → example.com
|
|
879
|
+
* localhost / IPs → returned unchanged
|
|
880
|
+
* Returns a bare host (no leading dot) — getRootDomain() adds the cookie-domain dot.
|
|
881
|
+
* (FSR-47: same-site referrer classification must use this, not naive last-two-labels.)
|
|
882
|
+
*/
|
|
883
|
+
function getRegistrableDomain(hostname) {
|
|
884
|
+
if (!hostname)
|
|
885
|
+
return hostname;
|
|
886
|
+
const lower = hostname.toLowerCase();
|
|
887
|
+
// Handle localhost and IP addresses
|
|
888
|
+
if (lower === 'localhost' ||
|
|
889
|
+
/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(lower) || // IPv4
|
|
890
|
+
/^\[?[0-9a-fA-F:]+\]?$/.test(lower)) { // IPv6
|
|
891
|
+
return hostname;
|
|
892
|
+
}
|
|
893
|
+
const parts = lower.split('.');
|
|
894
|
+
if (parts.length < 2)
|
|
895
|
+
return hostname;
|
|
896
|
+
const lastTwo = parts.slice(-2).join('.');
|
|
897
|
+
if (TWO_PART_TLDS.has(lastTwo) && parts.length >= 3) {
|
|
898
|
+
return parts.slice(-3).join('.');
|
|
899
|
+
}
|
|
900
|
+
return parts.slice(-2).join('.');
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Get root domain for cross-subdomain tracking (cookie-domain form, with leading dot).
|
|
764
904
|
*/
|
|
765
905
|
function getRootDomain() {
|
|
766
906
|
const hostname = window.location.hostname;
|
|
767
|
-
// Handle localhost and IP addresses
|
|
907
|
+
// Handle localhost and IP addresses — never set a cookie domain for these.
|
|
768
908
|
if (hostname === 'localhost' ||
|
|
769
909
|
hostname.match(/^[0-9]{1,3}\./) || // IPv4
|
|
770
910
|
hostname.match(/^\[?[0-9a-fA-F:]+\]?$/)) { // IPv6
|
|
771
911
|
return hostname;
|
|
772
912
|
}
|
|
773
|
-
// Get root domain (last two parts: example.com)
|
|
774
913
|
const parts = hostname.split('.');
|
|
775
|
-
if (parts.length
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
const sld = parts[parts.length - 2];
|
|
779
|
-
// Common two-part TLDs (country-specific domains)
|
|
780
|
-
// Note: The CookieStorage.getAutoDomain() probe method handles unknown TLDs dynamically,
|
|
781
|
-
// but this list provides faster resolution for known patterns
|
|
782
|
-
const twoPartTlds = [
|
|
783
|
-
// United Kingdom
|
|
784
|
-
'co.uk', 'org.uk', 'net.uk', 'ac.uk', 'gov.uk', 'me.uk',
|
|
785
|
-
// Australia
|
|
786
|
-
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au',
|
|
787
|
-
// New Zealand
|
|
788
|
-
'co.nz', 'net.nz', 'org.nz', 'govt.nz',
|
|
789
|
-
// Japan
|
|
790
|
-
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp',
|
|
791
|
-
// India
|
|
792
|
-
'co.in', 'net.in', 'org.in', 'gov.in', 'ac.in',
|
|
793
|
-
// South Africa
|
|
794
|
-
'co.za', 'net.za', 'org.za', 'gov.za',
|
|
795
|
-
// Brazil
|
|
796
|
-
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br',
|
|
797
|
-
// South Korea
|
|
798
|
-
'co.kr', 'ne.kr', 'or.kr', 'go.kr', 'ac.kr',
|
|
799
|
-
// China
|
|
800
|
-
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
|
|
801
|
-
// Other Asia-Pacific
|
|
802
|
-
'co.id', 'co.th', 'com.sg', 'com.my', 'com.ph', 'com.vn',
|
|
803
|
-
'com.tw', 'com.hk',
|
|
804
|
-
// Latin America
|
|
805
|
-
'com.mx', 'com.ar', 'com.co', 'com.pe', 'com.cl',
|
|
806
|
-
// Europe
|
|
807
|
-
'co.il', 'co.at', 'co.hu', 'co.pl'
|
|
808
|
-
];
|
|
809
|
-
const lastTwo = `${sld}.${tld}`;
|
|
810
|
-
if (twoPartTlds.includes(lastTwo) && parts.length >= 3) {
|
|
811
|
-
return '.' + parts.slice(-3).join('.');
|
|
812
|
-
}
|
|
813
|
-
return '.' + parts.slice(-2).join('.');
|
|
814
|
-
}
|
|
815
|
-
return hostname;
|
|
914
|
+
if (parts.length < 2)
|
|
915
|
+
return hostname;
|
|
916
|
+
return '.' + getRegistrableDomain(hostname);
|
|
816
917
|
}
|
|
817
918
|
/**
|
|
818
919
|
* Get referrer data
|
|
@@ -867,9 +968,10 @@ var Datalyr = (function (exports) {
|
|
|
867
968
|
* Handles anonymous_id, user_id, and identity resolution
|
|
868
969
|
*/
|
|
869
970
|
class IdentityManager {
|
|
870
|
-
constructor() {
|
|
971
|
+
constructor(options = {}) {
|
|
871
972
|
this.userId = null;
|
|
872
973
|
this.sessionId = null;
|
|
974
|
+
this.persistNewId = options.persistNewId !== false;
|
|
873
975
|
this.anonymousId = this.getOrCreateAnonymousId();
|
|
874
976
|
this.userId = this.getStoredUserId();
|
|
875
977
|
}
|
|
@@ -877,43 +979,84 @@ var Datalyr = (function (exports) {
|
|
|
877
979
|
* Get or create anonymous ID (device/browser identifier)
|
|
878
980
|
*/
|
|
879
981
|
getOrCreateAnonymousId() {
|
|
880
|
-
//
|
|
881
|
-
//
|
|
982
|
+
// 1. An EXISTING identity always wins — check the root-domain cookie (works across
|
|
983
|
+
// subdomains) then localStorage. FSR-50: a persisted visitor must NEVER be
|
|
984
|
+
// silently overwritten by a ?_dl_vid in the URL, or shared links would merge
|
|
985
|
+
// unrelated visitors (identity takeover). The CC bridge (restoreFromURL) writes
|
|
986
|
+
// this cookie BEFORE us, so the storefront visitor_id still wins there.
|
|
987
|
+
let anonymousId = cookies.get('__dl_visitor_id');
|
|
988
|
+
if (anonymousId) {
|
|
989
|
+
storage.set('dl_anonymous_id', anonymousId); // sync to localStorage
|
|
990
|
+
return anonymousId;
|
|
991
|
+
}
|
|
992
|
+
anonymousId = storage.getString('dl_anonymous_id'); // FSR-17: raw string, no JSON.parse
|
|
993
|
+
if (anonymousId) {
|
|
994
|
+
this.setRootDomainCookie('__dl_visitor_id', anonymousId);
|
|
995
|
+
return anonymousId;
|
|
996
|
+
}
|
|
997
|
+
// 2. Fresh visitor (no persisted id): accept a cross-domain bridge id from the URL,
|
|
998
|
+
// but ONLY a well-formed anon_<uuid> — reject arbitrary / oversized values (a
|
|
999
|
+
// multi-KB or attacker-crafted _dl_vid was previously persisted verbatim). Strip
|
|
1000
|
+
// it from the address bar so a re-shared URL can't merge the next visitor. (FSR-50)
|
|
882
1001
|
try {
|
|
883
1002
|
const urlParams = new URLSearchParams(window.location.search);
|
|
884
1003
|
const urlVisitorId = urlParams.get('_dl_vid');
|
|
885
|
-
if (urlVisitorId &&
|
|
886
|
-
|
|
887
|
-
this.
|
|
888
|
-
storage.set('dl_anonymous_id', urlVisitorId);
|
|
1004
|
+
if (urlVisitorId && this.isValidAnonymousId(urlVisitorId)) {
|
|
1005
|
+
this.stripUrlParam('_dl_vid');
|
|
1006
|
+
this.persistAnonymousId(urlVisitorId);
|
|
889
1007
|
return urlVisitorId;
|
|
890
1008
|
}
|
|
891
1009
|
}
|
|
892
1010
|
catch (e) {
|
|
893
|
-
// URL parsing failed - continue
|
|
1011
|
+
// URL parsing failed - continue to generate a fresh id
|
|
894
1012
|
console.warn('[Datalyr] Failed to parse URL for _dl_vid:', e);
|
|
895
1013
|
}
|
|
896
|
-
//
|
|
897
|
-
let anonymousId = cookies.get('__dl_visitor_id');
|
|
898
|
-
if (anonymousId) {
|
|
899
|
-
// Found in cookie - sync to localStorage
|
|
900
|
-
storage.set('dl_anonymous_id', anonymousId);
|
|
901
|
-
return anonymousId;
|
|
902
|
-
}
|
|
903
|
-
// 2. Check localStorage (fallback for cookie issues)
|
|
904
|
-
anonymousId = storage.get('dl_anonymous_id');
|
|
905
|
-
if (anonymousId) {
|
|
906
|
-
// Found in localStorage - set root domain cookie
|
|
907
|
-
this.setRootDomainCookie('__dl_visitor_id', anonymousId);
|
|
908
|
-
return anonymousId;
|
|
909
|
-
}
|
|
910
|
-
// 3. Generate new ID
|
|
1014
|
+
// 3. Generate a new ID (persisted unless tracking is disallowed at init — FSR-107).
|
|
911
1015
|
anonymousId = `anon_${generateUUID()}`;
|
|
912
|
-
|
|
913
|
-
this.setRootDomainCookie('__dl_visitor_id', anonymousId);
|
|
914
|
-
storage.set('dl_anonymous_id', anonymousId);
|
|
1016
|
+
this.persistAnonymousId(anonymousId);
|
|
915
1017
|
return anonymousId;
|
|
916
1018
|
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Persist a freshly-adopted/generated anonymous id to the root-domain cookie +
|
|
1021
|
+
* localStorage. Gated by persistNewId (FSR-107: don't write a tracking identifier for
|
|
1022
|
+
* an opted-out / GPC / DNT visitor at init).
|
|
1023
|
+
*/
|
|
1024
|
+
persistAnonymousId(id) {
|
|
1025
|
+
if (!this.persistNewId)
|
|
1026
|
+
return;
|
|
1027
|
+
this.setRootDomainCookie('__dl_visitor_id', id);
|
|
1028
|
+
storage.set('dl_anonymous_id', id);
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Whether a `_dl_vid` value is a well-formed Datalyr anonymous id (anon_ + UUID).
|
|
1032
|
+
* The length is bounded by the pattern, so an oversized/garbage value is rejected.
|
|
1033
|
+
* (FSR-50)
|
|
1034
|
+
*/
|
|
1035
|
+
isValidAnonymousId(id) {
|
|
1036
|
+
return /^anon_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Remove a query param from the current URL via history.replaceState (best-effort).
|
|
1040
|
+
* Used so a consumed `_dl_vid` doesn't linger in the address bar and get re-shared.
|
|
1041
|
+
* (FSR-50)
|
|
1042
|
+
*/
|
|
1043
|
+
stripUrlParam(param) {
|
|
1044
|
+
var _a;
|
|
1045
|
+
try {
|
|
1046
|
+
if (typeof window === 'undefined' || typeof ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState) !== 'function')
|
|
1047
|
+
return;
|
|
1048
|
+
const url = new URL(window.location.href);
|
|
1049
|
+
if (!url.searchParams.has(param))
|
|
1050
|
+
return;
|
|
1051
|
+
url.searchParams.delete(param);
|
|
1052
|
+
const search = url.searchParams.toString();
|
|
1053
|
+
const newUrl = url.pathname + (search ? '?' + search : '') + url.hash;
|
|
1054
|
+
window.history.replaceState(window.history.state, '', newUrl);
|
|
1055
|
+
}
|
|
1056
|
+
catch (_b) {
|
|
1057
|
+
// best-effort — never block init on URL rewrite
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
917
1060
|
/**
|
|
918
1061
|
* Set a root domain cookie for cross-subdomain tracking
|
|
919
1062
|
*/
|
|
@@ -948,7 +1091,10 @@ var Datalyr = (function (exports) {
|
|
|
948
1091
|
* Get stored user ID from previous session
|
|
949
1092
|
*/
|
|
950
1093
|
getStoredUserId() {
|
|
951
|
-
|
|
1094
|
+
// FSR-17: getString (not get) so a numeric-looking user_id ('12345') doesn't come
|
|
1095
|
+
// back as a JS number after reload, and a 16+-digit snowflake id isn't precision-
|
|
1096
|
+
// corrupted by JSON.parse — both would fragment identity from the second page on.
|
|
1097
|
+
return storage.getString('dl_user_id');
|
|
952
1098
|
}
|
|
953
1099
|
/**
|
|
954
1100
|
* Get the anonymous ID
|
|
@@ -1412,12 +1558,19 @@ var Datalyr = (function (exports) {
|
|
|
1412
1558
|
const attribution = {
|
|
1413
1559
|
timestamp: Date.now()
|
|
1414
1560
|
};
|
|
1415
|
-
// Capture UTM parameters
|
|
1561
|
+
// Capture UTM parameters.
|
|
1562
|
+
// Emit BOTH the canonical `utm_*` key AND the stripped alias (source/medium/...).
|
|
1563
|
+
// FSR-13: ingest (event_data.utm_source ?? utmSource) and EVERY attribution MV /
|
|
1564
|
+
// lookup pipe key on the prefixed `utm_source`/`utm_campaign` names with no fallback —
|
|
1565
|
+
// stripping the prefix left the UTM leg of the attribution read path dead for all web
|
|
1566
|
+
// events. The stripped alias is still emitted because the dashboard analytics MVs and
|
|
1567
|
+
// determineSource()/determineMedium() read it (and it's the long-standing shape).
|
|
1416
1568
|
for (const utm of this.UTM_PARAMS) {
|
|
1417
1569
|
const value = params[utm];
|
|
1418
1570
|
if (value) {
|
|
1571
|
+
attribution[utm] = value; // canonical: utm_source, utm_campaign, ...
|
|
1419
1572
|
const key = utm.replace('utm_', '');
|
|
1420
|
-
attribution[key] = value;
|
|
1573
|
+
attribution[key] = value; // alias: source, campaign, ...
|
|
1421
1574
|
}
|
|
1422
1575
|
}
|
|
1423
1576
|
// Capture click IDs. The first present (by CLICK_IDS priority) is the primary
|
|
@@ -1585,33 +1738,53 @@ var Datalyr = (function (exports) {
|
|
|
1585
1738
|
// Optionally set the cookie for future use
|
|
1586
1739
|
cookies.set('_fbp', adCookies._fbp, 90);
|
|
1587
1740
|
}
|
|
1588
|
-
//
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1741
|
+
// Meta's fbc format is `fb.{subdomainIndex}.{creationTime}.{fbclid}` where
|
|
1742
|
+
// creationTime is UNIX time in MILLISECONDS (matches the _fbp generation above
|
|
1743
|
+
// and the real _fbc cookie the Meta Pixel writes). Do NOT use seconds here.
|
|
1744
|
+
//
|
|
1745
|
+
// FSR-14: a returning visitor who clicks a NEW ad must get a FRESH _fbc. The old
|
|
1746
|
+
// once-only guards (`!_dl_fbclid_at`, `!_fbc`) meant a new fbclid never refreshed
|
|
1747
|
+
// _fbc while a prior 90-day _fbc still existed — Meta then received the OLD
|
|
1748
|
+
// campaign's fbc (and an old creationTime that can push the conversion outside the
|
|
1749
|
+
// click window) even though event_data carried the new fbclid. We detect a new
|
|
1750
|
+
// click by comparing the URL fbclid against the one embedded in the existing _fbc
|
|
1751
|
+
// (its 4th dot-segment). `_dl_fbclid_at` stays a pure ms timestamp — the CC bridge /
|
|
1752
|
+
// Shopify cart / server-side rebuild forward it as a time, so its format must not
|
|
1753
|
+
// change.
|
|
1591
1754
|
const fbclid = this.getCurrentFbclid();
|
|
1592
|
-
if (fbclid
|
|
1593
|
-
cookies.
|
|
1594
|
-
|
|
1755
|
+
if (fbclid) {
|
|
1756
|
+
const existingFbc = adCookies._fbc; // captured above (cookies.get('_fbc'))
|
|
1757
|
+
const embeddedFbclid = this.extractFbclidFromFbc(existingFbc);
|
|
1758
|
+
if (!existingFbc) {
|
|
1759
|
+
// No _fbc to compare against. Rebuild it, preferring the persisted click time
|
|
1760
|
+
// (covers the case where _fbc was evicted but the same click is still in play),
|
|
1761
|
+
// falling back to now if missing/corrupt (Number('abc') = NaN guard).
|
|
1762
|
+
const stored = cookies.get('_dl_fbclid_at');
|
|
1763
|
+
const parsed = stored ? Number(stored) : NaN;
|
|
1764
|
+
const timestamp = Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
|
|
1765
|
+
if (!stored)
|
|
1766
|
+
cookies.set('_dl_fbclid_at', String(timestamp), 90);
|
|
1767
|
+
adCookies._fbc = `fb.1.${timestamp}.${fbclid}`;
|
|
1768
|
+
cookies.set('_fbc', adCookies._fbc, 90);
|
|
1769
|
+
}
|
|
1770
|
+
else if (embeddedFbclid && embeddedFbclid !== fbclid) {
|
|
1771
|
+
// A NEW ad click landed over a stale _fbc — regenerate with the new fbclid and
|
|
1772
|
+
// the CURRENT click time, and overwrite the persisted click time to match.
|
|
1773
|
+
const now = Date.now();
|
|
1774
|
+
adCookies._fbc = `fb.1.${now}.${fbclid}`;
|
|
1775
|
+
cookies.set('_fbc', adCookies._fbc, 90);
|
|
1776
|
+
cookies.set('_dl_fbclid_at', String(now), 90);
|
|
1777
|
+
}
|
|
1778
|
+
// else: same fbclid already embedded in _fbc → leave both cookies untouched.
|
|
1779
|
+
}
|
|
1780
|
+
// Persist the click time the FIRST time we see gclid in the URL so server-side
|
|
1781
|
+
// events can stamp the real click moment even after the _gcl_* cookies evict.
|
|
1782
|
+
// (gclid has no synthesized-and-going-stale artifact like _fbc, and Google does not
|
|
1783
|
+
// validate a creationTime window the way Meta does, so this stays once-only.)
|
|
1595
1784
|
const gclid = this.hasClickId('gclid') ? ((_b = (_a = this.queryParamsCache) === null || _a === void 0 ? void 0 : _a.gclid) !== null && _b !== void 0 ? _b : null) : null;
|
|
1596
1785
|
if (gclid && !cookies.get('_dl_gclid_at')) {
|
|
1597
1786
|
cookies.set('_dl_gclid_at', String(Date.now()), 90);
|
|
1598
1787
|
}
|
|
1599
|
-
// Generate _fbc if we have fbclid but no _fbc.
|
|
1600
|
-
// Meta's fbc format is `fb.{subdomainIndex}.{creationTime}.{fbclid}` where
|
|
1601
|
-
// creationTime is UNIX time in MILLISECONDS (matches the _fbp generation above
|
|
1602
|
-
// and the real _fbc cookie the Meta Pixel writes). Do NOT use seconds here.
|
|
1603
|
-
if (fbclid && !adCookies._fbc) {
|
|
1604
|
-
// Prefer the persisted click time when valid; fall back to now if the
|
|
1605
|
-
// cookie is missing or corrupted (e.g. user edited it to garbage).
|
|
1606
|
-
// Without this guard, Number("abc") = NaN and Meta would reject
|
|
1607
|
-
// `fb.1.NaN.{fbclid}`.
|
|
1608
|
-
const stored = cookies.get('_dl_fbclid_at');
|
|
1609
|
-
const parsed = stored ? Number(stored) : NaN;
|
|
1610
|
-
const timestamp = Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
|
|
1611
|
-
adCookies._fbc = `fb.1.${timestamp}.${fbclid}`;
|
|
1612
|
-
// Optionally set the cookie for future use
|
|
1613
|
-
cookies.set('_fbc', adCookies._fbc, 90);
|
|
1614
|
-
}
|
|
1615
1788
|
// Filter out null values for cleaner data
|
|
1616
1789
|
return Object.fromEntries(Object.entries(adCookies).filter(([_, value]) => value !== null));
|
|
1617
1790
|
}
|
|
@@ -1631,6 +1804,21 @@ var Datalyr = (function (exports) {
|
|
|
1631
1804
|
const params = this.queryParamsCache || getAllQueryParams();
|
|
1632
1805
|
return params.fbclid || null;
|
|
1633
1806
|
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Extract the fbclid embedded in an `_fbc` cookie value
|
|
1809
|
+
* (`fb.{subdomainIndex}.{creationTime}.{fbclid}` → the 4th dot-segment), or null if
|
|
1810
|
+
* the value is missing/malformed. Used to detect whether a NEW fbclid in the URL
|
|
1811
|
+
* differs from the one a prior _fbc was built for. (FSR-14)
|
|
1812
|
+
*/
|
|
1813
|
+
extractFbclidFromFbc(fbc) {
|
|
1814
|
+
if (!fbc)
|
|
1815
|
+
return null;
|
|
1816
|
+
const parts = fbc.split('.');
|
|
1817
|
+
// fb . subdomainIndex . creationTime . fbclid (the fbclid itself may contain no dots)
|
|
1818
|
+
if (parts.length < 4 || parts[0] !== 'fb')
|
|
1819
|
+
return null;
|
|
1820
|
+
return parts.slice(3).join('.') || null;
|
|
1821
|
+
}
|
|
1634
1822
|
/**
|
|
1635
1823
|
* Get attribution data for event
|
|
1636
1824
|
*/
|
|
@@ -1648,11 +1836,18 @@ var Datalyr = (function (exports) {
|
|
|
1648
1836
|
const hasRealAttribution = !!(current.clickId ||
|
|
1649
1837
|
current.campaign ||
|
|
1650
1838
|
(current.source && current.source !== 'direct'));
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1839
|
+
// Direct / internal navigation: fall back to persisted attribution so the event
|
|
1840
|
+
// isn't mis-attributed to 'direct'. FSR-48: prefer the LAST touch (the most recent
|
|
1841
|
+
// real signal, e.g. a May Meta click) over the FIRST touch (e.g. a January gclid) —
|
|
1842
|
+
// spreading firstTouch carried its stale clickId top-level and credited the older
|
|
1843
|
+
// campaign for last-click. Also fall back to whichever of last/first touch is still
|
|
1844
|
+
// valid, so an expired first-touch with a live last-touch no longer drops to 'direct'.
|
|
1845
|
+
if (!hasRealAttribution) {
|
|
1846
|
+
// getLastTouch()/getFirstTouch() already drop expired records, so either is live or
|
|
1847
|
+
// null. Prefer last-touch; fall back to first-touch if there's no live last-touch.
|
|
1848
|
+
const fallback = lastTouch || firstTouch;
|
|
1849
|
+
if (fallback) {
|
|
1850
|
+
current = Object.assign(Object.assign({}, fallback), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
|
|
1656
1851
|
}
|
|
1657
1852
|
}
|
|
1658
1853
|
// Capture advertising cookies automatically
|
|
@@ -1772,8 +1967,10 @@ var Datalyr = (function (exports) {
|
|
|
1772
1967
|
* internal navs from being mis-classified as referral.
|
|
1773
1968
|
*/
|
|
1774
1969
|
isSameRootDomain(a, b) {
|
|
1775
|
-
|
|
1776
|
-
|
|
1970
|
+
// FSR-47: use the real eTLD+1 (public-suffix-aware) so a ccTLD merchant
|
|
1971
|
+
// (shop.example.co.uk) doesn't collapse every external referrer that merely shares
|
|
1972
|
+
// the suffix (google.co.uk → root 'co.uk') into 'direct'.
|
|
1973
|
+
return getRegistrableDomain(a) === getRegistrableDomain(b);
|
|
1777
1974
|
}
|
|
1778
1975
|
/**
|
|
1779
1976
|
* Extract hostname from URL
|
|
@@ -1821,6 +2018,16 @@ var Datalyr = (function (exports) {
|
|
|
1821
2018
|
// already-overloaded server). See sendBatch / the rateLimitedUntil gate.
|
|
1822
2019
|
class RateLimitError extends Error {
|
|
1823
2020
|
}
|
|
2021
|
+
// A permanent (non-retryable) failure — a 4xx other than 408/429 (invalid event shape,
|
|
2022
|
+
// origin not allowed, auth). Retrying or parking it just head-of-line-blocks the offline
|
|
2023
|
+
// queue forever and hammers ingest. Tagged so the send paths DROP the batch instead of
|
|
2024
|
+
// unshifting it back to the head. (FSR-55)
|
|
2025
|
+
class PermanentError extends Error {
|
|
2026
|
+
constructor(status, message) {
|
|
2027
|
+
super(message || `HTTP ${status}`);
|
|
2028
|
+
this.status = status;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
1824
2031
|
class EventQueue {
|
|
1825
2032
|
constructor(config) {
|
|
1826
2033
|
this.queue = [];
|
|
@@ -1887,16 +2094,30 @@ var Datalyr = (function (exports) {
|
|
|
1887
2094
|
this.log('Duplicate event suppressed:', eventName);
|
|
1888
2095
|
return;
|
|
1889
2096
|
}
|
|
1890
|
-
//
|
|
1891
|
-
//
|
|
1892
|
-
//
|
|
2097
|
+
// Critical events (purchase/signup/lead/…) send immediately, bypassing the batch.
|
|
2098
|
+
// FSR-16: persist the event to the offline queue BEFORE the first send and remove it
|
|
2099
|
+
// on confirmed delivery. Previously the event existed ONLY inside the sendBatch retry
|
|
2100
|
+
// closure during backoff (~31s) — not in any tracked queue — so a tab close mid-retry
|
|
2101
|
+
// (the most plausible moment after checkout) lost the purchase permanently: forceFlush
|
|
2102
|
+
// / destroy never saw it. Server-side event_id dedup makes the at-least-once re-send
|
|
2103
|
+
// safe.
|
|
1893
2104
|
if (this.config.criticalEvents.includes(eventName)) {
|
|
1894
2105
|
this.log('Critical event, sending immediately:', eventName);
|
|
1895
|
-
|
|
1896
|
-
this.sendBatch([event])
|
|
1897
|
-
|
|
1898
|
-
//
|
|
1899
|
-
this.
|
|
2106
|
+
this.persistCriticalEvent(event);
|
|
2107
|
+
this.sendBatch([event])
|
|
2108
|
+
.then(() => {
|
|
2109
|
+
// Delivered — drop the persisted copy so it isn't replayed next load.
|
|
2110
|
+
this.removeFromOfflineQueue(event.event_id);
|
|
2111
|
+
})
|
|
2112
|
+
.catch((error) => {
|
|
2113
|
+
if (error instanceof PermanentError) {
|
|
2114
|
+
this.log('Critical event permanently rejected, dropping:', eventName, error);
|
|
2115
|
+
this.removeFromOfflineQueue(event.event_id);
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
// Leave it persisted: the periodic drain + next-load drain retry it, and an
|
|
2119
|
+
// unload mid-retry can now beacon it (it's in the offline queue). (FSR-16)
|
|
2120
|
+
this.log('Critical event send failed, retained in offline queue:', eventName, error);
|
|
1900
2121
|
});
|
|
1901
2122
|
return;
|
|
1902
2123
|
}
|
|
@@ -2045,7 +2266,14 @@ var Datalyr = (function (exports) {
|
|
|
2045
2266
|
// Remove the same slice from the live queue; the offline queue now drains on
|
|
2046
2267
|
// every periodic tick (WEB-1/WEB-2), so they are still retried, not stranded.
|
|
2047
2268
|
this.queue.splice(0, batchSize);
|
|
2048
|
-
|
|
2269
|
+
// FSR-55: don't park a permanently-rejected batch — it would never succeed and
|
|
2270
|
+
// would block the offline drain. Drop it.
|
|
2271
|
+
if (error instanceof PermanentError) {
|
|
2272
|
+
this.log(`Dropping ${events.length} events — permanent error ${error.status}`);
|
|
2273
|
+
}
|
|
2274
|
+
else {
|
|
2275
|
+
this.moveToOfflineQueue(events);
|
|
2276
|
+
}
|
|
2049
2277
|
}
|
|
2050
2278
|
finally {
|
|
2051
2279
|
this.inFlight = [];
|
|
@@ -2065,6 +2293,14 @@ var Datalyr = (function (exports) {
|
|
|
2065
2293
|
// Get current endpoint (main or fallback)
|
|
2066
2294
|
const endpoints = [this.config.endpoint, ...this.config.fallbackEndpoints];
|
|
2067
2295
|
const currentEndpoint = endpoints[endpointIndex] || this.config.endpoint;
|
|
2296
|
+
const body = JSON.stringify(batchPayload);
|
|
2297
|
+
// FSR-54: keepalive requests share a 64KB in-flight quota (Fetch spec) — a larger
|
|
2298
|
+
// body fails INSTANTLY with a network error, then retries through every fallback +
|
|
2299
|
+
// backoff (all failing the same way) and parks in the offline queue, where it
|
|
2300
|
+
// head-of-line-blocks the drain forever. keepalive only matters for unload survival,
|
|
2301
|
+
// so only request it when the body is safely under the cap; oversized batches go via
|
|
2302
|
+
// a normal fetch (no cap) and succeed.
|
|
2303
|
+
const useKeepalive = new Blob([body]).size <= 60000;
|
|
2068
2304
|
try {
|
|
2069
2305
|
const response = yield fetch(currentEndpoint, {
|
|
2070
2306
|
method: 'POST',
|
|
@@ -2072,8 +2308,8 @@ var Datalyr = (function (exports) {
|
|
|
2072
2308
|
'Content-Type': 'application/json',
|
|
2073
2309
|
'X-Batch-Size': events.length.toString()
|
|
2074
2310
|
},
|
|
2075
|
-
body
|
|
2076
|
-
keepalive:
|
|
2311
|
+
body,
|
|
2312
|
+
keepalive: useKeepalive
|
|
2077
2313
|
});
|
|
2078
2314
|
if (!response.ok) {
|
|
2079
2315
|
// Handle rate limiting
|
|
@@ -2089,15 +2325,21 @@ var Datalyr = (function (exports) {
|
|
|
2089
2325
|
this.log(`Rate limited; backing off ${retryAfter}s`);
|
|
2090
2326
|
throw new RateLimitError('Rate limited (429)');
|
|
2091
2327
|
}
|
|
2328
|
+
// FSR-55: a 4xx other than 408 (timeout) is PERMANENT — invalid event shape
|
|
2329
|
+
// (400), origin not allowed (403), auth (401). Retrying / parking it just blocks
|
|
2330
|
+
// the queue and hammers ingest forever. Tag it so the caller drops the batch.
|
|
2331
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 408) {
|
|
2332
|
+
throw new PermanentError(response.status, `HTTP ${response.status}: ${response.statusText}`);
|
|
2333
|
+
}
|
|
2092
2334
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
2093
2335
|
}
|
|
2094
2336
|
this.log(`Batch sent successfully to ${currentEndpoint}: ${events.length} events`);
|
|
2095
2337
|
}
|
|
2096
2338
|
catch (error) {
|
|
2097
|
-
// A 429 is deliberate backpressure — do NOT retry
|
|
2098
|
-
// backoff). Propagate so the
|
|
2099
|
-
//
|
|
2100
|
-
if (error instanceof RateLimitError) {
|
|
2339
|
+
// A 429 is deliberate backpressure and a 4xx is permanent — do NOT retry either
|
|
2340
|
+
// (neither fallback nor backoff). Propagate so the caller routes them correctly
|
|
2341
|
+
// (offline queue for 429, drop for permanent). This prevents the retry storm.
|
|
2342
|
+
if (error instanceof RateLimitError || error instanceof PermanentError) {
|
|
2101
2343
|
throw error;
|
|
2102
2344
|
}
|
|
2103
2345
|
// Try next fallback endpoint if available
|
|
@@ -2201,6 +2443,38 @@ var Datalyr = (function (exports) {
|
|
|
2201
2443
|
this.offlineQueueLock = false;
|
|
2202
2444
|
}
|
|
2203
2445
|
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Persist a critical event to the offline queue BEFORE its immediate send, so it
|
|
2448
|
+
* survives an unload during the send/backoff window. Deduped on event_id so a
|
|
2449
|
+
* re-enqueue can't double-store it. (FSR-16)
|
|
2450
|
+
*/
|
|
2451
|
+
persistCriticalEvent(event) {
|
|
2452
|
+
if (!this.enabled)
|
|
2453
|
+
return;
|
|
2454
|
+
if (this.offlineQueue.some(e => e.event_id === event.event_id))
|
|
2455
|
+
return;
|
|
2456
|
+
this.offlineQueue.push(event);
|
|
2457
|
+
if (this.offlineQueue.length > this.config.maxOfflineQueueSize) {
|
|
2458
|
+
this.offlineQueue.splice(0, this.offlineQueue.length - this.config.maxOfflineQueueSize);
|
|
2459
|
+
}
|
|
2460
|
+
this.saveOfflineQueue();
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Remove a delivered/dropped event from the offline queue by event_id, and clear the
|
|
2464
|
+
* persisted copy when the queue empties. (FSR-16)
|
|
2465
|
+
*/
|
|
2466
|
+
removeFromOfflineQueue(eventId) {
|
|
2467
|
+
const before = this.offlineQueue.length;
|
|
2468
|
+
this.offlineQueue = this.offlineQueue.filter(e => e.event_id !== eventId);
|
|
2469
|
+
if (this.offlineQueue.length === before)
|
|
2470
|
+
return;
|
|
2471
|
+
if (this.offlineQueue.length === 0) {
|
|
2472
|
+
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2473
|
+
}
|
|
2474
|
+
else {
|
|
2475
|
+
this.saveOfflineQueue();
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2204
2478
|
/**
|
|
2205
2479
|
* Load offline queue from storage
|
|
2206
2480
|
*/
|
|
@@ -2255,8 +2529,17 @@ var Datalyr = (function (exports) {
|
|
|
2255
2529
|
this.saveOfflineQueue();
|
|
2256
2530
|
}
|
|
2257
2531
|
catch (error) {
|
|
2532
|
+
// FSR-55: a permanently-rejected (4xx) batch must NOT go back to the head —
|
|
2533
|
+
// that would block every event behind it forever and hammer ingest every tick.
|
|
2534
|
+
// Drop it (already spliced off the front) and keep draining the rest.
|
|
2535
|
+
if (error instanceof PermanentError) {
|
|
2536
|
+
this.log(`Dropping poison offline batch (${batch.length}) — permanent error ${error.status}`);
|
|
2537
|
+
this.saveOfflineQueue();
|
|
2538
|
+
continue;
|
|
2539
|
+
}
|
|
2258
2540
|
this.log('Failed to send offline batch:', error);
|
|
2259
|
-
//
|
|
2541
|
+
// Transient/rate-limited: put the batch back at the head and stop this drain;
|
|
2542
|
+
// the next tick (after any rateLimitedUntil window) retries.
|
|
2260
2543
|
this.offlineQueue.unshift(...batch);
|
|
2261
2544
|
this.saveOfflineQueue();
|
|
2262
2545
|
break;
|
|
@@ -2300,42 +2583,71 @@ var Datalyr = (function (exports) {
|
|
|
2300
2583
|
};
|
|
2301
2584
|
}
|
|
2302
2585
|
/**
|
|
2303
|
-
* Force flush (
|
|
2304
|
-
*
|
|
2305
|
-
* and persist any chunk the browser refuses so it survives to the next page load.
|
|
2306
|
-
* Previously this beaconed the live queue as one blob — ignoring the offline queue
|
|
2307
|
-
* entirely and silently failing (returning false) whenever the payload exceeded 64KB.
|
|
2586
|
+
* Force flush. Called on visibilitychange:hidden (NON-terminal — an ordinary tab
|
|
2587
|
+
* switch / mobile background) and on pagehide+beforeunload (terminal unload).
|
|
2308
2588
|
*
|
|
2309
|
-
*
|
|
2310
|
-
*
|
|
2311
|
-
*
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2316
|
-
*
|
|
2589
|
+
* FSR-15: the old single path beaconed BOTH queues on EVERY visibilitychange and then
|
|
2590
|
+
* storage.remove()'d the persisted offline copy on sendBeacon()===true. But sendBeacon
|
|
2591
|
+
* true only means the browser ENQUEUED the request — not that it was delivered. The
|
|
2592
|
+
* offline queue exists precisely because earlier sends failed (ingest down / 5xx / 429);
|
|
2593
|
+
* a single alt-tab during that outage beaconed the whole backlog into the dead endpoint
|
|
2594
|
+
* and ERASED the persisted copy, so nothing replayed when ingest recovered. It also
|
|
2595
|
+
* ignored rateLimitedUntil, firing beacons straight into the Retry-After window.
|
|
2596
|
+
*
|
|
2597
|
+
* Now:
|
|
2598
|
+
* - Non-terminal (tab switch): deliver the LIVE queue via a response-checked keepalive
|
|
2599
|
+
* fetch (flush) and response-check-drain the offline backlog (processOfflineQueue) —
|
|
2600
|
+
* neither erases the backlog on failure. The destructive beacon path is not used.
|
|
2601
|
+
* - Terminal (unload): beacon live+offline best-effort, but PERSIST everything first
|
|
2602
|
+
* and NEVER storage.remove() on a mere beacon enqueue. The next page load's
|
|
2603
|
+
* response-checked drain (normal fetch, no 64KB cap) is the source of truth and
|
|
2604
|
+
* clears the copy; server event_id dedup makes the potential double-send safe.
|
|
2605
|
+
* - Both paths honor rateLimitedUntil (don't beacon/flush into the backoff window).
|
|
2606
|
+
*
|
|
2607
|
+
* Still excludes the in-flight _flush batch (its keepalive fetch already carries it,
|
|
2608
|
+
* HIGH-1), and detaches synchronously so a repeat unload event this lifecycle re-enters
|
|
2609
|
+
* with nothing to re-beacon (HIGH-2).
|
|
2317
2610
|
*/
|
|
2318
2611
|
forceFlush() {
|
|
2319
|
-
return __awaiter(this,
|
|
2612
|
+
return __awaiter(this, arguments, void 0, function* (isTerminal = false) {
|
|
2320
2613
|
if (!this.enabled)
|
|
2321
2614
|
return;
|
|
2322
|
-
if (!
|
|
2323
|
-
//
|
|
2615
|
+
if (!isTerminal) {
|
|
2616
|
+
// Non-terminal visibilitychange: the page is still alive, so use the
|
|
2617
|
+
// response-checked send paths (keepalive fetch) — they move failures to the
|
|
2618
|
+
// persisted offline queue rather than erasing it. Within the 429 window, leave
|
|
2619
|
+
// everything parked.
|
|
2620
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
2621
|
+
return;
|
|
2324
2622
|
yield this.flush();
|
|
2325
2623
|
yield this.processOfflineQueue();
|
|
2326
2624
|
return;
|
|
2327
2625
|
}
|
|
2626
|
+
// Terminal unload.
|
|
2328
2627
|
// Exclude the in-flight batch (its keepalive fetch already carries it).
|
|
2329
2628
|
const live = this.queue.slice(this.inFlight.length);
|
|
2330
2629
|
const pending = [...live, ...this.offlineQueue];
|
|
2331
|
-
// Detach
|
|
2332
|
-
//
|
|
2333
|
-
//
|
|
2334
|
-
// below, not back into this.offlineQueue, for exactly this reason.
|
|
2630
|
+
// Detach the IN-MEMORY queues synchronously so a second unload event this lifecycle
|
|
2631
|
+
// finds nothing to re-beacon (HIGH-2). The persisted STORAGE copy below is the
|
|
2632
|
+
// durable record and is deliberately NOT removed on a successful beacon. (FSR-15)
|
|
2335
2633
|
this.queue = this.queue.slice(0, this.inFlight.length);
|
|
2336
2634
|
this.offlineQueue = [];
|
|
2337
2635
|
if (pending.length === 0)
|
|
2338
2636
|
return;
|
|
2637
|
+
// Persist the full backlog FIRST so it survives no matter what sendBeacon reports.
|
|
2638
|
+
// The next load's response-checked drain delivers + clears it (dedup-safe). (FSR-15)
|
|
2639
|
+
if (this.enabled) {
|
|
2640
|
+
const toPersist = pending.slice(-this.config.maxOfflineQueueSize);
|
|
2641
|
+
if (pending.length > toPersist.length) {
|
|
2642
|
+
this.log(`Offline cap dropped ${pending.length - toPersist.length} oldest events at unload`);
|
|
2643
|
+
}
|
|
2644
|
+
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2645
|
+
}
|
|
2646
|
+
// Within the 429 window, don't beacon into it — the persisted copy drains next load.
|
|
2647
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
2648
|
+
return;
|
|
2649
|
+
if (!navigator.sendBeacon)
|
|
2650
|
+
return; // no beacon API — next load fetch-drains the copy
|
|
2339
2651
|
const MAX_BEACON_BYTES = 60000; // headroom under the browser's ~64KB cap
|
|
2340
2652
|
// Greedily pack events into chunks bounded by BOTH batchSize and byte size.
|
|
2341
2653
|
const chunks = [];
|
|
@@ -2356,36 +2668,18 @@ var Datalyr = (function (exports) {
|
|
|
2356
2668
|
}
|
|
2357
2669
|
if (current.length > 0)
|
|
2358
2670
|
chunks.push(current);
|
|
2359
|
-
//
|
|
2360
|
-
//
|
|
2361
|
-
let
|
|
2362
|
-
for (
|
|
2363
|
-
const blob = new Blob([JSON.stringify(this.buildBatch(
|
|
2671
|
+
// Best-effort early delivery. We do NOT storage.remove() on success — the persisted
|
|
2672
|
+
// copy is the source of truth for the next load (sendBeacon true ≠ delivered).
|
|
2673
|
+
let beaconed = 0;
|
|
2674
|
+
for (const chunk of chunks) {
|
|
2675
|
+
const blob = new Blob([JSON.stringify(this.buildBatch(chunk))], {
|
|
2364
2676
|
type: 'application/json'
|
|
2365
2677
|
});
|
|
2366
|
-
if (!navigator.sendBeacon(this.config.endpoint, blob))
|
|
2367
|
-
failedFrom = c;
|
|
2678
|
+
if (!navigator.sendBeacon(this.config.endpoint, blob))
|
|
2368
2679
|
break;
|
|
2369
|
-
|
|
2370
|
-
}
|
|
2371
|
-
if (failedFrom >= 0) {
|
|
2372
|
-
const remainder = chunks
|
|
2373
|
-
.slice(failedFrom)
|
|
2374
|
-
.reduce((acc, c) => acc.concat(c), []);
|
|
2375
|
-
// Persist straight to storage (NOT this.offlineQueue) so a repeat unload event
|
|
2376
|
-
// won't re-beacon it; the next page load's drain (which uses a normal fetch with
|
|
2377
|
-
// no 64KB cap) delivers it. MED-2: log if the maxOfflineQueueSize cap truncates.
|
|
2378
|
-
const toPersist = remainder.slice(-this.config.maxOfflineQueueSize);
|
|
2379
|
-
if (remainder.length > toPersist.length) {
|
|
2380
|
-
this.log(`Offline cap dropped ${remainder.length - toPersist.length} oldest events at unload`);
|
|
2381
|
-
}
|
|
2382
|
-
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2383
|
-
this.log(`sendBeacon refused ${remainder.length} events; persisted ${toPersist.length} for next load`);
|
|
2384
|
-
}
|
|
2385
|
-
else {
|
|
2386
|
-
this.log('Events sent via sendBeacon');
|
|
2387
|
-
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2680
|
+
beaconed += chunk.length;
|
|
2388
2681
|
}
|
|
2682
|
+
this.log(`forceFlush(terminal): beaconed ${beaconed}/${pending.length} events; persisted copy retained for next-load drain`);
|
|
2389
2683
|
});
|
|
2390
2684
|
}
|
|
2391
2685
|
/**
|
|
@@ -2625,17 +2919,33 @@ var Datalyr = (function (exports) {
|
|
|
2625
2919
|
if (this.initialized)
|
|
2626
2920
|
return;
|
|
2627
2921
|
try {
|
|
2628
|
-
//
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2922
|
+
// FSR-52: time-box the /container-scripts fetch. init() is awaited BEFORE the
|
|
2923
|
+
// initial page() fires, and this fetch had no timeout — a slow/stalled ingest
|
|
2924
|
+
// worker (the fleet has a documented history of worker-side stalls) delayed or lost
|
|
2925
|
+
// the landing pageview indefinitely. Abort after 3s so page() still fires; the catch
|
|
2926
|
+
// below leaves pixels/remoteConfig unset (the SDK proceeds with first-party tracking
|
|
2927
|
+
// and defaults). AbortSignal.timeout isn't on every supported browser, so use a
|
|
2928
|
+
// manual controller.
|
|
2929
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
2930
|
+
const timeoutId = controller ? setTimeout(() => controller.abort(), 3000) : null;
|
|
2931
|
+
let response;
|
|
2932
|
+
try {
|
|
2933
|
+
response = yield fetch(`${this.endpoint}/container-scripts`, {
|
|
2934
|
+
method: 'POST',
|
|
2935
|
+
headers: {
|
|
2936
|
+
'Content-Type': 'application/json',
|
|
2937
|
+
'X-Container-Version': '1.0'
|
|
2938
|
+
},
|
|
2939
|
+
body: JSON.stringify({
|
|
2940
|
+
workspaceId: this.workspaceId
|
|
2941
|
+
}),
|
|
2942
|
+
signal: controller ? controller.signal : undefined
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
finally {
|
|
2946
|
+
if (timeoutId)
|
|
2947
|
+
clearTimeout(timeoutId);
|
|
2948
|
+
}
|
|
2639
2949
|
if (!response.ok) {
|
|
2640
2950
|
throw new Error(`Failed to fetch container scripts: ${response.status}`);
|
|
2641
2951
|
}
|
|
@@ -3106,6 +3416,16 @@ var Datalyr = (function (exports) {
|
|
|
3106
3416
|
this.log('Error initializing TikTok Pixel:', error);
|
|
3107
3417
|
}
|
|
3108
3418
|
}
|
|
3419
|
+
/**
|
|
3420
|
+
* Whether the Meta Pixel is configured AND loaded (fbq present) — i.e. a Purchase
|
|
3421
|
+
* co-fire via trackToPixels() would actually reach Meta rather than silently no-op.
|
|
3422
|
+
* Used by the CC purchase-pixel dedup so its once-per-order guard isn't burned before
|
|
3423
|
+
* the pixel is live. (FSR-102)
|
|
3424
|
+
*/
|
|
3425
|
+
hasMetaPixel() {
|
|
3426
|
+
var _a, _b;
|
|
3427
|
+
return !!(((_b = (_a = this.pixels) === null || _a === void 0 ? void 0 : _a.meta) === null || _b === void 0 ? void 0 : _b.enabled) && window.fbq);
|
|
3428
|
+
}
|
|
3109
3429
|
/**
|
|
3110
3430
|
* Track event to all initialized pixels
|
|
3111
3431
|
*/
|
|
@@ -3413,28 +3733,59 @@ var Datalyr = (function (exports) {
|
|
|
3413
3733
|
setupFormMonitoring() {
|
|
3414
3734
|
// Monitor existing forms
|
|
3415
3735
|
this.scanForEmailForms();
|
|
3416
|
-
// Watch for new forms (SPAs)
|
|
3417
|
-
|
|
3736
|
+
// Watch for new forms (SPAs). FSR-101: store the observer so destroy() (opt-out / SDK
|
|
3737
|
+
// destroy) can disconnect it — previously it was a local and leaked for the page
|
|
3738
|
+
// lifetime, re-scanning + re-attaching listeners on every DOM mutation after opt-out.
|
|
3739
|
+
this.formObserver = new MutationObserver(() => {
|
|
3418
3740
|
this.scanForEmailForms();
|
|
3419
3741
|
});
|
|
3420
|
-
|
|
3742
|
+
this.formObserver.observe(document.body, {
|
|
3421
3743
|
childList: true,
|
|
3422
3744
|
subtree: true
|
|
3423
3745
|
});
|
|
3424
3746
|
this.log('Form monitoring active');
|
|
3425
3747
|
}
|
|
3748
|
+
/**
|
|
3749
|
+
* The form's email input that belongs to the VISITOR (not a gift/recipient field), or
|
|
3750
|
+
* null when there's no usable / unambiguous one. (FSR-49)
|
|
3751
|
+
*/
|
|
3752
|
+
findVisitorEmailInput(form) {
|
|
3753
|
+
const all = Array.from(form.querySelectorAll('input[type="email"], input[name*="email" i], input[id*="email" i], input[autocomplete="email"]'));
|
|
3754
|
+
// Drop third-party (gift/recipient/refer-a-friend) email fields.
|
|
3755
|
+
const candidates = all.filter(i => !this.isThirdPartyEmailField(i));
|
|
3756
|
+
if (candidates.length === 0)
|
|
3757
|
+
return null;
|
|
3758
|
+
if (candidates.length === 1)
|
|
3759
|
+
return candidates[0];
|
|
3760
|
+
// Multiple remain → prefer an explicit autocomplete="email" (the visitor's own field).
|
|
3761
|
+
const preferred = candidates.filter(i => (i.getAttribute('autocomplete') || '').toLowerCase() === 'email');
|
|
3762
|
+
if (preferred.length === 1)
|
|
3763
|
+
return preferred[0];
|
|
3764
|
+
// Still ambiguous → skip rather than identify as the wrong person.
|
|
3765
|
+
this.log('Multiple email inputs in form; skipping auto-identify (ambiguous)');
|
|
3766
|
+
return null;
|
|
3767
|
+
}
|
|
3768
|
+
isThirdPartyEmailField(input) {
|
|
3769
|
+
const haystack = [
|
|
3770
|
+
input.name, input.id, input.getAttribute('autocomplete'),
|
|
3771
|
+
input.getAttribute('aria-label'), input.placeholder
|
|
3772
|
+
].filter(Boolean).join(' ');
|
|
3773
|
+
return AutoIdentifyManager.THIRD_PARTY_EMAIL_FIELD.test(haystack);
|
|
3774
|
+
}
|
|
3426
3775
|
/**
|
|
3427
3776
|
* Scan DOM for forms with email inputs
|
|
3428
3777
|
*/
|
|
3429
3778
|
scanForEmailForms() {
|
|
3779
|
+
if (this.destroyed)
|
|
3780
|
+
return; // FSR-101: defense-in-depth after disconnect
|
|
3430
3781
|
const forms = document.querySelectorAll('form');
|
|
3431
3782
|
forms.forEach(form => {
|
|
3432
3783
|
// Skip if already listening
|
|
3433
3784
|
if (this.formListeners.some(l => l.element === form)) {
|
|
3434
3785
|
return;
|
|
3435
3786
|
}
|
|
3436
|
-
//
|
|
3437
|
-
const emailInput =
|
|
3787
|
+
// Only monitor forms with a capturable VISITOR email field (FSR-49).
|
|
3788
|
+
const emailInput = this.findVisitorEmailInput(form);
|
|
3438
3789
|
if (emailInput) {
|
|
3439
3790
|
const handler = (e) => this.handleFormSubmit(e, form);
|
|
3440
3791
|
form.addEventListener('submit', handler);
|
|
@@ -3448,8 +3799,8 @@ var Datalyr = (function (exports) {
|
|
|
3448
3799
|
*/
|
|
3449
3800
|
handleFormSubmit(_event, form) {
|
|
3450
3801
|
try {
|
|
3451
|
-
// Find email input
|
|
3452
|
-
const emailInput =
|
|
3802
|
+
// Find the visitor's own email input (excludes gift/recipient fields — FSR-49).
|
|
3803
|
+
const emailInput = this.findVisitorEmailInput(form);
|
|
3453
3804
|
if (!emailInput)
|
|
3454
3805
|
return;
|
|
3455
3806
|
const email = emailInput.value.trim();
|
|
@@ -3791,6 +4142,12 @@ var Datalyr = (function (exports) {
|
|
|
3791
4142
|
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
|
3792
4143
|
this.originalXHRSend = undefined;
|
|
3793
4144
|
}
|
|
4145
|
+
// Disconnect the form MutationObserver (FSR-101) — otherwise it keeps firing
|
|
4146
|
+
// scanForEmailForms on every DOM mutation after opt-out, re-attaching listeners.
|
|
4147
|
+
if (this.formObserver) {
|
|
4148
|
+
this.formObserver.disconnect();
|
|
4149
|
+
this.formObserver = undefined;
|
|
4150
|
+
}
|
|
3794
4151
|
// Remove form listeners
|
|
3795
4152
|
this.formListeners.forEach(({ element, handler }) => {
|
|
3796
4153
|
element.removeEventListener('submit', handler);
|
|
@@ -3812,6 +4169,10 @@ var Datalyr = (function (exports) {
|
|
|
3812
4169
|
}
|
|
3813
4170
|
}
|
|
3814
4171
|
}
|
|
4172
|
+
// FSR-49: email fields that belong to a THIRD PARTY, not the visitor — gift/recipient/
|
|
4173
|
+
// refer-a-friend/"email this to" forms. Capturing these identifies the visitor as
|
|
4174
|
+
// someone else (and feeds that person's email to Meta advanced matching).
|
|
4175
|
+
AutoIdentifyManager.THIRD_PARTY_EMAIL_FIELD = /(recipient|friend|gift|refer|invite|colleague|coworker|share|to[-_]?email|email[-_]?to|second(ary)?[-_]?email)/i;
|
|
3815
4176
|
|
|
3816
4177
|
/** Keys the remote config is allowed to fill on DatalyrConfig. */
|
|
3817
4178
|
const REMOTE_KEYS = [
|
|
@@ -3923,8 +4284,11 @@ var Datalyr = (function (exports) {
|
|
|
3923
4284
|
if (this.config.platform === 'checkoutchamp') {
|
|
3924
4285
|
this.restoreFromURL();
|
|
3925
4286
|
}
|
|
3926
|
-
// Initialize modules
|
|
3927
|
-
|
|
4287
|
+
// Initialize modules.
|
|
4288
|
+
// FSR-107: don't write a persistent tracking cookie/localStorage id for a visitor who
|
|
4289
|
+
// is opted-out / GPC / DNT at init — the id stays in memory only (events don't send
|
|
4290
|
+
// anyway). shouldTrack() is computable here (config/opt-out/consent are all set above).
|
|
4291
|
+
this.identity = new IdentityManager({ persistNewId: this.shouldTrack() });
|
|
3928
4292
|
this.session = new SessionManager(this.config.sessionTimeout);
|
|
3929
4293
|
this.attribution = new AttributionManager({
|
|
3930
4294
|
attributionWindow: this.config.attributionWindow,
|
|
@@ -3975,7 +4339,7 @@ var Datalyr = (function (exports) {
|
|
|
3975
4339
|
return this.initializationPromise;
|
|
3976
4340
|
}
|
|
3977
4341
|
this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
|
|
3978
|
-
var _a;
|
|
4342
|
+
var _a, _b;
|
|
3979
4343
|
try {
|
|
3980
4344
|
// SEC-03: encryption is for PII-AT-REST only — it must NOT gate event delivery,
|
|
3981
4345
|
// pageviews, or pixels. On a non-secure context (http://) or an old browser,
|
|
@@ -4098,6 +4462,15 @@ var Datalyr = (function (exports) {
|
|
|
4098
4462
|
// opted-out / DNT / GPC visitor's id + click-ids aren't stamped on outbound links.
|
|
4099
4463
|
this.syncOutboundLinkParams(this.config.checkoutChampDomains);
|
|
4100
4464
|
}
|
|
4465
|
+
// Stripe Payment Link auto-decoration (D1, DEFAULT ON): stamp
|
|
4466
|
+
// client_reference_id=<visitor_id> (+ prefilled_email when identified) onto
|
|
4467
|
+
// buy.stripe.com / custom payment-link domain anchors and Stripe embeds, so a
|
|
4468
|
+
// snippet-only merchant's checkout.session.completed webhooks attribute
|
|
4469
|
+
// deterministically with zero server work. Gated on shouldTrack() so
|
|
4470
|
+
// opted-out / DNT / GPC visitors never get an id stamped into outbound links.
|
|
4471
|
+
if (this.config.stripePaymentLinks !== false && this.shouldTrack()) {
|
|
4472
|
+
this.syncStripePaymentLinks((_b = this.config.stripeLinkDomains) !== null && _b !== void 0 ? _b : []);
|
|
4473
|
+
}
|
|
4101
4474
|
// Track initial page view if enabled (AFTER encryption ready)
|
|
4102
4475
|
if (this.config.trackPageViews) {
|
|
4103
4476
|
this.page();
|
|
@@ -4246,10 +4619,13 @@ var Datalyr = (function (exports) {
|
|
|
4246
4619
|
return;
|
|
4247
4620
|
}
|
|
4248
4621
|
try {
|
|
4249
|
-
//
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4622
|
+
// FSR-53: do NOT rotate the session id on identify(). 'Session fixation' is an
|
|
4623
|
+
// auth-credential concept; a client-generated analytics session_id is not one.
|
|
4624
|
+
// Rotating split every identified visit (and every auto-identify form submit) into
|
|
4625
|
+
// two session_ids — inflating server-side uniq(session_id) counts by +1 per
|
|
4626
|
+
// identified visitor and landing the post-login conversion in a session with zero
|
|
4627
|
+
// preceding pageviews. Matches iOS/RN (no rotate-on-identify) and every major
|
|
4628
|
+
// analytics SDK; keeps session-scoped attribution joins intact.
|
|
4253
4629
|
// Update identity
|
|
4254
4630
|
const identityLink = this.identity.identify(userId, traits);
|
|
4255
4631
|
// SEC-03 Fix: Store user properties encrypted (contains PII)
|
|
@@ -4399,6 +4775,15 @@ var Datalyr = (function (exports) {
|
|
|
4399
4775
|
// Clear the journey too, or the next user's touchpoints append to the previous
|
|
4400
4776
|
// user's (cross-user contamination of touchpoint_count / days_since_first_touch).
|
|
4401
4777
|
storage.remove('dl_journey');
|
|
4778
|
+
// FSR-106: clear first/last-touch attribution as well — same cross-user-contamination
|
|
4779
|
+
// rationale as the journey. Otherwise the next anon visitor's events carry the
|
|
4780
|
+
// PREVIOUS user's first_touch_*/last_touch_* (via getAttributionData's fallback),
|
|
4781
|
+
// crediting user B's conversions to the ad click that brought user A.
|
|
4782
|
+
// (Meta's _fbc/_fbp cookies are deliberately left alone — they're shared with the
|
|
4783
|
+
// merchant's own Meta Pixel and clearing them could break it; the fresh anonymous_id
|
|
4784
|
+
// already de-links the visitor server-side.)
|
|
4785
|
+
storage.remove('dl_first_touch');
|
|
4786
|
+
storage.remove('dl_last_touch');
|
|
4402
4787
|
this.session.createNewSession();
|
|
4403
4788
|
this.log('User reset');
|
|
4404
4789
|
}
|
|
@@ -4575,8 +4960,17 @@ var Datalyr = (function (exports) {
|
|
|
4575
4960
|
* Set consent preferences
|
|
4576
4961
|
*/
|
|
4577
4962
|
setConsent(consent) {
|
|
4963
|
+
// Always record + persist consent FIRST, even before init(). FSR-51: a consent-
|
|
4964
|
+
// management platform commonly calls setConsent() before init() so tracking is gated
|
|
4965
|
+
// from the very first event. The old code then dereferenced this.queue/this.config
|
|
4966
|
+
// (both undefined pre-init) inside shouldTrack()/setEnabled() and threw an uncaught
|
|
4967
|
+
// TypeError into the CMP's callback chain. init() re-reads dl_consent (and gates the
|
|
4968
|
+
// queue) at startup, so a pre-init call is honored there.
|
|
4578
4969
|
this.consent = consent;
|
|
4579
4970
|
storage.set('dl_consent', consent);
|
|
4971
|
+
if (!this.initialized) {
|
|
4972
|
+
return;
|
|
4973
|
+
}
|
|
4580
4974
|
// Enforce it live (not just persist it). Gate the queue by the full policy
|
|
4581
4975
|
// (analytics consent + opt-out + DNT/GPC), and on withdrawal purge buffered events
|
|
4582
4976
|
// too — mirroring optOut — so events captured before withdrawal can't drain if
|
|
@@ -4817,6 +5211,16 @@ var Datalyr = (function (exports) {
|
|
|
4817
5211
|
const orderId = order.orderId;
|
|
4818
5212
|
if (!orderId)
|
|
4819
5213
|
return;
|
|
5214
|
+
// FSR-102: only burn the once-per-order guard once the Meta Pixel is actually live.
|
|
5215
|
+
// trackToPixels no-ops when the container config never arrived (pixels=null after a
|
|
5216
|
+
// transient /container-scripts failure); setting the guard first would permanently
|
|
5217
|
+
// suppress the co-fire for the rest of the funnel session even when the container
|
|
5218
|
+
// loads on a later upsell page. orderData persists across upsell loads, so a later
|
|
5219
|
+
// page retries. Meta's 48h event_id dedup still protects against a double-fire.
|
|
5220
|
+
if (!this.container.hasMetaPixel()) {
|
|
5221
|
+
this.log("CC Purchase Pixel: Meta pixel not ready; will retry on next funnel page");
|
|
5222
|
+
return;
|
|
5223
|
+
}
|
|
4820
5224
|
// orderData persists across upsell page loads — fire the primary Purchase
|
|
4821
5225
|
// Pixel once per order. (Meta also dedupes by event_id over 48h, so this is
|
|
4822
5226
|
// belt-and-suspenders against a per-page re-fire.)
|
|
@@ -4876,14 +5280,20 @@ var Datalyr = (function (exports) {
|
|
|
4876
5280
|
}
|
|
4877
5281
|
};
|
|
4878
5282
|
const buildBridgeParams = () => {
|
|
5283
|
+
var _a, _b;
|
|
4879
5284
|
const attribution = this.attribution.getAttributionData();
|
|
4880
5285
|
const out = {};
|
|
4881
5286
|
const vid = this.identity.getAnonymousId();
|
|
4882
5287
|
const fbc = this.cookies.get("_fbc") || attribution._fbc;
|
|
4883
5288
|
const fbp = this.cookies.get("_fbp") || attribution._fbp;
|
|
4884
|
-
|
|
5289
|
+
// FSR-104: read the named click-id fields directly (captureAttribution stores every
|
|
5290
|
+
// present click id under its own key) so a landing URL carrying BOTH fbclid and
|
|
5291
|
+
// gclid bridges both — the old `clickIdType === 'fbclid' ? clickId : null` dropped
|
|
5292
|
+
// gclid whenever fbclid was the primary, silently losing Google attribution on the
|
|
5293
|
+
// cross-domain CC path. Fall back to the primary clickId for older stored shapes.
|
|
5294
|
+
const fbclid = (_a = attribution.fbclid) !== null && _a !== void 0 ? _a : (attribution.clickIdType === "fbclid" ? attribution.clickId : null);
|
|
4885
5295
|
const fbclidAt = this.cookies.get("_dl_fbclid_at");
|
|
4886
|
-
const gclid = attribution.clickIdType === "gclid" ? attribution.clickId : null;
|
|
5296
|
+
const gclid = (_b = attribution.gclid) !== null && _b !== void 0 ? _b : (attribution.clickIdType === "gclid" ? attribution.clickId : null);
|
|
4887
5297
|
const gclidAt = this.cookies.get("_dl_gclid_at");
|
|
4888
5298
|
if (vid)
|
|
4889
5299
|
out._dl_vid = vid;
|
|
@@ -4981,23 +5391,190 @@ var Datalyr = (function (exports) {
|
|
|
4981
5391
|
}
|
|
4982
5392
|
this.log("Checkout Champ outbound-link sync active for:", lowerDomains);
|
|
4983
5393
|
}
|
|
5394
|
+
/**
|
|
5395
|
+
* Stripe Payment Link auto-decoration (D1). Structurally mirrors
|
|
5396
|
+
* syncOutboundLinkParams: stampAll on init, debounced MutationObserver for
|
|
5397
|
+
* SPA-rendered anchors, capture-phase click re-stamp + queue flush, disposer
|
|
5398
|
+
* wired into destroy()/pagehide.
|
|
5399
|
+
*
|
|
5400
|
+
* Match: exact-host equality against buy.stripe.com + config.stripeLinkDomains
|
|
5401
|
+
* via the URL API — NEVER substring (a[href*=...] would match
|
|
5402
|
+
* evil.com/buy.stripe.com paths) and NEVER checkout.stripe.com (Checkout
|
|
5403
|
+
* Session URLs ignore these params — the session is already created).
|
|
5404
|
+
*
|
|
5405
|
+
* Stamp (URL API preserves existing query + hash):
|
|
5406
|
+
* - client_reference_id=<visitorId> only if the param is absent (merchant-set
|
|
5407
|
+
* wins) and the id passes the Stripe format guard (Stripe silently DROPS
|
|
5408
|
+
* invalid values — don't write garbage into merchant links).
|
|
5409
|
+
* - prefilled_email=<email> only if identified email exists, the param is
|
|
5410
|
+
* absent, locked_prefilled_email is absent, and privacyMode !== 'strict'.
|
|
5411
|
+
* - <stripe-pricing-table>/<stripe-buy-button>: client-reference-id attribute
|
|
5412
|
+
* if absent.
|
|
5413
|
+
*
|
|
5414
|
+
* The decorated link's checkout.session.completed arrives with
|
|
5415
|
+
* client_reference_id, which stripe-connect.js already reads — zero server
|
|
5416
|
+
* changes. window.open(paymentLink) is not covered (use getVisitorId()
|
|
5417
|
+
* manually); iframe/shadow-DOM links are unreachable from document.
|
|
5418
|
+
*/
|
|
5419
|
+
syncStripePaymentLinks(extraDomains) {
|
|
5420
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
5421
|
+
return;
|
|
5422
|
+
const hosts = new Set(["buy.stripe.com"]);
|
|
5423
|
+
for (const d of extraDomains) {
|
|
5424
|
+
const h = String(d).trim().toLowerCase();
|
|
5425
|
+
if (h)
|
|
5426
|
+
hosts.add(h);
|
|
5427
|
+
}
|
|
5428
|
+
const matchesPaymentLinkHost = (href) => {
|
|
5429
|
+
try {
|
|
5430
|
+
const host = new URL(href, window.location.href).hostname.toLowerCase();
|
|
5431
|
+
return hosts.has(host); // exact-host equality only
|
|
5432
|
+
}
|
|
5433
|
+
catch (_a) {
|
|
5434
|
+
return false;
|
|
5435
|
+
}
|
|
5436
|
+
};
|
|
5437
|
+
// Stripe accepts alphanumeric + `-` + `_`, max 200 chars, and silently drops
|
|
5438
|
+
// invalid values. Legacy/foreign persisted cookie ids are unconstrained —
|
|
5439
|
+
// skip silently rather than stamp a value Stripe would discard.
|
|
5440
|
+
const CLIENT_REFERENCE_ID_RE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
5441
|
+
const getClientReferenceId = () => {
|
|
5442
|
+
const vid = this.identity.getAnonymousId();
|
|
5443
|
+
return vid && CLIENT_REFERENCE_ID_RE.test(vid) ? vid : null;
|
|
5444
|
+
};
|
|
5445
|
+
const getPrefilledEmail = () => {
|
|
5446
|
+
var _a;
|
|
5447
|
+
if (this.config.privacyMode === "strict")
|
|
5448
|
+
return null;
|
|
5449
|
+
const email = (_a = this.userProperties) === null || _a === void 0 ? void 0 : _a.email;
|
|
5450
|
+
return typeof email === "string" && email ? email : null;
|
|
5451
|
+
};
|
|
5452
|
+
const stampLink = (anchor) => {
|
|
5453
|
+
if (!anchor.href || !matchesPaymentLinkHost(anchor.href))
|
|
5454
|
+
return;
|
|
5455
|
+
try {
|
|
5456
|
+
const u = new URL(anchor.href, window.location.href);
|
|
5457
|
+
let mutated = false;
|
|
5458
|
+
const vid = getClientReferenceId();
|
|
5459
|
+
if (vid && !u.searchParams.get("client_reference_id")) {
|
|
5460
|
+
u.searchParams.set("client_reference_id", vid);
|
|
5461
|
+
mutated = true;
|
|
5462
|
+
}
|
|
5463
|
+
const email = getPrefilledEmail();
|
|
5464
|
+
if (email &&
|
|
5465
|
+
!u.searchParams.get("prefilled_email") &&
|
|
5466
|
+
!u.searchParams.get("locked_prefilled_email")) {
|
|
5467
|
+
// URL API percent-encodes automatically.
|
|
5468
|
+
u.searchParams.set("prefilled_email", email);
|
|
5469
|
+
mutated = true;
|
|
5470
|
+
}
|
|
5471
|
+
if (mutated)
|
|
5472
|
+
anchor.href = u.toString();
|
|
5473
|
+
}
|
|
5474
|
+
catch (_a) {
|
|
5475
|
+
// Ignore malformed URLs — don't break the page.
|
|
5476
|
+
}
|
|
5477
|
+
};
|
|
5478
|
+
const stampEmbeds = () => {
|
|
5479
|
+
const vid = getClientReferenceId();
|
|
5480
|
+
if (!vid)
|
|
5481
|
+
return;
|
|
5482
|
+
document.querySelectorAll("stripe-pricing-table, stripe-buy-button").forEach((el) => {
|
|
5483
|
+
if (!el.getAttribute("client-reference-id")) {
|
|
5484
|
+
el.setAttribute("client-reference-id", vid);
|
|
5485
|
+
}
|
|
5486
|
+
});
|
|
5487
|
+
};
|
|
5488
|
+
const stampAll = () => {
|
|
5489
|
+
document.querySelectorAll("a[href]").forEach((el) => stampLink(el));
|
|
5490
|
+
stampEmbeds();
|
|
5491
|
+
};
|
|
5492
|
+
const onClick = (e) => {
|
|
5493
|
+
var _a, _b;
|
|
5494
|
+
const target = (_a = e.target) === null || _a === void 0 ? void 0 : _a.closest("a[href]");
|
|
5495
|
+
if (!target)
|
|
5496
|
+
return;
|
|
5497
|
+
const anchor = target;
|
|
5498
|
+
if (!matchesPaymentLinkHost(anchor.href))
|
|
5499
|
+
return;
|
|
5500
|
+
stampLink(anchor); // re-stamp in case identify() happened since DOMReady
|
|
5501
|
+
try {
|
|
5502
|
+
(_b = this.queue) === null || _b === void 0 ? void 0 : _b.flush();
|
|
5503
|
+
}
|
|
5504
|
+
catch (_c) {
|
|
5505
|
+
// Best-effort — never block navigation.
|
|
5506
|
+
}
|
|
5507
|
+
};
|
|
5508
|
+
stampAll();
|
|
5509
|
+
document.addEventListener("click", onClick, true);
|
|
5510
|
+
try {
|
|
5511
|
+
// Debounce re-stamping (same rationale as the CC sync): a busy SPA can fire
|
|
5512
|
+
// thousands of mutations per second; 150ms is small enough to catch links
|
|
5513
|
+
// before the user can click them, large enough to coalesce bursts. The
|
|
5514
|
+
// click-time re-stamp is the final safety net — no pushState hook needed.
|
|
5515
|
+
let restampTimer = null;
|
|
5516
|
+
const scheduleRestamp = () => {
|
|
5517
|
+
if (restampTimer != null)
|
|
5518
|
+
return;
|
|
5519
|
+
restampTimer = setTimeout(() => {
|
|
5520
|
+
restampTimer = null;
|
|
5521
|
+
stampAll();
|
|
5522
|
+
}, 150);
|
|
5523
|
+
};
|
|
5524
|
+
const observer = new MutationObserver(scheduleRestamp);
|
|
5525
|
+
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
5526
|
+
// Observer + click listener live for the session; pagehide cleans up on
|
|
5527
|
+
// full-page unload, and destroy() tears them down early via this disposer
|
|
5528
|
+
// (otherwise destroy()+re-init() leaks the observer + capturing listener).
|
|
5529
|
+
this.stripeLinksDisposer = () => {
|
|
5530
|
+
try {
|
|
5531
|
+
observer.disconnect();
|
|
5532
|
+
}
|
|
5533
|
+
catch ( /* idempotent */_a) { /* idempotent */ }
|
|
5534
|
+
if (restampTimer != null) {
|
|
5535
|
+
clearTimeout(restampTimer);
|
|
5536
|
+
restampTimer = null;
|
|
5537
|
+
}
|
|
5538
|
+
document.removeEventListener("click", onClick, true);
|
|
5539
|
+
};
|
|
5540
|
+
window.addEventListener("pagehide", () => { var _a; return (_a = this.stripeLinksDisposer) === null || _a === void 0 ? void 0 : _a.call(this); }, { once: true });
|
|
5541
|
+
}
|
|
5542
|
+
catch (error) {
|
|
5543
|
+
this.log("MutationObserver setup failed (Stripe Payment Link sync):", error);
|
|
5544
|
+
}
|
|
5545
|
+
this.log("Stripe Payment Link auto-decoration active for:", Array.from(hosts));
|
|
5546
|
+
}
|
|
4984
5547
|
/**
|
|
4985
5548
|
* Create event payload
|
|
4986
5549
|
*/
|
|
4987
5550
|
createEventPayload(eventName, properties, eventIdArg) {
|
|
4988
5551
|
// NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
|
|
4989
|
-
// The session id changes on
|
|
4990
|
-
// identity only synced it at init/start — so the top-level
|
|
4991
|
-
// identity)
|
|
4992
|
-
// here, before reading either, so every event carries a
|
|
5552
|
+
// The session id changes on session timeout (and previously on identify(), removed in
|
|
5553
|
+
// FSR-53), but identity only synced it at init/start — so the top-level
|
|
5554
|
+
// payload.session_id (from identity) could disagree with event_data.session_id (from
|
|
5555
|
+
// session.getMetrics()). Sync here, before reading either, so every event carries a
|
|
5556
|
+
// single consistent session id.
|
|
4993
5557
|
this.identity.setSessionId(this.session.getSessionId());
|
|
4994
5558
|
// Sanitize and merge properties
|
|
4995
5559
|
const sanitizedProperties = sanitizeEventData(properties);
|
|
4996
5560
|
const eventData = deepMerge({}, this.superProperties, sanitizedProperties);
|
|
4997
|
-
//
|
|
5561
|
+
// FSR-105: caller-passed properties take precedence over auto-captured context.
|
|
5562
|
+
// Previously Object.assign clobbered them — e.g. track('purchase', { source: 'pos',
|
|
5563
|
+
// campaign: 'spring-sale' }) shipped with the URL-derived source/campaign instead.
|
|
5564
|
+
// Fill ONLY the attribution/browser-context keys the caller didn't set. (session
|
|
5565
|
+
// metrics + fingerprint stay SDK-authoritative below: session_id is a join key.)
|
|
5566
|
+
const assignMissing = (target, source) => {
|
|
5567
|
+
for (const k in source) {
|
|
5568
|
+
if (Object.prototype.hasOwnProperty.call(source, k) &&
|
|
5569
|
+
!Object.prototype.hasOwnProperty.call(target, k)) {
|
|
5570
|
+
target[k] = source[k];
|
|
5571
|
+
}
|
|
5572
|
+
}
|
|
5573
|
+
};
|
|
5574
|
+
// Add attribution data (caller wins on collisions)
|
|
4998
5575
|
const attributionData = this.attribution.getAttributionData();
|
|
4999
|
-
|
|
5000
|
-
// Add session metrics
|
|
5576
|
+
assignMissing(eventData, attributionData);
|
|
5577
|
+
// Add session metrics (SDK-authoritative — session_id et al. must not be overridden)
|
|
5001
5578
|
const sessionMetrics = this.session.getMetrics();
|
|
5002
5579
|
Object.assign(eventData, sessionMetrics);
|
|
5003
5580
|
// Add fingerprint data if enabled
|
|
@@ -5007,8 +5584,8 @@ var Datalyr = (function (exports) {
|
|
|
5007
5584
|
device_fingerprint: fingerprintData // Use snake_case only (matches backend)
|
|
5008
5585
|
});
|
|
5009
5586
|
}
|
|
5010
|
-
// Add browser context
|
|
5011
|
-
|
|
5587
|
+
// Add browser context (caller wins on collisions)
|
|
5588
|
+
assignMissing(eventData, {
|
|
5012
5589
|
url: window.location.href,
|
|
5013
5590
|
path: window.location.pathname,
|
|
5014
5591
|
referrer: document.referrer,
|
|
@@ -5046,8 +5623,10 @@ var Datalyr = (function (exports) {
|
|
|
5046
5623
|
// Resolution metadata
|
|
5047
5624
|
resolution_method: 'browser_sdk',
|
|
5048
5625
|
resolution_confidence: 1.0,
|
|
5049
|
-
// SDK metadata
|
|
5050
|
-
|
|
5626
|
+
// SDK metadata. MUST stay in sync with package.json "version" — the build guard
|
|
5627
|
+
// (scripts/check-bundle.js, run by build:check) fails the build if the bundle's
|
|
5628
|
+
// sdk_version doesn't match package.json. (FSR-103)
|
|
5629
|
+
sdk_version: '1.7.4',
|
|
5051
5630
|
sdk_name: 'datalyr-web-sdk'
|
|
5052
5631
|
};
|
|
5053
5632
|
return payload;
|
|
@@ -5132,10 +5711,13 @@ var Datalyr = (function (exports) {
|
|
|
5132
5711
|
setupUnloadHandler() {
|
|
5133
5712
|
// Store refs so destroy() can remove these — otherwise a destroy()+re-init() cycle
|
|
5134
5713
|
// leaks listeners that keep firing forceFlush() on the OLD (destroyed) queue. (H2)
|
|
5135
|
-
|
|
5714
|
+
// FSR-15: pagehide/beforeunload are TERMINAL (beacon both queues, keep the persisted
|
|
5715
|
+
// copy); a visibilitychange:hidden is just a tab switch / background, so use the
|
|
5716
|
+
// non-terminal path (response-checked fetch drain that never erases the backlog).
|
|
5717
|
+
this.unloadHandler = () => { this.queue.forceFlush(true); };
|
|
5136
5718
|
this.visibilityHandler = () => {
|
|
5137
5719
|
if (document.visibilityState === 'hidden')
|
|
5138
|
-
this.queue.forceFlush();
|
|
5720
|
+
this.queue.forceFlush(false);
|
|
5139
5721
|
};
|
|
5140
5722
|
// Use multiple events for maximum compatibility.
|
|
5141
5723
|
window.addEventListener('beforeunload', this.unloadHandler);
|
|
@@ -5223,7 +5805,11 @@ var Datalyr = (function (exports) {
|
|
|
5223
5805
|
* Debug logging
|
|
5224
5806
|
*/
|
|
5225
5807
|
log(...args) {
|
|
5226
|
-
|
|
5808
|
+
var _a;
|
|
5809
|
+
// FSR-51: optional-chain config — log() is reachable before init() (e.g.
|
|
5810
|
+
// setSuperProperties() called pre-init), where this.config is undefined; the bare
|
|
5811
|
+
// `this.config.debug` threw an uncaught TypeError into the caller.
|
|
5812
|
+
if ((_a = this.config) === null || _a === void 0 ? void 0 : _a.debug) {
|
|
5227
5813
|
console.log('[Datalyr]', ...args);
|
|
5228
5814
|
}
|
|
5229
5815
|
}
|
|
@@ -5261,6 +5847,10 @@ var Datalyr = (function (exports) {
|
|
|
5261
5847
|
this.outboundDisposer();
|
|
5262
5848
|
this.outboundDisposer = undefined;
|
|
5263
5849
|
}
|
|
5850
|
+
if (this.stripeLinksDisposer) {
|
|
5851
|
+
this.stripeLinksDisposer();
|
|
5852
|
+
this.stripeLinksDisposer = undefined;
|
|
5853
|
+
}
|
|
5264
5854
|
// Clean up queue
|
|
5265
5855
|
if (this.queue) {
|
|
5266
5856
|
this.queue.destroy();
|