@datalyr/web 1.7.2 → 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 +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 +648 -224
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +648 -224
- 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 +648 -224
- 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.esm.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
|
|
@@ -291,6 +291,42 @@ class SafeStorage {
|
|
|
291
291
|
return defaultValue;
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* Get a value as a RAW string, skipping the JSON.parse that get() applies.
|
|
296
|
+
*
|
|
297
|
+
* FSR-17: get() blindly JSON.parses, so a stored user_id like '12345' comes back as
|
|
298
|
+
* the NUMBER 12345 after reload, and a 16+-digit snowflake id ('1234567890123456789')
|
|
299
|
+
* is silently precision-corrupted (→ 1234567890123456800) — permanent identity
|
|
300
|
+
* fragmentation. Identifiers (dl_user_id, dl_anonymous_id, dl_auto_identified_email)
|
|
301
|
+
* must round-trip as the exact string they were stored as; use this for them.
|
|
302
|
+
*/
|
|
303
|
+
getString(key, defaultValue = null) {
|
|
304
|
+
const fullKey = this.prefix + key;
|
|
305
|
+
try {
|
|
306
|
+
const value = this.storage
|
|
307
|
+
? this.storage.getItem(fullKey)
|
|
308
|
+
: (this.memory.has(fullKey) ? this.memory.get(fullKey) : null);
|
|
309
|
+
if (value === null || value === undefined)
|
|
310
|
+
return defaultValue;
|
|
311
|
+
// Values written by set() with a string are stored raw (unquoted); return as-is.
|
|
312
|
+
// A value written as JSON (quoted string) is unwrapped so callers always get the
|
|
313
|
+
// logical string, never a quoted one.
|
|
314
|
+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
|
|
315
|
+
try {
|
|
316
|
+
const parsed = JSON.parse(value);
|
|
317
|
+
if (typeof parsed === 'string')
|
|
318
|
+
return parsed;
|
|
319
|
+
}
|
|
320
|
+
catch (_a) {
|
|
321
|
+
// fall through to raw
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
catch (_b) {
|
|
327
|
+
return defaultValue;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
294
330
|
set(key, value) {
|
|
295
331
|
const fullKey = this.prefix + key;
|
|
296
332
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
@@ -471,19 +507,32 @@ class CookieStorage {
|
|
|
471
507
|
this.secure = options.secure || 'auto';
|
|
472
508
|
}
|
|
473
509
|
get(name) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
510
|
+
// FSR-56: parse document.cookie into name=value pairs and return the FIRST match.
|
|
511
|
+
// The old `split('; ' + name + '=')` returned null whenever the same cookie name
|
|
512
|
+
// appeared more than once (3+ parts) — extremely common for ad cookies set at both
|
|
513
|
+
// the registrable domain and a subdomain (_fbp/_fbc/_ga), and producible by this SDK
|
|
514
|
+
// itself (the __dl_visitor_id host-only fallback). That caused real _fbp/_fbc to be
|
|
515
|
+
// ignored and re-synthesized, and the CC bridge cookie read to silently fail.
|
|
516
|
+
// Browsers list the most specific (longer path / host) cookie first, so first wins.
|
|
517
|
+
const cookieString = typeof document !== 'undefined' ? document.cookie : '';
|
|
518
|
+
if (!cookieString)
|
|
519
|
+
return null;
|
|
520
|
+
for (const pair of cookieString.split(';')) {
|
|
521
|
+
const trimmed = pair.trim();
|
|
522
|
+
const eq = trimmed.indexOf('=');
|
|
523
|
+
if (eq === -1)
|
|
524
|
+
continue;
|
|
525
|
+
if (trimmed.slice(0, eq) !== name)
|
|
526
|
+
continue;
|
|
527
|
+
const rawValue = trimmed.slice(eq + 1);
|
|
528
|
+
if (!rawValue)
|
|
529
|
+
return null;
|
|
530
|
+
try {
|
|
531
|
+
return decodeURIComponent(rawValue);
|
|
532
|
+
}
|
|
533
|
+
catch (_a) {
|
|
534
|
+
// Return raw value if decoding fails (backwards compatibility)
|
|
535
|
+
return rawValue;
|
|
487
536
|
}
|
|
488
537
|
}
|
|
489
538
|
return null;
|
|
@@ -660,6 +709,35 @@ function getAllQueryParams(search = window.location.search) {
|
|
|
660
709
|
}
|
|
661
710
|
return params;
|
|
662
711
|
}
|
|
712
|
+
// FSR-57: credential detection that matches whole TOKENS (after splitting a key on
|
|
713
|
+
// camelCase + separators), not substrings. The old substring regex deleted innocent
|
|
714
|
+
// keys — `author`/`authority` (contains 'auth'), `passenger_count`/`passport_number`
|
|
715
|
+
// (contains 'pass'), `session_type`/`cookie_consent` — silently dropping customer event
|
|
716
|
+
// properties. 'session' and 'cookie' are intentionally absent: they're not credentials.
|
|
717
|
+
// Single words that are unambiguous as whole tokens:
|
|
718
|
+
const SENSITIVE_KEY_WORDS = new Set([
|
|
719
|
+
'pass', 'password', 'passwd', 'pwd',
|
|
720
|
+
'secret', 'token', 'auth', 'authorization',
|
|
721
|
+
'bearer', 'signature', 'cvv', 'cvc', 'ssn'
|
|
722
|
+
]);
|
|
723
|
+
// Compound credentials that tokenize into innocent-looking parts (api_key → api+key)
|
|
724
|
+
// — matched against the separator-stripped key so api_key / apiKey / access_token all hit.
|
|
725
|
+
const SENSITIVE_KEY_COMPOUNDS = [
|
|
726
|
+
'apikey', 'accesskey', 'secretkey', 'privatekey', 'publickey',
|
|
727
|
+
'accesstoken', 'refreshtoken', 'idtoken', 'clientsecret',
|
|
728
|
+
'creditcard', 'cardnumber'
|
|
729
|
+
];
|
|
730
|
+
function isSensitiveKey(key) {
|
|
731
|
+
const words = key
|
|
732
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
733
|
+
.split(/[\s._\-]+/)
|
|
734
|
+
.map(w => w.toLowerCase())
|
|
735
|
+
.filter(Boolean);
|
|
736
|
+
if (words.some(w => SENSITIVE_KEY_WORDS.has(w)))
|
|
737
|
+
return true;
|
|
738
|
+
const normalized = key.toLowerCase().replace(/[\s._\-]+/g, '');
|
|
739
|
+
return SENSITIVE_KEY_COMPOUNDS.some(c => normalized.includes(c));
|
|
740
|
+
}
|
|
663
741
|
/**
|
|
664
742
|
* Sanitize event data (remove sensitive keys, DOM elements, functions)
|
|
665
743
|
*/
|
|
@@ -681,11 +759,10 @@ function sanitizeEventData(data, maxDepth = 5, currentDepth = 0) {
|
|
|
681
759
|
// Handle objects
|
|
682
760
|
if (typeof data === 'object') {
|
|
683
761
|
const sanitized = {};
|
|
684
|
-
const sensitiveKeys = /pass|pwd|token|secret|auth|bearer|session|cookie|signature|api[-_]?key|private[-_]?key|access[-_]?token|refresh[-_]?token/i;
|
|
685
762
|
for (const key in data) {
|
|
686
763
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
687
|
-
// Skip sensitive keys
|
|
688
|
-
if (
|
|
764
|
+
// Skip sensitive keys (whole-token match — see SENSITIVE_KEY_WORDS)
|
|
765
|
+
if (isSensitiveKey(key)) {
|
|
689
766
|
continue;
|
|
690
767
|
}
|
|
691
768
|
// Sanitize value recursively
|
|
@@ -700,9 +777,14 @@ function sanitizeEventData(data, maxDepth = 5, currentDepth = 0) {
|
|
|
700
777
|
if (data.length > 1000) {
|
|
701
778
|
return data.slice(0, 1000) + '...[truncated]';
|
|
702
779
|
}
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
780
|
+
// FSR-57: redact only UNAMBIGUOUS JWTs — base64url '{"...' header always starts with
|
|
781
|
+
// 'eyJ'. The old rules redacted ANY 3-dot-segment string ('www.google.com' → so every
|
|
782
|
+
// 3-label referrer_host became '[Redacted]'; '1.7.1' semver too) and ANY 32+-char hex
|
|
783
|
+
// string (md5/order IDs, pre-hashed emails, and — critically — 32-hex customer user
|
|
784
|
+
// IDs on the $alias path, which silently wrote user_id='[Redacted]' into
|
|
785
|
+
// visitor_user_links, collapsing those users). Credential-bearing KEYS are already
|
|
786
|
+
// dropped above, so the bare-hex value heuristic was net-negative and is removed.
|
|
787
|
+
if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(data)) { // JWT
|
|
706
788
|
return '[Redacted]';
|
|
707
789
|
}
|
|
708
790
|
return data;
|
|
@@ -756,60 +838,79 @@ function isGlobalPrivacyControlEnabled() {
|
|
|
756
838
|
return navigator.globalPrivacyControl === true ||
|
|
757
839
|
window.globalPrivacyControl === true;
|
|
758
840
|
}
|
|
841
|
+
// Common two-part TLDs (country-specific registries) where the registrable domain is
|
|
842
|
+
// the last THREE labels (e.g. shop.example.co.uk → example.co.uk). Not a full public
|
|
843
|
+
// suffix list, but covers the markets we see; CookieStorage.getAutoDomain() probes
|
|
844
|
+
// dynamically for the rest.
|
|
845
|
+
const TWO_PART_TLDS = new Set([
|
|
846
|
+
// United Kingdom
|
|
847
|
+
'co.uk', 'org.uk', 'net.uk', 'ac.uk', 'gov.uk', 'me.uk',
|
|
848
|
+
// Australia
|
|
849
|
+
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au',
|
|
850
|
+
// New Zealand
|
|
851
|
+
'co.nz', 'net.nz', 'org.nz', 'govt.nz',
|
|
852
|
+
// Japan
|
|
853
|
+
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp',
|
|
854
|
+
// India
|
|
855
|
+
'co.in', 'net.in', 'org.in', 'gov.in', 'ac.in',
|
|
856
|
+
// South Africa
|
|
857
|
+
'co.za', 'net.za', 'org.za', 'gov.za',
|
|
858
|
+
// Brazil
|
|
859
|
+
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br',
|
|
860
|
+
// South Korea
|
|
861
|
+
'co.kr', 'ne.kr', 'or.kr', 'go.kr', 'ac.kr',
|
|
862
|
+
// China
|
|
863
|
+
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
|
|
864
|
+
// Other Asia-Pacific
|
|
865
|
+
'co.id', 'co.th', 'com.sg', 'com.my', 'com.ph', 'com.vn',
|
|
866
|
+
'com.tw', 'com.hk',
|
|
867
|
+
// Latin America
|
|
868
|
+
'com.mx', 'com.ar', 'com.co', 'com.pe', 'com.cl',
|
|
869
|
+
// Europe
|
|
870
|
+
'co.il', 'co.at', 'co.hu', 'co.pl'
|
|
871
|
+
]);
|
|
759
872
|
/**
|
|
760
|
-
*
|
|
873
|
+
* Registrable domain (eTLD+1) for a hostname, honoring two-part ccTLDs.
|
|
874
|
+
* shop.example.co.uk → example.co.uk
|
|
875
|
+
* www.example.com → example.com
|
|
876
|
+
* localhost / IPs → returned unchanged
|
|
877
|
+
* Returns a bare host (no leading dot) — getRootDomain() adds the cookie-domain dot.
|
|
878
|
+
* (FSR-47: same-site referrer classification must use this, not naive last-two-labels.)
|
|
879
|
+
*/
|
|
880
|
+
function getRegistrableDomain(hostname) {
|
|
881
|
+
if (!hostname)
|
|
882
|
+
return hostname;
|
|
883
|
+
const lower = hostname.toLowerCase();
|
|
884
|
+
// Handle localhost and IP addresses
|
|
885
|
+
if (lower === 'localhost' ||
|
|
886
|
+
/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(lower) || // IPv4
|
|
887
|
+
/^\[?[0-9a-fA-F:]+\]?$/.test(lower)) { // IPv6
|
|
888
|
+
return hostname;
|
|
889
|
+
}
|
|
890
|
+
const parts = lower.split('.');
|
|
891
|
+
if (parts.length < 2)
|
|
892
|
+
return hostname;
|
|
893
|
+
const lastTwo = parts.slice(-2).join('.');
|
|
894
|
+
if (TWO_PART_TLDS.has(lastTwo) && parts.length >= 3) {
|
|
895
|
+
return parts.slice(-3).join('.');
|
|
896
|
+
}
|
|
897
|
+
return parts.slice(-2).join('.');
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Get root domain for cross-subdomain tracking (cookie-domain form, with leading dot).
|
|
761
901
|
*/
|
|
762
902
|
function getRootDomain() {
|
|
763
903
|
const hostname = window.location.hostname;
|
|
764
|
-
// Handle localhost and IP addresses
|
|
904
|
+
// Handle localhost and IP addresses — never set a cookie domain for these.
|
|
765
905
|
if (hostname === 'localhost' ||
|
|
766
906
|
hostname.match(/^[0-9]{1,3}\./) || // IPv4
|
|
767
907
|
hostname.match(/^\[?[0-9a-fA-F:]+\]?$/)) { // IPv6
|
|
768
908
|
return hostname;
|
|
769
909
|
}
|
|
770
|
-
// Get root domain (last two parts: example.com)
|
|
771
910
|
const parts = hostname.split('.');
|
|
772
|
-
if (parts.length
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const sld = parts[parts.length - 2];
|
|
776
|
-
// Common two-part TLDs (country-specific domains)
|
|
777
|
-
// Note: The CookieStorage.getAutoDomain() probe method handles unknown TLDs dynamically,
|
|
778
|
-
// but this list provides faster resolution for known patterns
|
|
779
|
-
const twoPartTlds = [
|
|
780
|
-
// United Kingdom
|
|
781
|
-
'co.uk', 'org.uk', 'net.uk', 'ac.uk', 'gov.uk', 'me.uk',
|
|
782
|
-
// Australia
|
|
783
|
-
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au',
|
|
784
|
-
// New Zealand
|
|
785
|
-
'co.nz', 'net.nz', 'org.nz', 'govt.nz',
|
|
786
|
-
// Japan
|
|
787
|
-
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp',
|
|
788
|
-
// India
|
|
789
|
-
'co.in', 'net.in', 'org.in', 'gov.in', 'ac.in',
|
|
790
|
-
// South Africa
|
|
791
|
-
'co.za', 'net.za', 'org.za', 'gov.za',
|
|
792
|
-
// Brazil
|
|
793
|
-
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br',
|
|
794
|
-
// South Korea
|
|
795
|
-
'co.kr', 'ne.kr', 'or.kr', 'go.kr', 'ac.kr',
|
|
796
|
-
// China
|
|
797
|
-
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
|
|
798
|
-
// Other Asia-Pacific
|
|
799
|
-
'co.id', 'co.th', 'com.sg', 'com.my', 'com.ph', 'com.vn',
|
|
800
|
-
'com.tw', 'com.hk',
|
|
801
|
-
// Latin America
|
|
802
|
-
'com.mx', 'com.ar', 'com.co', 'com.pe', 'com.cl',
|
|
803
|
-
// Europe
|
|
804
|
-
'co.il', 'co.at', 'co.hu', 'co.pl'
|
|
805
|
-
];
|
|
806
|
-
const lastTwo = `${sld}.${tld}`;
|
|
807
|
-
if (twoPartTlds.includes(lastTwo) && parts.length >= 3) {
|
|
808
|
-
return '.' + parts.slice(-3).join('.');
|
|
809
|
-
}
|
|
810
|
-
return '.' + parts.slice(-2).join('.');
|
|
811
|
-
}
|
|
812
|
-
return hostname;
|
|
911
|
+
if (parts.length < 2)
|
|
912
|
+
return hostname;
|
|
913
|
+
return '.' + getRegistrableDomain(hostname);
|
|
813
914
|
}
|
|
814
915
|
/**
|
|
815
916
|
* Get referrer data
|
|
@@ -864,9 +965,10 @@ function detectReferrerSource(hostname) {
|
|
|
864
965
|
* Handles anonymous_id, user_id, and identity resolution
|
|
865
966
|
*/
|
|
866
967
|
class IdentityManager {
|
|
867
|
-
constructor() {
|
|
968
|
+
constructor(options = {}) {
|
|
868
969
|
this.userId = null;
|
|
869
970
|
this.sessionId = null;
|
|
971
|
+
this.persistNewId = options.persistNewId !== false;
|
|
870
972
|
this.anonymousId = this.getOrCreateAnonymousId();
|
|
871
973
|
this.userId = this.getStoredUserId();
|
|
872
974
|
}
|
|
@@ -874,43 +976,84 @@ class IdentityManager {
|
|
|
874
976
|
* Get or create anonymous ID (device/browser identifier)
|
|
875
977
|
*/
|
|
876
978
|
getOrCreateAnonymousId() {
|
|
877
|
-
//
|
|
878
|
-
//
|
|
979
|
+
// 1. An EXISTING identity always wins — check the root-domain cookie (works across
|
|
980
|
+
// subdomains) then localStorage. FSR-50: a persisted visitor must NEVER be
|
|
981
|
+
// silently overwritten by a ?_dl_vid in the URL, or shared links would merge
|
|
982
|
+
// unrelated visitors (identity takeover). The CC bridge (restoreFromURL) writes
|
|
983
|
+
// this cookie BEFORE us, so the storefront visitor_id still wins there.
|
|
984
|
+
let anonymousId = cookies.get('__dl_visitor_id');
|
|
985
|
+
if (anonymousId) {
|
|
986
|
+
storage.set('dl_anonymous_id', anonymousId); // sync to localStorage
|
|
987
|
+
return anonymousId;
|
|
988
|
+
}
|
|
989
|
+
anonymousId = storage.getString('dl_anonymous_id'); // FSR-17: raw string, no JSON.parse
|
|
990
|
+
if (anonymousId) {
|
|
991
|
+
this.setRootDomainCookie('__dl_visitor_id', anonymousId);
|
|
992
|
+
return anonymousId;
|
|
993
|
+
}
|
|
994
|
+
// 2. Fresh visitor (no persisted id): accept a cross-domain bridge id from the URL,
|
|
995
|
+
// but ONLY a well-formed anon_<uuid> — reject arbitrary / oversized values (a
|
|
996
|
+
// multi-KB or attacker-crafted _dl_vid was previously persisted verbatim). Strip
|
|
997
|
+
// it from the address bar so a re-shared URL can't merge the next visitor. (FSR-50)
|
|
879
998
|
try {
|
|
880
999
|
const urlParams = new URLSearchParams(window.location.search);
|
|
881
1000
|
const urlVisitorId = urlParams.get('_dl_vid');
|
|
882
|
-
if (urlVisitorId &&
|
|
883
|
-
|
|
884
|
-
this.
|
|
885
|
-
storage.set('dl_anonymous_id', urlVisitorId);
|
|
1001
|
+
if (urlVisitorId && this.isValidAnonymousId(urlVisitorId)) {
|
|
1002
|
+
this.stripUrlParam('_dl_vid');
|
|
1003
|
+
this.persistAnonymousId(urlVisitorId);
|
|
886
1004
|
return urlVisitorId;
|
|
887
1005
|
}
|
|
888
1006
|
}
|
|
889
1007
|
catch (e) {
|
|
890
|
-
// URL parsing failed - continue
|
|
1008
|
+
// URL parsing failed - continue to generate a fresh id
|
|
891
1009
|
console.warn('[Datalyr] Failed to parse URL for _dl_vid:', e);
|
|
892
1010
|
}
|
|
893
|
-
//
|
|
894
|
-
let anonymousId = cookies.get('__dl_visitor_id');
|
|
895
|
-
if (anonymousId) {
|
|
896
|
-
// Found in cookie - sync to localStorage
|
|
897
|
-
storage.set('dl_anonymous_id', anonymousId);
|
|
898
|
-
return anonymousId;
|
|
899
|
-
}
|
|
900
|
-
// 2. Check localStorage (fallback for cookie issues)
|
|
901
|
-
anonymousId = storage.get('dl_anonymous_id');
|
|
902
|
-
if (anonymousId) {
|
|
903
|
-
// Found in localStorage - set root domain cookie
|
|
904
|
-
this.setRootDomainCookie('__dl_visitor_id', anonymousId);
|
|
905
|
-
return anonymousId;
|
|
906
|
-
}
|
|
907
|
-
// 3. Generate new ID
|
|
1011
|
+
// 3. Generate a new ID (persisted unless tracking is disallowed at init — FSR-107).
|
|
908
1012
|
anonymousId = `anon_${generateUUID()}`;
|
|
909
|
-
|
|
910
|
-
this.setRootDomainCookie('__dl_visitor_id', anonymousId);
|
|
911
|
-
storage.set('dl_anonymous_id', anonymousId);
|
|
1013
|
+
this.persistAnonymousId(anonymousId);
|
|
912
1014
|
return anonymousId;
|
|
913
1015
|
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Persist a freshly-adopted/generated anonymous id to the root-domain cookie +
|
|
1018
|
+
* localStorage. Gated by persistNewId (FSR-107: don't write a tracking identifier for
|
|
1019
|
+
* an opted-out / GPC / DNT visitor at init).
|
|
1020
|
+
*/
|
|
1021
|
+
persistAnonymousId(id) {
|
|
1022
|
+
if (!this.persistNewId)
|
|
1023
|
+
return;
|
|
1024
|
+
this.setRootDomainCookie('__dl_visitor_id', id);
|
|
1025
|
+
storage.set('dl_anonymous_id', id);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Whether a `_dl_vid` value is a well-formed Datalyr anonymous id (anon_ + UUID).
|
|
1029
|
+
* The length is bounded by the pattern, so an oversized/garbage value is rejected.
|
|
1030
|
+
* (FSR-50)
|
|
1031
|
+
*/
|
|
1032
|
+
isValidAnonymousId(id) {
|
|
1033
|
+
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);
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Remove a query param from the current URL via history.replaceState (best-effort).
|
|
1037
|
+
* Used so a consumed `_dl_vid` doesn't linger in the address bar and get re-shared.
|
|
1038
|
+
* (FSR-50)
|
|
1039
|
+
*/
|
|
1040
|
+
stripUrlParam(param) {
|
|
1041
|
+
var _a;
|
|
1042
|
+
try {
|
|
1043
|
+
if (typeof window === 'undefined' || typeof ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState) !== 'function')
|
|
1044
|
+
return;
|
|
1045
|
+
const url = new URL(window.location.href);
|
|
1046
|
+
if (!url.searchParams.has(param))
|
|
1047
|
+
return;
|
|
1048
|
+
url.searchParams.delete(param);
|
|
1049
|
+
const search = url.searchParams.toString();
|
|
1050
|
+
const newUrl = url.pathname + (search ? '?' + search : '') + url.hash;
|
|
1051
|
+
window.history.replaceState(window.history.state, '', newUrl);
|
|
1052
|
+
}
|
|
1053
|
+
catch (_b) {
|
|
1054
|
+
// best-effort — never block init on URL rewrite
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
914
1057
|
/**
|
|
915
1058
|
* Set a root domain cookie for cross-subdomain tracking
|
|
916
1059
|
*/
|
|
@@ -945,7 +1088,10 @@ class IdentityManager {
|
|
|
945
1088
|
* Get stored user ID from previous session
|
|
946
1089
|
*/
|
|
947
1090
|
getStoredUserId() {
|
|
948
|
-
|
|
1091
|
+
// FSR-17: getString (not get) so a numeric-looking user_id ('12345') doesn't come
|
|
1092
|
+
// back as a JS number after reload, and a 16+-digit snowflake id isn't precision-
|
|
1093
|
+
// corrupted by JSON.parse — both would fragment identity from the second page on.
|
|
1094
|
+
return storage.getString('dl_user_id');
|
|
949
1095
|
}
|
|
950
1096
|
/**
|
|
951
1097
|
* Get the anonymous ID
|
|
@@ -1409,12 +1555,19 @@ class AttributionManager {
|
|
|
1409
1555
|
const attribution = {
|
|
1410
1556
|
timestamp: Date.now()
|
|
1411
1557
|
};
|
|
1412
|
-
// Capture UTM parameters
|
|
1558
|
+
// Capture UTM parameters.
|
|
1559
|
+
// Emit BOTH the canonical `utm_*` key AND the stripped alias (source/medium/...).
|
|
1560
|
+
// FSR-13: ingest (event_data.utm_source ?? utmSource) and EVERY attribution MV /
|
|
1561
|
+
// lookup pipe key on the prefixed `utm_source`/`utm_campaign` names with no fallback —
|
|
1562
|
+
// stripping the prefix left the UTM leg of the attribution read path dead for all web
|
|
1563
|
+
// events. The stripped alias is still emitted because the dashboard analytics MVs and
|
|
1564
|
+
// determineSource()/determineMedium() read it (and it's the long-standing shape).
|
|
1413
1565
|
for (const utm of this.UTM_PARAMS) {
|
|
1414
1566
|
const value = params[utm];
|
|
1415
1567
|
if (value) {
|
|
1568
|
+
attribution[utm] = value; // canonical: utm_source, utm_campaign, ...
|
|
1416
1569
|
const key = utm.replace('utm_', '');
|
|
1417
|
-
attribution[key] = value;
|
|
1570
|
+
attribution[key] = value; // alias: source, campaign, ...
|
|
1418
1571
|
}
|
|
1419
1572
|
}
|
|
1420
1573
|
// Capture click IDs. The first present (by CLICK_IDS priority) is the primary
|
|
@@ -1582,33 +1735,53 @@ class AttributionManager {
|
|
|
1582
1735
|
// Optionally set the cookie for future use
|
|
1583
1736
|
cookies.set('_fbp', adCookies._fbp, 90);
|
|
1584
1737
|
}
|
|
1585
|
-
//
|
|
1586
|
-
//
|
|
1587
|
-
//
|
|
1738
|
+
// Meta's fbc format is `fb.{subdomainIndex}.{creationTime}.{fbclid}` where
|
|
1739
|
+
// creationTime is UNIX time in MILLISECONDS (matches the _fbp generation above
|
|
1740
|
+
// and the real _fbc cookie the Meta Pixel writes). Do NOT use seconds here.
|
|
1741
|
+
//
|
|
1742
|
+
// FSR-14: a returning visitor who clicks a NEW ad must get a FRESH _fbc. The old
|
|
1743
|
+
// once-only guards (`!_dl_fbclid_at`, `!_fbc`) meant a new fbclid never refreshed
|
|
1744
|
+
// _fbc while a prior 90-day _fbc still existed — Meta then received the OLD
|
|
1745
|
+
// campaign's fbc (and an old creationTime that can push the conversion outside the
|
|
1746
|
+
// click window) even though event_data carried the new fbclid. We detect a new
|
|
1747
|
+
// click by comparing the URL fbclid against the one embedded in the existing _fbc
|
|
1748
|
+
// (its 4th dot-segment). `_dl_fbclid_at` stays a pure ms timestamp — the CC bridge /
|
|
1749
|
+
// Shopify cart / server-side rebuild forward it as a time, so its format must not
|
|
1750
|
+
// change.
|
|
1588
1751
|
const fbclid = this.getCurrentFbclid();
|
|
1589
|
-
if (fbclid
|
|
1590
|
-
cookies.
|
|
1591
|
-
|
|
1752
|
+
if (fbclid) {
|
|
1753
|
+
const existingFbc = adCookies._fbc; // captured above (cookies.get('_fbc'))
|
|
1754
|
+
const embeddedFbclid = this.extractFbclidFromFbc(existingFbc);
|
|
1755
|
+
if (!existingFbc) {
|
|
1756
|
+
// No _fbc to compare against. Rebuild it, preferring the persisted click time
|
|
1757
|
+
// (covers the case where _fbc was evicted but the same click is still in play),
|
|
1758
|
+
// falling back to now if missing/corrupt (Number('abc') = NaN guard).
|
|
1759
|
+
const stored = cookies.get('_dl_fbclid_at');
|
|
1760
|
+
const parsed = stored ? Number(stored) : NaN;
|
|
1761
|
+
const timestamp = Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
|
|
1762
|
+
if (!stored)
|
|
1763
|
+
cookies.set('_dl_fbclid_at', String(timestamp), 90);
|
|
1764
|
+
adCookies._fbc = `fb.1.${timestamp}.${fbclid}`;
|
|
1765
|
+
cookies.set('_fbc', adCookies._fbc, 90);
|
|
1766
|
+
}
|
|
1767
|
+
else if (embeddedFbclid && embeddedFbclid !== fbclid) {
|
|
1768
|
+
// A NEW ad click landed over a stale _fbc — regenerate with the new fbclid and
|
|
1769
|
+
// the CURRENT click time, and overwrite the persisted click time to match.
|
|
1770
|
+
const now = Date.now();
|
|
1771
|
+
adCookies._fbc = `fb.1.${now}.${fbclid}`;
|
|
1772
|
+
cookies.set('_fbc', adCookies._fbc, 90);
|
|
1773
|
+
cookies.set('_dl_fbclid_at', String(now), 90);
|
|
1774
|
+
}
|
|
1775
|
+
// else: same fbclid already embedded in _fbc → leave both cookies untouched.
|
|
1776
|
+
}
|
|
1777
|
+
// Persist the click time the FIRST time we see gclid in the URL so server-side
|
|
1778
|
+
// events can stamp the real click moment even after the _gcl_* cookies evict.
|
|
1779
|
+
// (gclid has no synthesized-and-going-stale artifact like _fbc, and Google does not
|
|
1780
|
+
// validate a creationTime window the way Meta does, so this stays once-only.)
|
|
1592
1781
|
const gclid = this.hasClickId('gclid') ? ((_b = (_a = this.queryParamsCache) === null || _a === void 0 ? void 0 : _a.gclid) !== null && _b !== void 0 ? _b : null) : null;
|
|
1593
1782
|
if (gclid && !cookies.get('_dl_gclid_at')) {
|
|
1594
1783
|
cookies.set('_dl_gclid_at', String(Date.now()), 90);
|
|
1595
1784
|
}
|
|
1596
|
-
// Generate _fbc if we have fbclid but no _fbc.
|
|
1597
|
-
// Meta's fbc format is `fb.{subdomainIndex}.{creationTime}.{fbclid}` where
|
|
1598
|
-
// creationTime is UNIX time in MILLISECONDS (matches the _fbp generation above
|
|
1599
|
-
// and the real _fbc cookie the Meta Pixel writes). Do NOT use seconds here.
|
|
1600
|
-
if (fbclid && !adCookies._fbc) {
|
|
1601
|
-
// Prefer the persisted click time when valid; fall back to now if the
|
|
1602
|
-
// cookie is missing or corrupted (e.g. user edited it to garbage).
|
|
1603
|
-
// Without this guard, Number("abc") = NaN and Meta would reject
|
|
1604
|
-
// `fb.1.NaN.{fbclid}`.
|
|
1605
|
-
const stored = cookies.get('_dl_fbclid_at');
|
|
1606
|
-
const parsed = stored ? Number(stored) : NaN;
|
|
1607
|
-
const timestamp = Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
|
|
1608
|
-
adCookies._fbc = `fb.1.${timestamp}.${fbclid}`;
|
|
1609
|
-
// Optionally set the cookie for future use
|
|
1610
|
-
cookies.set('_fbc', adCookies._fbc, 90);
|
|
1611
|
-
}
|
|
1612
1785
|
// Filter out null values for cleaner data
|
|
1613
1786
|
return Object.fromEntries(Object.entries(adCookies).filter(([_, value]) => value !== null));
|
|
1614
1787
|
}
|
|
@@ -1628,6 +1801,21 @@ class AttributionManager {
|
|
|
1628
1801
|
const params = this.queryParamsCache || getAllQueryParams();
|
|
1629
1802
|
return params.fbclid || null;
|
|
1630
1803
|
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Extract the fbclid embedded in an `_fbc` cookie value
|
|
1806
|
+
* (`fb.{subdomainIndex}.{creationTime}.{fbclid}` → the 4th dot-segment), or null if
|
|
1807
|
+
* the value is missing/malformed. Used to detect whether a NEW fbclid in the URL
|
|
1808
|
+
* differs from the one a prior _fbc was built for. (FSR-14)
|
|
1809
|
+
*/
|
|
1810
|
+
extractFbclidFromFbc(fbc) {
|
|
1811
|
+
if (!fbc)
|
|
1812
|
+
return null;
|
|
1813
|
+
const parts = fbc.split('.');
|
|
1814
|
+
// fb . subdomainIndex . creationTime . fbclid (the fbclid itself may contain no dots)
|
|
1815
|
+
if (parts.length < 4 || parts[0] !== 'fb')
|
|
1816
|
+
return null;
|
|
1817
|
+
return parts.slice(3).join('.') || null;
|
|
1818
|
+
}
|
|
1631
1819
|
/**
|
|
1632
1820
|
* Get attribution data for event
|
|
1633
1821
|
*/
|
|
@@ -1645,11 +1833,18 @@ class AttributionManager {
|
|
|
1645
1833
|
const hasRealAttribution = !!(current.clickId ||
|
|
1646
1834
|
current.campaign ||
|
|
1647
1835
|
(current.source && current.source !== 'direct'));
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1836
|
+
// Direct / internal navigation: fall back to persisted attribution so the event
|
|
1837
|
+
// isn't mis-attributed to 'direct'. FSR-48: prefer the LAST touch (the most recent
|
|
1838
|
+
// real signal, e.g. a May Meta click) over the FIRST touch (e.g. a January gclid) —
|
|
1839
|
+
// spreading firstTouch carried its stale clickId top-level and credited the older
|
|
1840
|
+
// campaign for last-click. Also fall back to whichever of last/first touch is still
|
|
1841
|
+
// valid, so an expired first-touch with a live last-touch no longer drops to 'direct'.
|
|
1842
|
+
if (!hasRealAttribution) {
|
|
1843
|
+
// getLastTouch()/getFirstTouch() already drop expired records, so either is live or
|
|
1844
|
+
// null. Prefer last-touch; fall back to first-touch if there's no live last-touch.
|
|
1845
|
+
const fallback = lastTouch || firstTouch;
|
|
1846
|
+
if (fallback) {
|
|
1847
|
+
current = Object.assign(Object.assign({}, fallback), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
|
|
1653
1848
|
}
|
|
1654
1849
|
}
|
|
1655
1850
|
// Capture advertising cookies automatically
|
|
@@ -1769,8 +1964,10 @@ class AttributionManager {
|
|
|
1769
1964
|
* internal navs from being mis-classified as referral.
|
|
1770
1965
|
*/
|
|
1771
1966
|
isSameRootDomain(a, b) {
|
|
1772
|
-
|
|
1773
|
-
|
|
1967
|
+
// FSR-47: use the real eTLD+1 (public-suffix-aware) so a ccTLD merchant
|
|
1968
|
+
// (shop.example.co.uk) doesn't collapse every external referrer that merely shares
|
|
1969
|
+
// the suffix (google.co.uk → root 'co.uk') into 'direct'.
|
|
1970
|
+
return getRegistrableDomain(a) === getRegistrableDomain(b);
|
|
1774
1971
|
}
|
|
1775
1972
|
/**
|
|
1776
1973
|
* Extract hostname from URL
|
|
@@ -1818,6 +2015,16 @@ const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_ite
|
|
|
1818
2015
|
// already-overloaded server). See sendBatch / the rateLimitedUntil gate.
|
|
1819
2016
|
class RateLimitError extends Error {
|
|
1820
2017
|
}
|
|
2018
|
+
// A permanent (non-retryable) failure — a 4xx other than 408/429 (invalid event shape,
|
|
2019
|
+
// origin not allowed, auth). Retrying or parking it just head-of-line-blocks the offline
|
|
2020
|
+
// queue forever and hammers ingest. Tagged so the send paths DROP the batch instead of
|
|
2021
|
+
// unshifting it back to the head. (FSR-55)
|
|
2022
|
+
class PermanentError extends Error {
|
|
2023
|
+
constructor(status, message) {
|
|
2024
|
+
super(message || `HTTP ${status}`);
|
|
2025
|
+
this.status = status;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
1821
2028
|
class EventQueue {
|
|
1822
2029
|
constructor(config) {
|
|
1823
2030
|
this.queue = [];
|
|
@@ -1884,16 +2091,30 @@ class EventQueue {
|
|
|
1884
2091
|
this.log('Duplicate event suppressed:', eventName);
|
|
1885
2092
|
return;
|
|
1886
2093
|
}
|
|
1887
|
-
//
|
|
1888
|
-
//
|
|
1889
|
-
//
|
|
2094
|
+
// Critical events (purchase/signup/lead/…) send immediately, bypassing the batch.
|
|
2095
|
+
// FSR-16: persist the event to the offline queue BEFORE the first send and remove it
|
|
2096
|
+
// on confirmed delivery. Previously the event existed ONLY inside the sendBatch retry
|
|
2097
|
+
// closure during backoff (~31s) — not in any tracked queue — so a tab close mid-retry
|
|
2098
|
+
// (the most plausible moment after checkout) lost the purchase permanently: forceFlush
|
|
2099
|
+
// / destroy never saw it. Server-side event_id dedup makes the at-least-once re-send
|
|
2100
|
+
// safe.
|
|
1890
2101
|
if (this.config.criticalEvents.includes(eventName)) {
|
|
1891
2102
|
this.log('Critical event, sending immediately:', eventName);
|
|
1892
|
-
|
|
1893
|
-
this.sendBatch([event])
|
|
1894
|
-
|
|
1895
|
-
//
|
|
1896
|
-
this.
|
|
2103
|
+
this.persistCriticalEvent(event);
|
|
2104
|
+
this.sendBatch([event])
|
|
2105
|
+
.then(() => {
|
|
2106
|
+
// Delivered — drop the persisted copy so it isn't replayed next load.
|
|
2107
|
+
this.removeFromOfflineQueue(event.event_id);
|
|
2108
|
+
})
|
|
2109
|
+
.catch((error) => {
|
|
2110
|
+
if (error instanceof PermanentError) {
|
|
2111
|
+
this.log('Critical event permanently rejected, dropping:', eventName, error);
|
|
2112
|
+
this.removeFromOfflineQueue(event.event_id);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
// Leave it persisted: the periodic drain + next-load drain retry it, and an
|
|
2116
|
+
// unload mid-retry can now beacon it (it's in the offline queue). (FSR-16)
|
|
2117
|
+
this.log('Critical event send failed, retained in offline queue:', eventName, error);
|
|
1897
2118
|
});
|
|
1898
2119
|
return;
|
|
1899
2120
|
}
|
|
@@ -2042,7 +2263,14 @@ class EventQueue {
|
|
|
2042
2263
|
// Remove the same slice from the live queue; the offline queue now drains on
|
|
2043
2264
|
// every periodic tick (WEB-1/WEB-2), so they are still retried, not stranded.
|
|
2044
2265
|
this.queue.splice(0, batchSize);
|
|
2045
|
-
|
|
2266
|
+
// FSR-55: don't park a permanently-rejected batch — it would never succeed and
|
|
2267
|
+
// would block the offline drain. Drop it.
|
|
2268
|
+
if (error instanceof PermanentError) {
|
|
2269
|
+
this.log(`Dropping ${events.length} events — permanent error ${error.status}`);
|
|
2270
|
+
}
|
|
2271
|
+
else {
|
|
2272
|
+
this.moveToOfflineQueue(events);
|
|
2273
|
+
}
|
|
2046
2274
|
}
|
|
2047
2275
|
finally {
|
|
2048
2276
|
this.inFlight = [];
|
|
@@ -2062,6 +2290,14 @@ class EventQueue {
|
|
|
2062
2290
|
// Get current endpoint (main or fallback)
|
|
2063
2291
|
const endpoints = [this.config.endpoint, ...this.config.fallbackEndpoints];
|
|
2064
2292
|
const currentEndpoint = endpoints[endpointIndex] || this.config.endpoint;
|
|
2293
|
+
const body = JSON.stringify(batchPayload);
|
|
2294
|
+
// FSR-54: keepalive requests share a 64KB in-flight quota (Fetch spec) — a larger
|
|
2295
|
+
// body fails INSTANTLY with a network error, then retries through every fallback +
|
|
2296
|
+
// backoff (all failing the same way) and parks in the offline queue, where it
|
|
2297
|
+
// head-of-line-blocks the drain forever. keepalive only matters for unload survival,
|
|
2298
|
+
// so only request it when the body is safely under the cap; oversized batches go via
|
|
2299
|
+
// a normal fetch (no cap) and succeed.
|
|
2300
|
+
const useKeepalive = new Blob([body]).size <= 60000;
|
|
2065
2301
|
try {
|
|
2066
2302
|
const response = yield fetch(currentEndpoint, {
|
|
2067
2303
|
method: 'POST',
|
|
@@ -2069,8 +2305,8 @@ class EventQueue {
|
|
|
2069
2305
|
'Content-Type': 'application/json',
|
|
2070
2306
|
'X-Batch-Size': events.length.toString()
|
|
2071
2307
|
},
|
|
2072
|
-
body
|
|
2073
|
-
keepalive:
|
|
2308
|
+
body,
|
|
2309
|
+
keepalive: useKeepalive
|
|
2074
2310
|
});
|
|
2075
2311
|
if (!response.ok) {
|
|
2076
2312
|
// Handle rate limiting
|
|
@@ -2086,15 +2322,21 @@ class EventQueue {
|
|
|
2086
2322
|
this.log(`Rate limited; backing off ${retryAfter}s`);
|
|
2087
2323
|
throw new RateLimitError('Rate limited (429)');
|
|
2088
2324
|
}
|
|
2325
|
+
// FSR-55: a 4xx other than 408 (timeout) is PERMANENT — invalid event shape
|
|
2326
|
+
// (400), origin not allowed (403), auth (401). Retrying / parking it just blocks
|
|
2327
|
+
// the queue and hammers ingest forever. Tag it so the caller drops the batch.
|
|
2328
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 408) {
|
|
2329
|
+
throw new PermanentError(response.status, `HTTP ${response.status}: ${response.statusText}`);
|
|
2330
|
+
}
|
|
2089
2331
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
2090
2332
|
}
|
|
2091
2333
|
this.log(`Batch sent successfully to ${currentEndpoint}: ${events.length} events`);
|
|
2092
2334
|
}
|
|
2093
2335
|
catch (error) {
|
|
2094
|
-
// A 429 is deliberate backpressure — do NOT retry
|
|
2095
|
-
// backoff). Propagate so the
|
|
2096
|
-
//
|
|
2097
|
-
if (error instanceof RateLimitError) {
|
|
2336
|
+
// A 429 is deliberate backpressure and a 4xx is permanent — do NOT retry either
|
|
2337
|
+
// (neither fallback nor backoff). Propagate so the caller routes them correctly
|
|
2338
|
+
// (offline queue for 429, drop for permanent). This prevents the retry storm.
|
|
2339
|
+
if (error instanceof RateLimitError || error instanceof PermanentError) {
|
|
2098
2340
|
throw error;
|
|
2099
2341
|
}
|
|
2100
2342
|
// Try next fallback endpoint if available
|
|
@@ -2198,6 +2440,38 @@ class EventQueue {
|
|
|
2198
2440
|
this.offlineQueueLock = false;
|
|
2199
2441
|
}
|
|
2200
2442
|
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Persist a critical event to the offline queue BEFORE its immediate send, so it
|
|
2445
|
+
* survives an unload during the send/backoff window. Deduped on event_id so a
|
|
2446
|
+
* re-enqueue can't double-store it. (FSR-16)
|
|
2447
|
+
*/
|
|
2448
|
+
persistCriticalEvent(event) {
|
|
2449
|
+
if (!this.enabled)
|
|
2450
|
+
return;
|
|
2451
|
+
if (this.offlineQueue.some(e => e.event_id === event.event_id))
|
|
2452
|
+
return;
|
|
2453
|
+
this.offlineQueue.push(event);
|
|
2454
|
+
if (this.offlineQueue.length > this.config.maxOfflineQueueSize) {
|
|
2455
|
+
this.offlineQueue.splice(0, this.offlineQueue.length - this.config.maxOfflineQueueSize);
|
|
2456
|
+
}
|
|
2457
|
+
this.saveOfflineQueue();
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Remove a delivered/dropped event from the offline queue by event_id, and clear the
|
|
2461
|
+
* persisted copy when the queue empties. (FSR-16)
|
|
2462
|
+
*/
|
|
2463
|
+
removeFromOfflineQueue(eventId) {
|
|
2464
|
+
const before = this.offlineQueue.length;
|
|
2465
|
+
this.offlineQueue = this.offlineQueue.filter(e => e.event_id !== eventId);
|
|
2466
|
+
if (this.offlineQueue.length === before)
|
|
2467
|
+
return;
|
|
2468
|
+
if (this.offlineQueue.length === 0) {
|
|
2469
|
+
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2470
|
+
}
|
|
2471
|
+
else {
|
|
2472
|
+
this.saveOfflineQueue();
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2201
2475
|
/**
|
|
2202
2476
|
* Load offline queue from storage
|
|
2203
2477
|
*/
|
|
@@ -2252,8 +2526,17 @@ class EventQueue {
|
|
|
2252
2526
|
this.saveOfflineQueue();
|
|
2253
2527
|
}
|
|
2254
2528
|
catch (error) {
|
|
2529
|
+
// FSR-55: a permanently-rejected (4xx) batch must NOT go back to the head —
|
|
2530
|
+
// that would block every event behind it forever and hammer ingest every tick.
|
|
2531
|
+
// Drop it (already spliced off the front) and keep draining the rest.
|
|
2532
|
+
if (error instanceof PermanentError) {
|
|
2533
|
+
this.log(`Dropping poison offline batch (${batch.length}) — permanent error ${error.status}`);
|
|
2534
|
+
this.saveOfflineQueue();
|
|
2535
|
+
continue;
|
|
2536
|
+
}
|
|
2255
2537
|
this.log('Failed to send offline batch:', error);
|
|
2256
|
-
//
|
|
2538
|
+
// Transient/rate-limited: put the batch back at the head and stop this drain;
|
|
2539
|
+
// the next tick (after any rateLimitedUntil window) retries.
|
|
2257
2540
|
this.offlineQueue.unshift(...batch);
|
|
2258
2541
|
this.saveOfflineQueue();
|
|
2259
2542
|
break;
|
|
@@ -2297,42 +2580,71 @@ class EventQueue {
|
|
|
2297
2580
|
};
|
|
2298
2581
|
}
|
|
2299
2582
|
/**
|
|
2300
|
-
* Force flush (
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2303
|
-
*
|
|
2304
|
-
*
|
|
2583
|
+
* Force flush. Called on visibilitychange:hidden (NON-terminal — an ordinary tab
|
|
2584
|
+
* switch / mobile background) and on pagehide+beforeunload (terminal unload).
|
|
2585
|
+
*
|
|
2586
|
+
* FSR-15: the old single path beaconed BOTH queues on EVERY visibilitychange and then
|
|
2587
|
+
* storage.remove()'d the persisted offline copy on sendBeacon()===true. But sendBeacon
|
|
2588
|
+
* true only means the browser ENQUEUED the request — not that it was delivered. The
|
|
2589
|
+
* offline queue exists precisely because earlier sends failed (ingest down / 5xx / 429);
|
|
2590
|
+
* a single alt-tab during that outage beaconed the whole backlog into the dead endpoint
|
|
2591
|
+
* and ERASED the persisted copy, so nothing replayed when ingest recovered. It also
|
|
2592
|
+
* ignored rateLimitedUntil, firing beacons straight into the Retry-After window.
|
|
2593
|
+
*
|
|
2594
|
+
* Now:
|
|
2595
|
+
* - Non-terminal (tab switch): deliver the LIVE queue via a response-checked keepalive
|
|
2596
|
+
* fetch (flush) and response-check-drain the offline backlog (processOfflineQueue) —
|
|
2597
|
+
* neither erases the backlog on failure. The destructive beacon path is not used.
|
|
2598
|
+
* - Terminal (unload): beacon live+offline best-effort, but PERSIST everything first
|
|
2599
|
+
* and NEVER storage.remove() on a mere beacon enqueue. The next page load's
|
|
2600
|
+
* response-checked drain (normal fetch, no 64KB cap) is the source of truth and
|
|
2601
|
+
* clears the copy; server event_id dedup makes the potential double-send safe.
|
|
2602
|
+
* - Both paths honor rateLimitedUntil (don't beacon/flush into the backoff window).
|
|
2305
2603
|
*
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2308
|
-
*
|
|
2309
|
-
* double-send. (`inFlight` is the front `inFlight.length` events of this.queue.)
|
|
2310
|
-
* - HIGH-2: handleUnload is wired to visibilitychange+pagehide+beforeunload, so this
|
|
2311
|
-
* runs multiple times per lifecycle. Detach what we beacon SYNCHRONOUSLY (clear the
|
|
2312
|
-
* in-memory queues, persist a refused remainder straight to storage rather than back
|
|
2313
|
-
* into this.offlineQueue) so a repeat call finds nothing to re-send.
|
|
2604
|
+
* Still excludes the in-flight _flush batch (its keepalive fetch already carries it,
|
|
2605
|
+
* HIGH-1), and detaches synchronously so a repeat unload event this lifecycle re-enters
|
|
2606
|
+
* with nothing to re-beacon (HIGH-2).
|
|
2314
2607
|
*/
|
|
2315
2608
|
forceFlush() {
|
|
2316
|
-
return __awaiter(this,
|
|
2609
|
+
return __awaiter(this, arguments, void 0, function* (isTerminal = false) {
|
|
2317
2610
|
if (!this.enabled)
|
|
2318
2611
|
return;
|
|
2319
|
-
if (!
|
|
2320
|
-
//
|
|
2612
|
+
if (!isTerminal) {
|
|
2613
|
+
// Non-terminal visibilitychange: the page is still alive, so use the
|
|
2614
|
+
// response-checked send paths (keepalive fetch) — they move failures to the
|
|
2615
|
+
// persisted offline queue rather than erasing it. Within the 429 window, leave
|
|
2616
|
+
// everything parked.
|
|
2617
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
2618
|
+
return;
|
|
2321
2619
|
yield this.flush();
|
|
2322
2620
|
yield this.processOfflineQueue();
|
|
2323
2621
|
return;
|
|
2324
2622
|
}
|
|
2623
|
+
// Terminal unload.
|
|
2325
2624
|
// Exclude the in-flight batch (its keepalive fetch already carries it).
|
|
2326
2625
|
const live = this.queue.slice(this.inFlight.length);
|
|
2327
2626
|
const pending = [...live, ...this.offlineQueue];
|
|
2328
|
-
// Detach
|
|
2329
|
-
//
|
|
2330
|
-
//
|
|
2331
|
-
// below, not back into this.offlineQueue, for exactly this reason.
|
|
2627
|
+
// Detach the IN-MEMORY queues synchronously so a second unload event this lifecycle
|
|
2628
|
+
// finds nothing to re-beacon (HIGH-2). The persisted STORAGE copy below is the
|
|
2629
|
+
// durable record and is deliberately NOT removed on a successful beacon. (FSR-15)
|
|
2332
2630
|
this.queue = this.queue.slice(0, this.inFlight.length);
|
|
2333
2631
|
this.offlineQueue = [];
|
|
2334
2632
|
if (pending.length === 0)
|
|
2335
2633
|
return;
|
|
2634
|
+
// Persist the full backlog FIRST so it survives no matter what sendBeacon reports.
|
|
2635
|
+
// The next load's response-checked drain delivers + clears it (dedup-safe). (FSR-15)
|
|
2636
|
+
if (this.enabled) {
|
|
2637
|
+
const toPersist = pending.slice(-this.config.maxOfflineQueueSize);
|
|
2638
|
+
if (pending.length > toPersist.length) {
|
|
2639
|
+
this.log(`Offline cap dropped ${pending.length - toPersist.length} oldest events at unload`);
|
|
2640
|
+
}
|
|
2641
|
+
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2642
|
+
}
|
|
2643
|
+
// Within the 429 window, don't beacon into it — the persisted copy drains next load.
|
|
2644
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
2645
|
+
return;
|
|
2646
|
+
if (!navigator.sendBeacon)
|
|
2647
|
+
return; // no beacon API — next load fetch-drains the copy
|
|
2336
2648
|
const MAX_BEACON_BYTES = 60000; // headroom under the browser's ~64KB cap
|
|
2337
2649
|
// Greedily pack events into chunks bounded by BOTH batchSize and byte size.
|
|
2338
2650
|
const chunks = [];
|
|
@@ -2353,36 +2665,18 @@ class EventQueue {
|
|
|
2353
2665
|
}
|
|
2354
2666
|
if (current.length > 0)
|
|
2355
2667
|
chunks.push(current);
|
|
2356
|
-
//
|
|
2357
|
-
//
|
|
2358
|
-
let
|
|
2359
|
-
for (
|
|
2360
|
-
const blob = new Blob([JSON.stringify(this.buildBatch(
|
|
2668
|
+
// Best-effort early delivery. We do NOT storage.remove() on success — the persisted
|
|
2669
|
+
// copy is the source of truth for the next load (sendBeacon true ≠ delivered).
|
|
2670
|
+
let beaconed = 0;
|
|
2671
|
+
for (const chunk of chunks) {
|
|
2672
|
+
const blob = new Blob([JSON.stringify(this.buildBatch(chunk))], {
|
|
2361
2673
|
type: 'application/json'
|
|
2362
2674
|
});
|
|
2363
|
-
if (!navigator.sendBeacon(this.config.endpoint, blob))
|
|
2364
|
-
failedFrom = c;
|
|
2675
|
+
if (!navigator.sendBeacon(this.config.endpoint, blob))
|
|
2365
2676
|
break;
|
|
2366
|
-
|
|
2367
|
-
}
|
|
2368
|
-
if (failedFrom >= 0) {
|
|
2369
|
-
const remainder = chunks
|
|
2370
|
-
.slice(failedFrom)
|
|
2371
|
-
.reduce((acc, c) => acc.concat(c), []);
|
|
2372
|
-
// Persist straight to storage (NOT this.offlineQueue) so a repeat unload event
|
|
2373
|
-
// won't re-beacon it; the next page load's drain (which uses a normal fetch with
|
|
2374
|
-
// no 64KB cap) delivers it. MED-2: log if the maxOfflineQueueSize cap truncates.
|
|
2375
|
-
const toPersist = remainder.slice(-this.config.maxOfflineQueueSize);
|
|
2376
|
-
if (remainder.length > toPersist.length) {
|
|
2377
|
-
this.log(`Offline cap dropped ${remainder.length - toPersist.length} oldest events at unload`);
|
|
2378
|
-
}
|
|
2379
|
-
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2380
|
-
this.log(`sendBeacon refused ${remainder.length} events; persisted ${toPersist.length} for next load`);
|
|
2381
|
-
}
|
|
2382
|
-
else {
|
|
2383
|
-
this.log('Events sent via sendBeacon');
|
|
2384
|
-
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2677
|
+
beaconed += chunk.length;
|
|
2385
2678
|
}
|
|
2679
|
+
this.log(`forceFlush(terminal): beaconed ${beaconed}/${pending.length} events; persisted copy retained for next-load drain`);
|
|
2386
2680
|
});
|
|
2387
2681
|
}
|
|
2388
2682
|
/**
|
|
@@ -2622,17 +2916,33 @@ class ContainerManager {
|
|
|
2622
2916
|
if (this.initialized)
|
|
2623
2917
|
return;
|
|
2624
2918
|
try {
|
|
2625
|
-
//
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2919
|
+
// FSR-52: time-box the /container-scripts fetch. init() is awaited BEFORE the
|
|
2920
|
+
// initial page() fires, and this fetch had no timeout — a slow/stalled ingest
|
|
2921
|
+
// worker (the fleet has a documented history of worker-side stalls) delayed or lost
|
|
2922
|
+
// the landing pageview indefinitely. Abort after 3s so page() still fires; the catch
|
|
2923
|
+
// below leaves pixels/remoteConfig unset (the SDK proceeds with first-party tracking
|
|
2924
|
+
// and defaults). AbortSignal.timeout isn't on every supported browser, so use a
|
|
2925
|
+
// manual controller.
|
|
2926
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
2927
|
+
const timeoutId = controller ? setTimeout(() => controller.abort(), 3000) : null;
|
|
2928
|
+
let response;
|
|
2929
|
+
try {
|
|
2930
|
+
response = yield fetch(`${this.endpoint}/container-scripts`, {
|
|
2931
|
+
method: 'POST',
|
|
2932
|
+
headers: {
|
|
2933
|
+
'Content-Type': 'application/json',
|
|
2934
|
+
'X-Container-Version': '1.0'
|
|
2935
|
+
},
|
|
2936
|
+
body: JSON.stringify({
|
|
2937
|
+
workspaceId: this.workspaceId
|
|
2938
|
+
}),
|
|
2939
|
+
signal: controller ? controller.signal : undefined
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
finally {
|
|
2943
|
+
if (timeoutId)
|
|
2944
|
+
clearTimeout(timeoutId);
|
|
2945
|
+
}
|
|
2636
2946
|
if (!response.ok) {
|
|
2637
2947
|
throw new Error(`Failed to fetch container scripts: ${response.status}`);
|
|
2638
2948
|
}
|
|
@@ -3103,6 +3413,16 @@ class ContainerManager {
|
|
|
3103
3413
|
this.log('Error initializing TikTok Pixel:', error);
|
|
3104
3414
|
}
|
|
3105
3415
|
}
|
|
3416
|
+
/**
|
|
3417
|
+
* Whether the Meta Pixel is configured AND loaded (fbq present) — i.e. a Purchase
|
|
3418
|
+
* co-fire via trackToPixels() would actually reach Meta rather than silently no-op.
|
|
3419
|
+
* Used by the CC purchase-pixel dedup so its once-per-order guard isn't burned before
|
|
3420
|
+
* the pixel is live. (FSR-102)
|
|
3421
|
+
*/
|
|
3422
|
+
hasMetaPixel() {
|
|
3423
|
+
var _a, _b;
|
|
3424
|
+
return !!(((_b = (_a = this.pixels) === null || _a === void 0 ? void 0 : _a.meta) === null || _b === void 0 ? void 0 : _b.enabled) && window.fbq);
|
|
3425
|
+
}
|
|
3106
3426
|
/**
|
|
3107
3427
|
* Track event to all initialized pixels
|
|
3108
3428
|
*/
|
|
@@ -3410,28 +3730,59 @@ class AutoIdentifyManager {
|
|
|
3410
3730
|
setupFormMonitoring() {
|
|
3411
3731
|
// Monitor existing forms
|
|
3412
3732
|
this.scanForEmailForms();
|
|
3413
|
-
// Watch for new forms (SPAs)
|
|
3414
|
-
|
|
3733
|
+
// Watch for new forms (SPAs). FSR-101: store the observer so destroy() (opt-out / SDK
|
|
3734
|
+
// destroy) can disconnect it — previously it was a local and leaked for the page
|
|
3735
|
+
// lifetime, re-scanning + re-attaching listeners on every DOM mutation after opt-out.
|
|
3736
|
+
this.formObserver = new MutationObserver(() => {
|
|
3415
3737
|
this.scanForEmailForms();
|
|
3416
3738
|
});
|
|
3417
|
-
|
|
3739
|
+
this.formObserver.observe(document.body, {
|
|
3418
3740
|
childList: true,
|
|
3419
3741
|
subtree: true
|
|
3420
3742
|
});
|
|
3421
3743
|
this.log('Form monitoring active');
|
|
3422
3744
|
}
|
|
3745
|
+
/**
|
|
3746
|
+
* The form's email input that belongs to the VISITOR (not a gift/recipient field), or
|
|
3747
|
+
* null when there's no usable / unambiguous one. (FSR-49)
|
|
3748
|
+
*/
|
|
3749
|
+
findVisitorEmailInput(form) {
|
|
3750
|
+
const all = Array.from(form.querySelectorAll('input[type="email"], input[name*="email" i], input[id*="email" i], input[autocomplete="email"]'));
|
|
3751
|
+
// Drop third-party (gift/recipient/refer-a-friend) email fields.
|
|
3752
|
+
const candidates = all.filter(i => !this.isThirdPartyEmailField(i));
|
|
3753
|
+
if (candidates.length === 0)
|
|
3754
|
+
return null;
|
|
3755
|
+
if (candidates.length === 1)
|
|
3756
|
+
return candidates[0];
|
|
3757
|
+
// Multiple remain → prefer an explicit autocomplete="email" (the visitor's own field).
|
|
3758
|
+
const preferred = candidates.filter(i => (i.getAttribute('autocomplete') || '').toLowerCase() === 'email');
|
|
3759
|
+
if (preferred.length === 1)
|
|
3760
|
+
return preferred[0];
|
|
3761
|
+
// Still ambiguous → skip rather than identify as the wrong person.
|
|
3762
|
+
this.log('Multiple email inputs in form; skipping auto-identify (ambiguous)');
|
|
3763
|
+
return null;
|
|
3764
|
+
}
|
|
3765
|
+
isThirdPartyEmailField(input) {
|
|
3766
|
+
const haystack = [
|
|
3767
|
+
input.name, input.id, input.getAttribute('autocomplete'),
|
|
3768
|
+
input.getAttribute('aria-label'), input.placeholder
|
|
3769
|
+
].filter(Boolean).join(' ');
|
|
3770
|
+
return AutoIdentifyManager.THIRD_PARTY_EMAIL_FIELD.test(haystack);
|
|
3771
|
+
}
|
|
3423
3772
|
/**
|
|
3424
3773
|
* Scan DOM for forms with email inputs
|
|
3425
3774
|
*/
|
|
3426
3775
|
scanForEmailForms() {
|
|
3776
|
+
if (this.destroyed)
|
|
3777
|
+
return; // FSR-101: defense-in-depth after disconnect
|
|
3427
3778
|
const forms = document.querySelectorAll('form');
|
|
3428
3779
|
forms.forEach(form => {
|
|
3429
3780
|
// Skip if already listening
|
|
3430
3781
|
if (this.formListeners.some(l => l.element === form)) {
|
|
3431
3782
|
return;
|
|
3432
3783
|
}
|
|
3433
|
-
//
|
|
3434
|
-
const emailInput =
|
|
3784
|
+
// Only monitor forms with a capturable VISITOR email field (FSR-49).
|
|
3785
|
+
const emailInput = this.findVisitorEmailInput(form);
|
|
3435
3786
|
if (emailInput) {
|
|
3436
3787
|
const handler = (e) => this.handleFormSubmit(e, form);
|
|
3437
3788
|
form.addEventListener('submit', handler);
|
|
@@ -3445,8 +3796,8 @@ class AutoIdentifyManager {
|
|
|
3445
3796
|
*/
|
|
3446
3797
|
handleFormSubmit(_event, form) {
|
|
3447
3798
|
try {
|
|
3448
|
-
// Find email input
|
|
3449
|
-
const emailInput =
|
|
3799
|
+
// Find the visitor's own email input (excludes gift/recipient fields — FSR-49).
|
|
3800
|
+
const emailInput = this.findVisitorEmailInput(form);
|
|
3450
3801
|
if (!emailInput)
|
|
3451
3802
|
return;
|
|
3452
3803
|
const email = emailInput.value.trim();
|
|
@@ -3788,6 +4139,12 @@ class AutoIdentifyManager {
|
|
|
3788
4139
|
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
|
3789
4140
|
this.originalXHRSend = undefined;
|
|
3790
4141
|
}
|
|
4142
|
+
// Disconnect the form MutationObserver (FSR-101) — otherwise it keeps firing
|
|
4143
|
+
// scanForEmailForms on every DOM mutation after opt-out, re-attaching listeners.
|
|
4144
|
+
if (this.formObserver) {
|
|
4145
|
+
this.formObserver.disconnect();
|
|
4146
|
+
this.formObserver = undefined;
|
|
4147
|
+
}
|
|
3791
4148
|
// Remove form listeners
|
|
3792
4149
|
this.formListeners.forEach(({ element, handler }) => {
|
|
3793
4150
|
element.removeEventListener('submit', handler);
|
|
@@ -3809,6 +4166,10 @@ class AutoIdentifyManager {
|
|
|
3809
4166
|
}
|
|
3810
4167
|
}
|
|
3811
4168
|
}
|
|
4169
|
+
// FSR-49: email fields that belong to a THIRD PARTY, not the visitor — gift/recipient/
|
|
4170
|
+
// refer-a-friend/"email this to" forms. Capturing these identifies the visitor as
|
|
4171
|
+
// someone else (and feeds that person's email to Meta advanced matching).
|
|
4172
|
+
AutoIdentifyManager.THIRD_PARTY_EMAIL_FIELD = /(recipient|friend|gift|refer|invite|colleague|coworker|share|to[-_]?email|email[-_]?to|second(ary)?[-_]?email)/i;
|
|
3812
4173
|
|
|
3813
4174
|
/** Keys the remote config is allowed to fill on DatalyrConfig. */
|
|
3814
4175
|
const REMOTE_KEYS = [
|
|
@@ -3920,8 +4281,11 @@ class Datalyr {
|
|
|
3920
4281
|
if (this.config.platform === 'checkoutchamp') {
|
|
3921
4282
|
this.restoreFromURL();
|
|
3922
4283
|
}
|
|
3923
|
-
// Initialize modules
|
|
3924
|
-
|
|
4284
|
+
// Initialize modules.
|
|
4285
|
+
// FSR-107: don't write a persistent tracking cookie/localStorage id for a visitor who
|
|
4286
|
+
// is opted-out / GPC / DNT at init — the id stays in memory only (events don't send
|
|
4287
|
+
// anyway). shouldTrack() is computable here (config/opt-out/consent are all set above).
|
|
4288
|
+
this.identity = new IdentityManager({ persistNewId: this.shouldTrack() });
|
|
3925
4289
|
this.session = new SessionManager(this.config.sessionTimeout);
|
|
3926
4290
|
this.attribution = new AttributionManager({
|
|
3927
4291
|
attributionWindow: this.config.attributionWindow,
|
|
@@ -4243,10 +4607,13 @@ class Datalyr {
|
|
|
4243
4607
|
return;
|
|
4244
4608
|
}
|
|
4245
4609
|
try {
|
|
4246
|
-
//
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4610
|
+
// FSR-53: do NOT rotate the session id on identify(). 'Session fixation' is an
|
|
4611
|
+
// auth-credential concept; a client-generated analytics session_id is not one.
|
|
4612
|
+
// Rotating split every identified visit (and every auto-identify form submit) into
|
|
4613
|
+
// two session_ids — inflating server-side uniq(session_id) counts by +1 per
|
|
4614
|
+
// identified visitor and landing the post-login conversion in a session with zero
|
|
4615
|
+
// preceding pageviews. Matches iOS/RN (no rotate-on-identify) and every major
|
|
4616
|
+
// analytics SDK; keeps session-scoped attribution joins intact.
|
|
4250
4617
|
// Update identity
|
|
4251
4618
|
const identityLink = this.identity.identify(userId, traits);
|
|
4252
4619
|
// SEC-03 Fix: Store user properties encrypted (contains PII)
|
|
@@ -4396,6 +4763,15 @@ class Datalyr {
|
|
|
4396
4763
|
// Clear the journey too, or the next user's touchpoints append to the previous
|
|
4397
4764
|
// user's (cross-user contamination of touchpoint_count / days_since_first_touch).
|
|
4398
4765
|
storage.remove('dl_journey');
|
|
4766
|
+
// FSR-106: clear first/last-touch attribution as well — same cross-user-contamination
|
|
4767
|
+
// rationale as the journey. Otherwise the next anon visitor's events carry the
|
|
4768
|
+
// PREVIOUS user's first_touch_*/last_touch_* (via getAttributionData's fallback),
|
|
4769
|
+
// crediting user B's conversions to the ad click that brought user A.
|
|
4770
|
+
// (Meta's _fbc/_fbp cookies are deliberately left alone — they're shared with the
|
|
4771
|
+
// merchant's own Meta Pixel and clearing them could break it; the fresh anonymous_id
|
|
4772
|
+
// already de-links the visitor server-side.)
|
|
4773
|
+
storage.remove('dl_first_touch');
|
|
4774
|
+
storage.remove('dl_last_touch');
|
|
4399
4775
|
this.session.createNewSession();
|
|
4400
4776
|
this.log('User reset');
|
|
4401
4777
|
}
|
|
@@ -4572,8 +4948,17 @@ class Datalyr {
|
|
|
4572
4948
|
* Set consent preferences
|
|
4573
4949
|
*/
|
|
4574
4950
|
setConsent(consent) {
|
|
4951
|
+
// Always record + persist consent FIRST, even before init(). FSR-51: a consent-
|
|
4952
|
+
// management platform commonly calls setConsent() before init() so tracking is gated
|
|
4953
|
+
// from the very first event. The old code then dereferenced this.queue/this.config
|
|
4954
|
+
// (both undefined pre-init) inside shouldTrack()/setEnabled() and threw an uncaught
|
|
4955
|
+
// TypeError into the CMP's callback chain. init() re-reads dl_consent (and gates the
|
|
4956
|
+
// queue) at startup, so a pre-init call is honored there.
|
|
4575
4957
|
this.consent = consent;
|
|
4576
4958
|
storage.set('dl_consent', consent);
|
|
4959
|
+
if (!this.initialized) {
|
|
4960
|
+
return;
|
|
4961
|
+
}
|
|
4577
4962
|
// Enforce it live (not just persist it). Gate the queue by the full policy
|
|
4578
4963
|
// (analytics consent + opt-out + DNT/GPC), and on withdrawal purge buffered events
|
|
4579
4964
|
// too — mirroring optOut — so events captured before withdrawal can't drain if
|
|
@@ -4814,6 +5199,16 @@ class Datalyr {
|
|
|
4814
5199
|
const orderId = order.orderId;
|
|
4815
5200
|
if (!orderId)
|
|
4816
5201
|
return;
|
|
5202
|
+
// FSR-102: only burn the once-per-order guard once the Meta Pixel is actually live.
|
|
5203
|
+
// trackToPixels no-ops when the container config never arrived (pixels=null after a
|
|
5204
|
+
// transient /container-scripts failure); setting the guard first would permanently
|
|
5205
|
+
// suppress the co-fire for the rest of the funnel session even when the container
|
|
5206
|
+
// loads on a later upsell page. orderData persists across upsell loads, so a later
|
|
5207
|
+
// page retries. Meta's 48h event_id dedup still protects against a double-fire.
|
|
5208
|
+
if (!this.container.hasMetaPixel()) {
|
|
5209
|
+
this.log("CC Purchase Pixel: Meta pixel not ready; will retry on next funnel page");
|
|
5210
|
+
return;
|
|
5211
|
+
}
|
|
4817
5212
|
// orderData persists across upsell page loads — fire the primary Purchase
|
|
4818
5213
|
// Pixel once per order. (Meta also dedupes by event_id over 48h, so this is
|
|
4819
5214
|
// belt-and-suspenders against a per-page re-fire.)
|
|
@@ -4873,14 +5268,20 @@ class Datalyr {
|
|
|
4873
5268
|
}
|
|
4874
5269
|
};
|
|
4875
5270
|
const buildBridgeParams = () => {
|
|
5271
|
+
var _a, _b;
|
|
4876
5272
|
const attribution = this.attribution.getAttributionData();
|
|
4877
5273
|
const out = {};
|
|
4878
5274
|
const vid = this.identity.getAnonymousId();
|
|
4879
5275
|
const fbc = this.cookies.get("_fbc") || attribution._fbc;
|
|
4880
5276
|
const fbp = this.cookies.get("_fbp") || attribution._fbp;
|
|
4881
|
-
|
|
5277
|
+
// FSR-104: read the named click-id fields directly (captureAttribution stores every
|
|
5278
|
+
// present click id under its own key) so a landing URL carrying BOTH fbclid and
|
|
5279
|
+
// gclid bridges both — the old `clickIdType === 'fbclid' ? clickId : null` dropped
|
|
5280
|
+
// gclid whenever fbclid was the primary, silently losing Google attribution on the
|
|
5281
|
+
// cross-domain CC path. Fall back to the primary clickId for older stored shapes.
|
|
5282
|
+
const fbclid = (_a = attribution.fbclid) !== null && _a !== void 0 ? _a : (attribution.clickIdType === "fbclid" ? attribution.clickId : null);
|
|
4882
5283
|
const fbclidAt = this.cookies.get("_dl_fbclid_at");
|
|
4883
|
-
const gclid = attribution.clickIdType === "gclid" ? attribution.clickId : null;
|
|
5284
|
+
const gclid = (_b = attribution.gclid) !== null && _b !== void 0 ? _b : (attribution.clickIdType === "gclid" ? attribution.clickId : null);
|
|
4884
5285
|
const gclidAt = this.cookies.get("_dl_gclid_at");
|
|
4885
5286
|
if (vid)
|
|
4886
5287
|
out._dl_vid = vid;
|
|
@@ -4983,18 +5384,32 @@ class Datalyr {
|
|
|
4983
5384
|
*/
|
|
4984
5385
|
createEventPayload(eventName, properties, eventIdArg) {
|
|
4985
5386
|
// NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
|
|
4986
|
-
// The session id changes on
|
|
4987
|
-
// identity only synced it at init/start — so the top-level
|
|
4988
|
-
// identity)
|
|
4989
|
-
// here, before reading either, so every event carries a
|
|
5387
|
+
// The session id changes on session timeout (and previously on identify(), removed in
|
|
5388
|
+
// FSR-53), but identity only synced it at init/start — so the top-level
|
|
5389
|
+
// payload.session_id (from identity) could disagree with event_data.session_id (from
|
|
5390
|
+
// session.getMetrics()). Sync here, before reading either, so every event carries a
|
|
5391
|
+
// single consistent session id.
|
|
4990
5392
|
this.identity.setSessionId(this.session.getSessionId());
|
|
4991
5393
|
// Sanitize and merge properties
|
|
4992
5394
|
const sanitizedProperties = sanitizeEventData(properties);
|
|
4993
5395
|
const eventData = deepMerge({}, this.superProperties, sanitizedProperties);
|
|
4994
|
-
//
|
|
5396
|
+
// FSR-105: caller-passed properties take precedence over auto-captured context.
|
|
5397
|
+
// Previously Object.assign clobbered them — e.g. track('purchase', { source: 'pos',
|
|
5398
|
+
// campaign: 'spring-sale' }) shipped with the URL-derived source/campaign instead.
|
|
5399
|
+
// Fill ONLY the attribution/browser-context keys the caller didn't set. (session
|
|
5400
|
+
// metrics + fingerprint stay SDK-authoritative below: session_id is a join key.)
|
|
5401
|
+
const assignMissing = (target, source) => {
|
|
5402
|
+
for (const k in source) {
|
|
5403
|
+
if (Object.prototype.hasOwnProperty.call(source, k) &&
|
|
5404
|
+
!Object.prototype.hasOwnProperty.call(target, k)) {
|
|
5405
|
+
target[k] = source[k];
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
};
|
|
5409
|
+
// Add attribution data (caller wins on collisions)
|
|
4995
5410
|
const attributionData = this.attribution.getAttributionData();
|
|
4996
|
-
|
|
4997
|
-
// Add session metrics
|
|
5411
|
+
assignMissing(eventData, attributionData);
|
|
5412
|
+
// Add session metrics (SDK-authoritative — session_id et al. must not be overridden)
|
|
4998
5413
|
const sessionMetrics = this.session.getMetrics();
|
|
4999
5414
|
Object.assign(eventData, sessionMetrics);
|
|
5000
5415
|
// Add fingerprint data if enabled
|
|
@@ -5004,8 +5419,8 @@ class Datalyr {
|
|
|
5004
5419
|
device_fingerprint: fingerprintData // Use snake_case only (matches backend)
|
|
5005
5420
|
});
|
|
5006
5421
|
}
|
|
5007
|
-
// Add browser context
|
|
5008
|
-
|
|
5422
|
+
// Add browser context (caller wins on collisions)
|
|
5423
|
+
assignMissing(eventData, {
|
|
5009
5424
|
url: window.location.href,
|
|
5010
5425
|
path: window.location.pathname,
|
|
5011
5426
|
referrer: document.referrer,
|
|
@@ -5043,8 +5458,10 @@ class Datalyr {
|
|
|
5043
5458
|
// Resolution metadata
|
|
5044
5459
|
resolution_method: 'browser_sdk',
|
|
5045
5460
|
resolution_confidence: 1.0,
|
|
5046
|
-
// SDK metadata
|
|
5047
|
-
|
|
5461
|
+
// SDK metadata. MUST stay in sync with package.json "version" — the build guard
|
|
5462
|
+
// (scripts/check-bundle.js, run by build:check) fails the build if the bundle's
|
|
5463
|
+
// sdk_version doesn't match package.json. (FSR-103)
|
|
5464
|
+
sdk_version: '1.7.3',
|
|
5048
5465
|
sdk_name: 'datalyr-web-sdk'
|
|
5049
5466
|
};
|
|
5050
5467
|
return payload;
|
|
@@ -5129,10 +5546,13 @@ class Datalyr {
|
|
|
5129
5546
|
setupUnloadHandler() {
|
|
5130
5547
|
// Store refs so destroy() can remove these — otherwise a destroy()+re-init() cycle
|
|
5131
5548
|
// leaks listeners that keep firing forceFlush() on the OLD (destroyed) queue. (H2)
|
|
5132
|
-
|
|
5549
|
+
// FSR-15: pagehide/beforeunload are TERMINAL (beacon both queues, keep the persisted
|
|
5550
|
+
// copy); a visibilitychange:hidden is just a tab switch / background, so use the
|
|
5551
|
+
// non-terminal path (response-checked fetch drain that never erases the backlog).
|
|
5552
|
+
this.unloadHandler = () => { this.queue.forceFlush(true); };
|
|
5133
5553
|
this.visibilityHandler = () => {
|
|
5134
5554
|
if (document.visibilityState === 'hidden')
|
|
5135
|
-
this.queue.forceFlush();
|
|
5555
|
+
this.queue.forceFlush(false);
|
|
5136
5556
|
};
|
|
5137
5557
|
// Use multiple events for maximum compatibility.
|
|
5138
5558
|
window.addEventListener('beforeunload', this.unloadHandler);
|
|
@@ -5220,7 +5640,11 @@ class Datalyr {
|
|
|
5220
5640
|
* Debug logging
|
|
5221
5641
|
*/
|
|
5222
5642
|
log(...args) {
|
|
5223
|
-
|
|
5643
|
+
var _a;
|
|
5644
|
+
// FSR-51: optional-chain config — log() is reachable before init() (e.g.
|
|
5645
|
+
// setSuperProperties() called pre-init), where this.config is undefined; the bare
|
|
5646
|
+
// `this.config.debug` threw an uncaught TypeError into the caller.
|
|
5647
|
+
if ((_a = this.config) === null || _a === void 0 ? void 0 : _a.debug) {
|
|
5224
5648
|
console.log('[Datalyr]', ...args);
|
|
5225
5649
|
}
|
|
5226
5650
|
}
|