@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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.7.2
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
- var _a;
475
- const value = `; ${document.cookie}`;
476
- const parts = value.split(`; ${name}=`);
477
- if (parts.length === 2) {
478
- const rawValue = ((_a = parts.pop()) === null || _a === void 0 ? void 0 : _a.split(';').shift()) || null;
479
- if (rawValue) {
480
- try {
481
- return decodeURIComponent(rawValue);
482
- }
483
- catch (_b) {
484
- // Return raw value if decoding fails (backwards compatibility)
485
- return rawValue;
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 (sensitiveKeys.test(key)) {
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
- // Remove potential JWT tokens or API keys
704
- if (data.match(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/) || // JWT
705
- data.match(/^[a-f0-9]{32,}$/i)) { // Hex tokens
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
- * Get root domain for cross-subdomain tracking
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 >= 2) {
773
- // Handle .co.uk, .com.au, etc
774
- const tld = parts[parts.length - 1];
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
- // 0. Check URL parameter first (cross-domain linking via _dl_vid)
878
- // This enables tracking continuity when users navigate between different root domains
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 && urlVisitorId.startsWith('anon_')) {
883
- // Valid visitor ID from URL - use it and persist
884
- this.setRootDomainCookie('__dl_visitor_id', urlVisitorId);
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 with cookie/localStorage checks
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
- // 1. Check root domain cookie first (works across subdomains)
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
- // 4. Store in both cookie (primary) and localStorage (backup)
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
- return storage.get('dl_user_id');
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
- // Persist the click time the FIRST time we see fbclid/gclid in URL so we can
1586
- // rebuild fbc (and stamp real click time on server-side events) later even
1587
- // when _fbc/_gclid cookies get evicted. Once-only: never overwrite.
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 && !cookies.get('_dl_fbclid_at')) {
1590
- cookies.set('_dl_fbclid_at', String(Date.now()), 90);
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
- if (!hasRealAttribution && firstTouch) {
1649
- // Direct / internal navigation: fall back to persistent attribution (90-day
1650
- // window) so the event isn't mis-attributed to 'direct', keeping page context.
1651
- if (!firstTouch.expires_at || Date.now() < firstTouch.expires_at) {
1652
- current = Object.assign(Object.assign({}, firstTouch), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
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
- const root = (h) => h.split('.').slice(-2).join('.');
1773
- return root(a) === root(b);
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
- // CRITICAL FIX (CRITICAL-05): Critical events need proper error handling
1888
- // Instead of calling sendBatch() without await, we add them to queue
1889
- // with immediate flush AND move to offline queue on failure
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
- // Send immediately with proper error handling
1893
- this.sendBatch([event]).catch((error) => {
1894
- this.log('Critical event send failed, adding to offline queue:', eventName, error);
1895
- // Move to offline queue to ensure it's not lost
1896
- this.moveToOfflineQueue([event]);
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
- this.moveToOfflineQueue(events);
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: JSON.stringify(batchPayload),
2073
- keepalive: true
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 it here (neither fallback nor
2095
- // backoff). Propagate so the batch moves to the offline queue; the periodic drain
2096
- // resumes only after rateLimitedUntil. This is what prevents the retry storm.
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
- // Put back in queue
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 (for page unload)
2301
- * FIXED (WEB-3): include the offline queue, chunk under sendBeacon's ~64KB cap,
2302
- * and persist any chunk the browser refuses so it survives to the next page load.
2303
- * Previously this beaconed the live queue as one blob ignoring the offline queue
2304
- * entirely and silently failing (returning false) whenever the payload exceeded 64KB.
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
- * Two follow-up review fixes:
2307
- * - HIGH-1: exclude any batch currently in-flight in _flush(). That batch is being
2308
- * sent with a keepalive fetch that already survives unload; re-beaconing it would
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, void 0, void 0, function* () {
2609
+ return __awaiter(this, arguments, void 0, function* (isTerminal = false) {
2317
2610
  if (!this.enabled)
2318
2611
  return;
2319
- if (!navigator.sendBeacon) {
2320
- // No beacon API best-effort keepalive-fetch drain of both queues.
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 synchronously: keep only the in-flight front (for _flush to settle) and
2329
- // empty the offline queue, so a second unload event this lifecycle re-enters with
2330
- // nothing to re-beacon. The refused remainder (if any) is persisted to STORAGE
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
- // Send each chunk; the moment the browser refuses to enqueue one, keep it and
2357
- // everything after it for the next session.
2358
- let failedFrom = -1;
2359
- for (let c = 0; c < chunks.length; c++) {
2360
- const blob = new Blob([JSON.stringify(this.buildBatch(chunks[c]))], {
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
- // Fetch container configuration
2626
- const response = yield fetch(`${this.endpoint}/container-scripts`, {
2627
- method: 'POST',
2628
- headers: {
2629
- 'Content-Type': 'application/json',
2630
- 'X-Container-Version': '1.0'
2631
- },
2632
- body: JSON.stringify({
2633
- workspaceId: this.workspaceId
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
- const observer = new MutationObserver(() => {
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
- observer.observe(document.body, {
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
- // Check if form has email input
3434
- const emailInput = form.querySelector('input[type="email"], input[name*="email" i], input[id*="email" i]');
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 = form.querySelector('input[type="email"], input[name*="email" i], input[id*="email" i]');
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
- this.identity = new IdentityManager();
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
- // FIXED (DATA-05): Rotate session ID on identify to prevent session fixation
4247
- if (this.session) {
4248
- this.session.rotateSessionId();
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
- const fbclid = attribution.clickIdType === "fbclid" ? attribution.clickId : null;
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 identify() (rotateSessionId) and on session timeout, but
4987
- // identity only synced it at init/start — so the top-level payload.session_id (from
4988
- // identity) disagreed with event_data.session_id (from session.getMetrics()). Sync
4989
- // here, before reading either, so every event carries a single consistent session id.
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
- // Add attribution data
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
- Object.assign(eventData, attributionData);
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
- Object.assign(eventData, {
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 (keep in sync with package.json version)
5047
- sdk_version: '1.7.1',
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
- this.unloadHandler = () => { this.queue.forceFlush(); };
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
- if (this.config.debug) {
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
  }