@datalyr/web 1.7.2 → 1.7.3

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