@datalyr/web 1.7.1 → 1.7.3
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 +8 -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 +673 -225
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +673 -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 +673 -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.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/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.3
|
|
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
|
+
]);
|
|
875
|
+
/**
|
|
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
|
+
}
|
|
762
902
|
/**
|
|
763
|
-
* Get root domain for cross-subdomain tracking
|
|
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
|
|
@@ -1362,7 +1508,7 @@ var Datalyr = (function (exports) {
|
|
|
1362
1508
|
'msclkid', // Microsoft/Bing
|
|
1363
1509
|
'twclid', // Twitter/X
|
|
1364
1510
|
'li_fat_id', // LinkedIn
|
|
1365
|
-
'sclid', // Snapchat
|
|
1511
|
+
'sclid', // Snapchat (canonical internal name — see CLICK_ID_ALIASES)
|
|
1366
1512
|
'dclid', // Google Display/DoubleClick
|
|
1367
1513
|
'epik', // Pinterest
|
|
1368
1514
|
'rdt_cid', // Reddit
|
|
@@ -1370,6 +1516,15 @@ var Datalyr = (function (exports) {
|
|
|
1370
1516
|
'irclid', // Impact Radius
|
|
1371
1517
|
'ko_click_id' // Klaviyo
|
|
1372
1518
|
];
|
|
1519
|
+
// Real ad-platform URL params whose value we store under a canonical internal
|
|
1520
|
+
// name. Snapchat appends &ScCid= (alias `sccid`) to the landing URL — it does
|
|
1521
|
+
// NOT use `sclid`. Query-param lookup is case-sensitive, so capture the real
|
|
1522
|
+
// params and normalize to `sclid`, which ingest / the MV / server-side
|
|
1523
|
+
// attribution all key on. Without this, real Snap ad clicks captured nothing.
|
|
1524
|
+
this.CLICK_ID_ALIASES = {
|
|
1525
|
+
ScCid: 'sclid',
|
|
1526
|
+
sccid: 'sclid',
|
|
1527
|
+
};
|
|
1373
1528
|
// Default tracked params matching dl.js
|
|
1374
1529
|
this.DEFAULT_TRACKED_PARAMS = [
|
|
1375
1530
|
'lyr', // Datalyr partner tracking
|
|
@@ -1403,12 +1558,19 @@ var Datalyr = (function (exports) {
|
|
|
1403
1558
|
const attribution = {
|
|
1404
1559
|
timestamp: Date.now()
|
|
1405
1560
|
};
|
|
1406
|
-
// 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).
|
|
1407
1568
|
for (const utm of this.UTM_PARAMS) {
|
|
1408
1569
|
const value = params[utm];
|
|
1409
1570
|
if (value) {
|
|
1571
|
+
attribution[utm] = value; // canonical: utm_source, utm_campaign, ...
|
|
1410
1572
|
const key = utm.replace('utm_', '');
|
|
1411
|
-
attribution[key] = value;
|
|
1573
|
+
attribution[key] = value; // alias: source, campaign, ...
|
|
1412
1574
|
}
|
|
1413
1575
|
}
|
|
1414
1576
|
// Capture click IDs. The first present (by CLICK_IDS priority) is the primary
|
|
@@ -1426,6 +1588,18 @@ var Datalyr = (function (exports) {
|
|
|
1426
1588
|
attribution[clickId] = value;
|
|
1427
1589
|
}
|
|
1428
1590
|
}
|
|
1591
|
+
// Aliased click IDs (e.g. Snap's ScCid/sccid → canonical sclid). Param lookup
|
|
1592
|
+
// above is case-sensitive and uses canonical names, so check the real params here.
|
|
1593
|
+
for (const [param, canonical] of Object.entries(this.CLICK_ID_ALIASES)) {
|
|
1594
|
+
const value = params[param];
|
|
1595
|
+
if (value && !attribution[canonical]) {
|
|
1596
|
+
if (!attribution.clickId) {
|
|
1597
|
+
attribution.clickId = value;
|
|
1598
|
+
attribution.clickIdType = canonical;
|
|
1599
|
+
}
|
|
1600
|
+
attribution[canonical] = value;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1429
1603
|
// Capture custom tracked parameters
|
|
1430
1604
|
for (const param of this.trackedParams) {
|
|
1431
1605
|
const value = params[param];
|
|
@@ -1550,6 +1724,9 @@ var Datalyr = (function (exports) {
|
|
|
1550
1724
|
// TikTok cookies
|
|
1551
1725
|
adCookies._ttp = cookies.get('_ttp');
|
|
1552
1726
|
adCookies._ttc = cookies.get('_ttc');
|
|
1727
|
+
// Snapchat first-party cookie (Snap Pixel sets _scid). Flows to the server as
|
|
1728
|
+
// cookies._scid and is sent on the Snap CAPI event as sc_cookie1 (raw).
|
|
1729
|
+
adCookies._scid = cookies.get('_scid');
|
|
1553
1730
|
// Generate _fbp if missing (Facebook browser ID)
|
|
1554
1731
|
if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
|
|
1555
1732
|
const timestamp = Date.now();
|
|
@@ -1561,33 +1738,53 @@ var Datalyr = (function (exports) {
|
|
|
1561
1738
|
// Optionally set the cookie for future use
|
|
1562
1739
|
cookies.set('_fbp', adCookies._fbp, 90);
|
|
1563
1740
|
}
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
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.
|
|
1567
1754
|
const fbclid = this.getCurrentFbclid();
|
|
1568
|
-
if (fbclid
|
|
1569
|
-
cookies.
|
|
1570
|
-
|
|
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.)
|
|
1571
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;
|
|
1572
1785
|
if (gclid && !cookies.get('_dl_gclid_at')) {
|
|
1573
1786
|
cookies.set('_dl_gclid_at', String(Date.now()), 90);
|
|
1574
1787
|
}
|
|
1575
|
-
// Generate _fbc if we have fbclid but no _fbc.
|
|
1576
|
-
// Meta's fbc format is `fb.{subdomainIndex}.{creationTime}.{fbclid}` where
|
|
1577
|
-
// creationTime is UNIX time in MILLISECONDS (matches the _fbp generation above
|
|
1578
|
-
// and the real _fbc cookie the Meta Pixel writes). Do NOT use seconds here.
|
|
1579
|
-
if (fbclid && !adCookies._fbc) {
|
|
1580
|
-
// Prefer the persisted click time when valid; fall back to now if the
|
|
1581
|
-
// cookie is missing or corrupted (e.g. user edited it to garbage).
|
|
1582
|
-
// Without this guard, Number("abc") = NaN and Meta would reject
|
|
1583
|
-
// `fb.1.NaN.{fbclid}`.
|
|
1584
|
-
const stored = cookies.get('_dl_fbclid_at');
|
|
1585
|
-
const parsed = stored ? Number(stored) : NaN;
|
|
1586
|
-
const timestamp = Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
|
|
1587
|
-
adCookies._fbc = `fb.1.${timestamp}.${fbclid}`;
|
|
1588
|
-
// Optionally set the cookie for future use
|
|
1589
|
-
cookies.set('_fbc', adCookies._fbc, 90);
|
|
1590
|
-
}
|
|
1591
1788
|
// Filter out null values for cleaner data
|
|
1592
1789
|
return Object.fromEntries(Object.entries(adCookies).filter(([_, value]) => value !== null));
|
|
1593
1790
|
}
|
|
@@ -1607,6 +1804,21 @@ var Datalyr = (function (exports) {
|
|
|
1607
1804
|
const params = this.queryParamsCache || getAllQueryParams();
|
|
1608
1805
|
return params.fbclid || null;
|
|
1609
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
|
+
}
|
|
1610
1822
|
/**
|
|
1611
1823
|
* Get attribution data for event
|
|
1612
1824
|
*/
|
|
@@ -1624,11 +1836,18 @@ var Datalyr = (function (exports) {
|
|
|
1624
1836
|
const hasRealAttribution = !!(current.clickId ||
|
|
1625
1837
|
current.campaign ||
|
|
1626
1838
|
(current.source && current.source !== 'direct'));
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
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 });
|
|
1632
1851
|
}
|
|
1633
1852
|
}
|
|
1634
1853
|
// Capture advertising cookies automatically
|
|
@@ -1748,8 +1967,10 @@ var Datalyr = (function (exports) {
|
|
|
1748
1967
|
* internal navs from being mis-classified as referral.
|
|
1749
1968
|
*/
|
|
1750
1969
|
isSameRootDomain(a, b) {
|
|
1751
|
-
|
|
1752
|
-
|
|
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);
|
|
1753
1974
|
}
|
|
1754
1975
|
/**
|
|
1755
1976
|
* Extract hostname from URL
|
|
@@ -1797,6 +2018,16 @@ var Datalyr = (function (exports) {
|
|
|
1797
2018
|
// already-overloaded server). See sendBatch / the rateLimitedUntil gate.
|
|
1798
2019
|
class RateLimitError extends Error {
|
|
1799
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
|
+
}
|
|
1800
2031
|
class EventQueue {
|
|
1801
2032
|
constructor(config) {
|
|
1802
2033
|
this.queue = [];
|
|
@@ -1863,16 +2094,30 @@ var Datalyr = (function (exports) {
|
|
|
1863
2094
|
this.log('Duplicate event suppressed:', eventName);
|
|
1864
2095
|
return;
|
|
1865
2096
|
}
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
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.
|
|
1869
2104
|
if (this.config.criticalEvents.includes(eventName)) {
|
|
1870
2105
|
this.log('Critical event, sending immediately:', eventName);
|
|
1871
|
-
|
|
1872
|
-
this.sendBatch([event])
|
|
1873
|
-
|
|
1874
|
-
//
|
|
1875
|
-
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);
|
|
1876
2121
|
});
|
|
1877
2122
|
return;
|
|
1878
2123
|
}
|
|
@@ -2021,7 +2266,14 @@ var Datalyr = (function (exports) {
|
|
|
2021
2266
|
// Remove the same slice from the live queue; the offline queue now drains on
|
|
2022
2267
|
// every periodic tick (WEB-1/WEB-2), so they are still retried, not stranded.
|
|
2023
2268
|
this.queue.splice(0, batchSize);
|
|
2024
|
-
|
|
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
|
+
}
|
|
2025
2277
|
}
|
|
2026
2278
|
finally {
|
|
2027
2279
|
this.inFlight = [];
|
|
@@ -2041,6 +2293,14 @@ var Datalyr = (function (exports) {
|
|
|
2041
2293
|
// Get current endpoint (main or fallback)
|
|
2042
2294
|
const endpoints = [this.config.endpoint, ...this.config.fallbackEndpoints];
|
|
2043
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;
|
|
2044
2304
|
try {
|
|
2045
2305
|
const response = yield fetch(currentEndpoint, {
|
|
2046
2306
|
method: 'POST',
|
|
@@ -2048,8 +2308,8 @@ var Datalyr = (function (exports) {
|
|
|
2048
2308
|
'Content-Type': 'application/json',
|
|
2049
2309
|
'X-Batch-Size': events.length.toString()
|
|
2050
2310
|
},
|
|
2051
|
-
body
|
|
2052
|
-
keepalive:
|
|
2311
|
+
body,
|
|
2312
|
+
keepalive: useKeepalive
|
|
2053
2313
|
});
|
|
2054
2314
|
if (!response.ok) {
|
|
2055
2315
|
// Handle rate limiting
|
|
@@ -2065,15 +2325,21 @@ var Datalyr = (function (exports) {
|
|
|
2065
2325
|
this.log(`Rate limited; backing off ${retryAfter}s`);
|
|
2066
2326
|
throw new RateLimitError('Rate limited (429)');
|
|
2067
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
|
+
}
|
|
2068
2334
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
2069
2335
|
}
|
|
2070
2336
|
this.log(`Batch sent successfully to ${currentEndpoint}: ${events.length} events`);
|
|
2071
2337
|
}
|
|
2072
2338
|
catch (error) {
|
|
2073
|
-
// A 429 is deliberate backpressure — do NOT retry
|
|
2074
|
-
// backoff). Propagate so the
|
|
2075
|
-
//
|
|
2076
|
-
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) {
|
|
2077
2343
|
throw error;
|
|
2078
2344
|
}
|
|
2079
2345
|
// Try next fallback endpoint if available
|
|
@@ -2177,6 +2443,38 @@ var Datalyr = (function (exports) {
|
|
|
2177
2443
|
this.offlineQueueLock = false;
|
|
2178
2444
|
}
|
|
2179
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
|
+
}
|
|
2180
2478
|
/**
|
|
2181
2479
|
* Load offline queue from storage
|
|
2182
2480
|
*/
|
|
@@ -2231,8 +2529,17 @@ var Datalyr = (function (exports) {
|
|
|
2231
2529
|
this.saveOfflineQueue();
|
|
2232
2530
|
}
|
|
2233
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
|
+
}
|
|
2234
2540
|
this.log('Failed to send offline batch:', error);
|
|
2235
|
-
//
|
|
2541
|
+
// Transient/rate-limited: put the batch back at the head and stop this drain;
|
|
2542
|
+
// the next tick (after any rateLimitedUntil window) retries.
|
|
2236
2543
|
this.offlineQueue.unshift(...batch);
|
|
2237
2544
|
this.saveOfflineQueue();
|
|
2238
2545
|
break;
|
|
@@ -2276,42 +2583,71 @@ var Datalyr = (function (exports) {
|
|
|
2276
2583
|
};
|
|
2277
2584
|
}
|
|
2278
2585
|
/**
|
|
2279
|
-
* Force flush (
|
|
2280
|
-
*
|
|
2281
|
-
* and persist any chunk the browser refuses so it survives to the next page load.
|
|
2282
|
-
* Previously this beaconed the live queue as one blob — ignoring the offline queue
|
|
2283
|
-
* 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).
|
|
2284
2588
|
*
|
|
2285
|
-
*
|
|
2286
|
-
*
|
|
2287
|
-
*
|
|
2288
|
-
*
|
|
2289
|
-
*
|
|
2290
|
-
*
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
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).
|
|
2293
2610
|
*/
|
|
2294
2611
|
forceFlush() {
|
|
2295
|
-
return __awaiter(this,
|
|
2612
|
+
return __awaiter(this, arguments, void 0, function* (isTerminal = false) {
|
|
2296
2613
|
if (!this.enabled)
|
|
2297
2614
|
return;
|
|
2298
|
-
if (!
|
|
2299
|
-
//
|
|
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;
|
|
2300
2622
|
yield this.flush();
|
|
2301
2623
|
yield this.processOfflineQueue();
|
|
2302
2624
|
return;
|
|
2303
2625
|
}
|
|
2626
|
+
// Terminal unload.
|
|
2304
2627
|
// Exclude the in-flight batch (its keepalive fetch already carries it).
|
|
2305
2628
|
const live = this.queue.slice(this.inFlight.length);
|
|
2306
2629
|
const pending = [...live, ...this.offlineQueue];
|
|
2307
|
-
// Detach
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
// 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)
|
|
2311
2633
|
this.queue = this.queue.slice(0, this.inFlight.length);
|
|
2312
2634
|
this.offlineQueue = [];
|
|
2313
2635
|
if (pending.length === 0)
|
|
2314
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
|
|
2315
2651
|
const MAX_BEACON_BYTES = 60000; // headroom under the browser's ~64KB cap
|
|
2316
2652
|
// Greedily pack events into chunks bounded by BOTH batchSize and byte size.
|
|
2317
2653
|
const chunks = [];
|
|
@@ -2332,36 +2668,18 @@ var Datalyr = (function (exports) {
|
|
|
2332
2668
|
}
|
|
2333
2669
|
if (current.length > 0)
|
|
2334
2670
|
chunks.push(current);
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
let
|
|
2338
|
-
for (
|
|
2339
|
-
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))], {
|
|
2340
2676
|
type: 'application/json'
|
|
2341
2677
|
});
|
|
2342
|
-
if (!navigator.sendBeacon(this.config.endpoint, blob))
|
|
2343
|
-
failedFrom = c;
|
|
2678
|
+
if (!navigator.sendBeacon(this.config.endpoint, blob))
|
|
2344
2679
|
break;
|
|
2345
|
-
|
|
2346
|
-
}
|
|
2347
|
-
if (failedFrom >= 0) {
|
|
2348
|
-
const remainder = chunks
|
|
2349
|
-
.slice(failedFrom)
|
|
2350
|
-
.reduce((acc, c) => acc.concat(c), []);
|
|
2351
|
-
// Persist straight to storage (NOT this.offlineQueue) so a repeat unload event
|
|
2352
|
-
// won't re-beacon it; the next page load's drain (which uses a normal fetch with
|
|
2353
|
-
// no 64KB cap) delivers it. MED-2: log if the maxOfflineQueueSize cap truncates.
|
|
2354
|
-
const toPersist = remainder.slice(-this.config.maxOfflineQueueSize);
|
|
2355
|
-
if (remainder.length > toPersist.length) {
|
|
2356
|
-
this.log(`Offline cap dropped ${remainder.length - toPersist.length} oldest events at unload`);
|
|
2357
|
-
}
|
|
2358
|
-
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2359
|
-
this.log(`sendBeacon refused ${remainder.length} events; persisted ${toPersist.length} for next load`);
|
|
2360
|
-
}
|
|
2361
|
-
else {
|
|
2362
|
-
this.log('Events sent via sendBeacon');
|
|
2363
|
-
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2680
|
+
beaconed += chunk.length;
|
|
2364
2681
|
}
|
|
2682
|
+
this.log(`forceFlush(terminal): beaconed ${beaconed}/${pending.length} events; persisted copy retained for next-load drain`);
|
|
2365
2683
|
});
|
|
2366
2684
|
}
|
|
2367
2685
|
/**
|
|
@@ -2601,17 +2919,33 @@ var Datalyr = (function (exports) {
|
|
|
2601
2919
|
if (this.initialized)
|
|
2602
2920
|
return;
|
|
2603
2921
|
try {
|
|
2604
|
-
//
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
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
|
+
}
|
|
2615
2949
|
if (!response.ok) {
|
|
2616
2950
|
throw new Error(`Failed to fetch container scripts: ${response.status}`);
|
|
2617
2951
|
}
|
|
@@ -3082,6 +3416,16 @@ var Datalyr = (function (exports) {
|
|
|
3082
3416
|
this.log('Error initializing TikTok Pixel:', error);
|
|
3083
3417
|
}
|
|
3084
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
|
+
}
|
|
3085
3429
|
/**
|
|
3086
3430
|
* Track event to all initialized pixels
|
|
3087
3431
|
*/
|
|
@@ -3389,28 +3733,59 @@ var Datalyr = (function (exports) {
|
|
|
3389
3733
|
setupFormMonitoring() {
|
|
3390
3734
|
// Monitor existing forms
|
|
3391
3735
|
this.scanForEmailForms();
|
|
3392
|
-
// Watch for new forms (SPAs)
|
|
3393
|
-
|
|
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(() => {
|
|
3394
3740
|
this.scanForEmailForms();
|
|
3395
3741
|
});
|
|
3396
|
-
|
|
3742
|
+
this.formObserver.observe(document.body, {
|
|
3397
3743
|
childList: true,
|
|
3398
3744
|
subtree: true
|
|
3399
3745
|
});
|
|
3400
3746
|
this.log('Form monitoring active');
|
|
3401
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
|
+
}
|
|
3402
3775
|
/**
|
|
3403
3776
|
* Scan DOM for forms with email inputs
|
|
3404
3777
|
*/
|
|
3405
3778
|
scanForEmailForms() {
|
|
3779
|
+
if (this.destroyed)
|
|
3780
|
+
return; // FSR-101: defense-in-depth after disconnect
|
|
3406
3781
|
const forms = document.querySelectorAll('form');
|
|
3407
3782
|
forms.forEach(form => {
|
|
3408
3783
|
// Skip if already listening
|
|
3409
3784
|
if (this.formListeners.some(l => l.element === form)) {
|
|
3410
3785
|
return;
|
|
3411
3786
|
}
|
|
3412
|
-
//
|
|
3413
|
-
const emailInput =
|
|
3787
|
+
// Only monitor forms with a capturable VISITOR email field (FSR-49).
|
|
3788
|
+
const emailInput = this.findVisitorEmailInput(form);
|
|
3414
3789
|
if (emailInput) {
|
|
3415
3790
|
const handler = (e) => this.handleFormSubmit(e, form);
|
|
3416
3791
|
form.addEventListener('submit', handler);
|
|
@@ -3424,8 +3799,8 @@ var Datalyr = (function (exports) {
|
|
|
3424
3799
|
*/
|
|
3425
3800
|
handleFormSubmit(_event, form) {
|
|
3426
3801
|
try {
|
|
3427
|
-
// Find email input
|
|
3428
|
-
const emailInput =
|
|
3802
|
+
// Find the visitor's own email input (excludes gift/recipient fields — FSR-49).
|
|
3803
|
+
const emailInput = this.findVisitorEmailInput(form);
|
|
3429
3804
|
if (!emailInput)
|
|
3430
3805
|
return;
|
|
3431
3806
|
const email = emailInput.value.trim();
|
|
@@ -3767,6 +4142,12 @@ var Datalyr = (function (exports) {
|
|
|
3767
4142
|
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
|
3768
4143
|
this.originalXHRSend = undefined;
|
|
3769
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
|
+
}
|
|
3770
4151
|
// Remove form listeners
|
|
3771
4152
|
this.formListeners.forEach(({ element, handler }) => {
|
|
3772
4153
|
element.removeEventListener('submit', handler);
|
|
@@ -3788,6 +4169,10 @@ var Datalyr = (function (exports) {
|
|
|
3788
4169
|
}
|
|
3789
4170
|
}
|
|
3790
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;
|
|
3791
4176
|
|
|
3792
4177
|
/** Keys the remote config is allowed to fill on DatalyrConfig. */
|
|
3793
4178
|
const REMOTE_KEYS = [
|
|
@@ -3899,8 +4284,11 @@ var Datalyr = (function (exports) {
|
|
|
3899
4284
|
if (this.config.platform === 'checkoutchamp') {
|
|
3900
4285
|
this.restoreFromURL();
|
|
3901
4286
|
}
|
|
3902
|
-
// Initialize modules
|
|
3903
|
-
|
|
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() });
|
|
3904
4292
|
this.session = new SessionManager(this.config.sessionTimeout);
|
|
3905
4293
|
this.attribution = new AttributionManager({
|
|
3906
4294
|
attributionWindow: this.config.attributionWindow,
|
|
@@ -4222,10 +4610,13 @@ var Datalyr = (function (exports) {
|
|
|
4222
4610
|
return;
|
|
4223
4611
|
}
|
|
4224
4612
|
try {
|
|
4225
|
-
//
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4613
|
+
// FSR-53: do NOT rotate the session id on identify(). 'Session fixation' is an
|
|
4614
|
+
// auth-credential concept; a client-generated analytics session_id is not one.
|
|
4615
|
+
// Rotating split every identified visit (and every auto-identify form submit) into
|
|
4616
|
+
// two session_ids — inflating server-side uniq(session_id) counts by +1 per
|
|
4617
|
+
// identified visitor and landing the post-login conversion in a session with zero
|
|
4618
|
+
// preceding pageviews. Matches iOS/RN (no rotate-on-identify) and every major
|
|
4619
|
+
// analytics SDK; keeps session-scoped attribution joins intact.
|
|
4229
4620
|
// Update identity
|
|
4230
4621
|
const identityLink = this.identity.identify(userId, traits);
|
|
4231
4622
|
// SEC-03 Fix: Store user properties encrypted (contains PII)
|
|
@@ -4375,6 +4766,15 @@ var Datalyr = (function (exports) {
|
|
|
4375
4766
|
// Clear the journey too, or the next user's touchpoints append to the previous
|
|
4376
4767
|
// user's (cross-user contamination of touchpoint_count / days_since_first_touch).
|
|
4377
4768
|
storage.remove('dl_journey');
|
|
4769
|
+
// FSR-106: clear first/last-touch attribution as well — same cross-user-contamination
|
|
4770
|
+
// rationale as the journey. Otherwise the next anon visitor's events carry the
|
|
4771
|
+
// PREVIOUS user's first_touch_*/last_touch_* (via getAttributionData's fallback),
|
|
4772
|
+
// crediting user B's conversions to the ad click that brought user A.
|
|
4773
|
+
// (Meta's _fbc/_fbp cookies are deliberately left alone — they're shared with the
|
|
4774
|
+
// merchant's own Meta Pixel and clearing them could break it; the fresh anonymous_id
|
|
4775
|
+
// already de-links the visitor server-side.)
|
|
4776
|
+
storage.remove('dl_first_touch');
|
|
4777
|
+
storage.remove('dl_last_touch');
|
|
4378
4778
|
this.session.createNewSession();
|
|
4379
4779
|
this.log('User reset');
|
|
4380
4780
|
}
|
|
@@ -4551,8 +4951,17 @@ var Datalyr = (function (exports) {
|
|
|
4551
4951
|
* Set consent preferences
|
|
4552
4952
|
*/
|
|
4553
4953
|
setConsent(consent) {
|
|
4954
|
+
// Always record + persist consent FIRST, even before init(). FSR-51: a consent-
|
|
4955
|
+
// management platform commonly calls setConsent() before init() so tracking is gated
|
|
4956
|
+
// from the very first event. The old code then dereferenced this.queue/this.config
|
|
4957
|
+
// (both undefined pre-init) inside shouldTrack()/setEnabled() and threw an uncaught
|
|
4958
|
+
// TypeError into the CMP's callback chain. init() re-reads dl_consent (and gates the
|
|
4959
|
+
// queue) at startup, so a pre-init call is honored there.
|
|
4554
4960
|
this.consent = consent;
|
|
4555
4961
|
storage.set('dl_consent', consent);
|
|
4962
|
+
if (!this.initialized) {
|
|
4963
|
+
return;
|
|
4964
|
+
}
|
|
4556
4965
|
// Enforce it live (not just persist it). Gate the queue by the full policy
|
|
4557
4966
|
// (analytics consent + opt-out + DNT/GPC), and on withdrawal purge buffered events
|
|
4558
4967
|
// too — mirroring optOut — so events captured before withdrawal can't drain if
|
|
@@ -4793,6 +5202,16 @@ var Datalyr = (function (exports) {
|
|
|
4793
5202
|
const orderId = order.orderId;
|
|
4794
5203
|
if (!orderId)
|
|
4795
5204
|
return;
|
|
5205
|
+
// FSR-102: only burn the once-per-order guard once the Meta Pixel is actually live.
|
|
5206
|
+
// trackToPixels no-ops when the container config never arrived (pixels=null after a
|
|
5207
|
+
// transient /container-scripts failure); setting the guard first would permanently
|
|
5208
|
+
// suppress the co-fire for the rest of the funnel session even when the container
|
|
5209
|
+
// loads on a later upsell page. orderData persists across upsell loads, so a later
|
|
5210
|
+
// page retries. Meta's 48h event_id dedup still protects against a double-fire.
|
|
5211
|
+
if (!this.container.hasMetaPixel()) {
|
|
5212
|
+
this.log("CC Purchase Pixel: Meta pixel not ready; will retry on next funnel page");
|
|
5213
|
+
return;
|
|
5214
|
+
}
|
|
4796
5215
|
// orderData persists across upsell page loads — fire the primary Purchase
|
|
4797
5216
|
// Pixel once per order. (Meta also dedupes by event_id over 48h, so this is
|
|
4798
5217
|
// belt-and-suspenders against a per-page re-fire.)
|
|
@@ -4852,14 +5271,20 @@ var Datalyr = (function (exports) {
|
|
|
4852
5271
|
}
|
|
4853
5272
|
};
|
|
4854
5273
|
const buildBridgeParams = () => {
|
|
5274
|
+
var _a, _b;
|
|
4855
5275
|
const attribution = this.attribution.getAttributionData();
|
|
4856
5276
|
const out = {};
|
|
4857
5277
|
const vid = this.identity.getAnonymousId();
|
|
4858
5278
|
const fbc = this.cookies.get("_fbc") || attribution._fbc;
|
|
4859
5279
|
const fbp = this.cookies.get("_fbp") || attribution._fbp;
|
|
4860
|
-
|
|
5280
|
+
// FSR-104: read the named click-id fields directly (captureAttribution stores every
|
|
5281
|
+
// present click id under its own key) so a landing URL carrying BOTH fbclid and
|
|
5282
|
+
// gclid bridges both — the old `clickIdType === 'fbclid' ? clickId : null` dropped
|
|
5283
|
+
// gclid whenever fbclid was the primary, silently losing Google attribution on the
|
|
5284
|
+
// cross-domain CC path. Fall back to the primary clickId for older stored shapes.
|
|
5285
|
+
const fbclid = (_a = attribution.fbclid) !== null && _a !== void 0 ? _a : (attribution.clickIdType === "fbclid" ? attribution.clickId : null);
|
|
4861
5286
|
const fbclidAt = this.cookies.get("_dl_fbclid_at");
|
|
4862
|
-
const gclid = attribution.clickIdType === "gclid" ? attribution.clickId : null;
|
|
5287
|
+
const gclid = (_b = attribution.gclid) !== null && _b !== void 0 ? _b : (attribution.clickIdType === "gclid" ? attribution.clickId : null);
|
|
4863
5288
|
const gclidAt = this.cookies.get("_dl_gclid_at");
|
|
4864
5289
|
if (vid)
|
|
4865
5290
|
out._dl_vid = vid;
|
|
@@ -4962,18 +5387,32 @@ var Datalyr = (function (exports) {
|
|
|
4962
5387
|
*/
|
|
4963
5388
|
createEventPayload(eventName, properties, eventIdArg) {
|
|
4964
5389
|
// NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
|
|
4965
|
-
// The session id changes on
|
|
4966
|
-
// identity only synced it at init/start — so the top-level
|
|
4967
|
-
// identity)
|
|
4968
|
-
// here, before reading either, so every event carries a
|
|
5390
|
+
// The session id changes on session timeout (and previously on identify(), removed in
|
|
5391
|
+
// FSR-53), but identity only synced it at init/start — so the top-level
|
|
5392
|
+
// payload.session_id (from identity) could disagree with event_data.session_id (from
|
|
5393
|
+
// session.getMetrics()). Sync here, before reading either, so every event carries a
|
|
5394
|
+
// single consistent session id.
|
|
4969
5395
|
this.identity.setSessionId(this.session.getSessionId());
|
|
4970
5396
|
// Sanitize and merge properties
|
|
4971
5397
|
const sanitizedProperties = sanitizeEventData(properties);
|
|
4972
5398
|
const eventData = deepMerge({}, this.superProperties, sanitizedProperties);
|
|
4973
|
-
//
|
|
5399
|
+
// FSR-105: caller-passed properties take precedence over auto-captured context.
|
|
5400
|
+
// Previously Object.assign clobbered them — e.g. track('purchase', { source: 'pos',
|
|
5401
|
+
// campaign: 'spring-sale' }) shipped with the URL-derived source/campaign instead.
|
|
5402
|
+
// Fill ONLY the attribution/browser-context keys the caller didn't set. (session
|
|
5403
|
+
// metrics + fingerprint stay SDK-authoritative below: session_id is a join key.)
|
|
5404
|
+
const assignMissing = (target, source) => {
|
|
5405
|
+
for (const k in source) {
|
|
5406
|
+
if (Object.prototype.hasOwnProperty.call(source, k) &&
|
|
5407
|
+
!Object.prototype.hasOwnProperty.call(target, k)) {
|
|
5408
|
+
target[k] = source[k];
|
|
5409
|
+
}
|
|
5410
|
+
}
|
|
5411
|
+
};
|
|
5412
|
+
// Add attribution data (caller wins on collisions)
|
|
4974
5413
|
const attributionData = this.attribution.getAttributionData();
|
|
4975
|
-
|
|
4976
|
-
// Add session metrics
|
|
5414
|
+
assignMissing(eventData, attributionData);
|
|
5415
|
+
// Add session metrics (SDK-authoritative — session_id et al. must not be overridden)
|
|
4977
5416
|
const sessionMetrics = this.session.getMetrics();
|
|
4978
5417
|
Object.assign(eventData, sessionMetrics);
|
|
4979
5418
|
// Add fingerprint data if enabled
|
|
@@ -4983,8 +5422,8 @@ var Datalyr = (function (exports) {
|
|
|
4983
5422
|
device_fingerprint: fingerprintData // Use snake_case only (matches backend)
|
|
4984
5423
|
});
|
|
4985
5424
|
}
|
|
4986
|
-
// Add browser context
|
|
4987
|
-
|
|
5425
|
+
// Add browser context (caller wins on collisions)
|
|
5426
|
+
assignMissing(eventData, {
|
|
4988
5427
|
url: window.location.href,
|
|
4989
5428
|
path: window.location.pathname,
|
|
4990
5429
|
referrer: document.referrer,
|
|
@@ -5022,8 +5461,10 @@ var Datalyr = (function (exports) {
|
|
|
5022
5461
|
// Resolution metadata
|
|
5023
5462
|
resolution_method: 'browser_sdk',
|
|
5024
5463
|
resolution_confidence: 1.0,
|
|
5025
|
-
// SDK metadata
|
|
5026
|
-
|
|
5464
|
+
// SDK metadata. MUST stay in sync with package.json "version" — the build guard
|
|
5465
|
+
// (scripts/check-bundle.js, run by build:check) fails the build if the bundle's
|
|
5466
|
+
// sdk_version doesn't match package.json. (FSR-103)
|
|
5467
|
+
sdk_version: '1.7.3',
|
|
5027
5468
|
sdk_name: 'datalyr-web-sdk'
|
|
5028
5469
|
};
|
|
5029
5470
|
return payload;
|
|
@@ -5108,10 +5549,13 @@ var Datalyr = (function (exports) {
|
|
|
5108
5549
|
setupUnloadHandler() {
|
|
5109
5550
|
// Store refs so destroy() can remove these — otherwise a destroy()+re-init() cycle
|
|
5110
5551
|
// leaks listeners that keep firing forceFlush() on the OLD (destroyed) queue. (H2)
|
|
5111
|
-
|
|
5552
|
+
// FSR-15: pagehide/beforeunload are TERMINAL (beacon both queues, keep the persisted
|
|
5553
|
+
// copy); a visibilitychange:hidden is just a tab switch / background, so use the
|
|
5554
|
+
// non-terminal path (response-checked fetch drain that never erases the backlog).
|
|
5555
|
+
this.unloadHandler = () => { this.queue.forceFlush(true); };
|
|
5112
5556
|
this.visibilityHandler = () => {
|
|
5113
5557
|
if (document.visibilityState === 'hidden')
|
|
5114
|
-
this.queue.forceFlush();
|
|
5558
|
+
this.queue.forceFlush(false);
|
|
5115
5559
|
};
|
|
5116
5560
|
// Use multiple events for maximum compatibility.
|
|
5117
5561
|
window.addEventListener('beforeunload', this.unloadHandler);
|
|
@@ -5199,7 +5643,11 @@ var Datalyr = (function (exports) {
|
|
|
5199
5643
|
* Debug logging
|
|
5200
5644
|
*/
|
|
5201
5645
|
log(...args) {
|
|
5202
|
-
|
|
5646
|
+
var _a;
|
|
5647
|
+
// FSR-51: optional-chain config — log() is reachable before init() (e.g.
|
|
5648
|
+
// setSuperProperties() called pre-init), where this.config is undefined; the bare
|
|
5649
|
+
// `this.config.debug` threw an uncaught TypeError into the caller.
|
|
5650
|
+
if ((_a = this.config) === null || _a === void 0 ? void 0 : _a.debug) {
|
|
5203
5651
|
console.log('[Datalyr]', ...args);
|
|
5204
5652
|
}
|
|
5205
5653
|
}
|