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