@datalyr/web 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.7.1
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
+ ]);
872
+ /**
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
+ }
759
899
  /**
760
- * Get root domain for cross-subdomain tracking
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
@@ -1359,7 +1505,7 @@ class AttributionManager {
1359
1505
  'msclkid', // Microsoft/Bing
1360
1506
  'twclid', // Twitter/X
1361
1507
  'li_fat_id', // LinkedIn
1362
- 'sclid', // Snapchat
1508
+ 'sclid', // Snapchat (canonical internal name — see CLICK_ID_ALIASES)
1363
1509
  'dclid', // Google Display/DoubleClick
1364
1510
  'epik', // Pinterest
1365
1511
  'rdt_cid', // Reddit
@@ -1367,6 +1513,15 @@ class AttributionManager {
1367
1513
  'irclid', // Impact Radius
1368
1514
  'ko_click_id' // Klaviyo
1369
1515
  ];
1516
+ // Real ad-platform URL params whose value we store under a canonical internal
1517
+ // name. Snapchat appends &ScCid= (alias `sccid`) to the landing URL — it does
1518
+ // NOT use `sclid`. Query-param lookup is case-sensitive, so capture the real
1519
+ // params and normalize to `sclid`, which ingest / the MV / server-side
1520
+ // attribution all key on. Without this, real Snap ad clicks captured nothing.
1521
+ this.CLICK_ID_ALIASES = {
1522
+ ScCid: 'sclid',
1523
+ sccid: 'sclid',
1524
+ };
1370
1525
  // Default tracked params matching dl.js
1371
1526
  this.DEFAULT_TRACKED_PARAMS = [
1372
1527
  'lyr', // Datalyr partner tracking
@@ -1400,12 +1555,19 @@ class AttributionManager {
1400
1555
  const attribution = {
1401
1556
  timestamp: Date.now()
1402
1557
  };
1403
- // 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).
1404
1565
  for (const utm of this.UTM_PARAMS) {
1405
1566
  const value = params[utm];
1406
1567
  if (value) {
1568
+ attribution[utm] = value; // canonical: utm_source, utm_campaign, ...
1407
1569
  const key = utm.replace('utm_', '');
1408
- attribution[key] = value;
1570
+ attribution[key] = value; // alias: source, campaign, ...
1409
1571
  }
1410
1572
  }
1411
1573
  // Capture click IDs. The first present (by CLICK_IDS priority) is the primary
@@ -1423,6 +1585,18 @@ class AttributionManager {
1423
1585
  attribution[clickId] = value;
1424
1586
  }
1425
1587
  }
1588
+ // Aliased click IDs (e.g. Snap's ScCid/sccid → canonical sclid). Param lookup
1589
+ // above is case-sensitive and uses canonical names, so check the real params here.
1590
+ for (const [param, canonical] of Object.entries(this.CLICK_ID_ALIASES)) {
1591
+ const value = params[param];
1592
+ if (value && !attribution[canonical]) {
1593
+ if (!attribution.clickId) {
1594
+ attribution.clickId = value;
1595
+ attribution.clickIdType = canonical;
1596
+ }
1597
+ attribution[canonical] = value;
1598
+ }
1599
+ }
1426
1600
  // Capture custom tracked parameters
1427
1601
  for (const param of this.trackedParams) {
1428
1602
  const value = params[param];
@@ -1547,6 +1721,9 @@ class AttributionManager {
1547
1721
  // TikTok cookies
1548
1722
  adCookies._ttp = cookies.get('_ttp');
1549
1723
  adCookies._ttc = cookies.get('_ttc');
1724
+ // Snapchat first-party cookie (Snap Pixel sets _scid). Flows to the server as
1725
+ // cookies._scid and is sent on the Snap CAPI event as sc_cookie1 (raw).
1726
+ adCookies._scid = cookies.get('_scid');
1550
1727
  // Generate _fbp if missing (Facebook browser ID)
1551
1728
  if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
1552
1729
  const timestamp = Date.now();
@@ -1558,33 +1735,53 @@ class AttributionManager {
1558
1735
  // Optionally set the cookie for future use
1559
1736
  cookies.set('_fbp', adCookies._fbp, 90);
1560
1737
  }
1561
- // Persist the click time the FIRST time we see fbclid/gclid in URL so we can
1562
- // rebuild fbc (and stamp real click time on server-side events) later even
1563
- // 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.
1564
1751
  const fbclid = this.getCurrentFbclid();
1565
- if (fbclid && !cookies.get('_dl_fbclid_at')) {
1566
- cookies.set('_dl_fbclid_at', String(Date.now()), 90);
1567
- }
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.)
1568
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;
1569
1782
  if (gclid && !cookies.get('_dl_gclid_at')) {
1570
1783
  cookies.set('_dl_gclid_at', String(Date.now()), 90);
1571
1784
  }
1572
- // Generate _fbc if we have fbclid but no _fbc.
1573
- // Meta's fbc format is `fb.{subdomainIndex}.{creationTime}.{fbclid}` where
1574
- // creationTime is UNIX time in MILLISECONDS (matches the _fbp generation above
1575
- // and the real _fbc cookie the Meta Pixel writes). Do NOT use seconds here.
1576
- if (fbclid && !adCookies._fbc) {
1577
- // Prefer the persisted click time when valid; fall back to now if the
1578
- // cookie is missing or corrupted (e.g. user edited it to garbage).
1579
- // Without this guard, Number("abc") = NaN and Meta would reject
1580
- // `fb.1.NaN.{fbclid}`.
1581
- const stored = cookies.get('_dl_fbclid_at');
1582
- const parsed = stored ? Number(stored) : NaN;
1583
- const timestamp = Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
1584
- adCookies._fbc = `fb.1.${timestamp}.${fbclid}`;
1585
- // Optionally set the cookie for future use
1586
- cookies.set('_fbc', adCookies._fbc, 90);
1587
- }
1588
1785
  // Filter out null values for cleaner data
1589
1786
  return Object.fromEntries(Object.entries(adCookies).filter(([_, value]) => value !== null));
1590
1787
  }
@@ -1604,6 +1801,21 @@ class AttributionManager {
1604
1801
  const params = this.queryParamsCache || getAllQueryParams();
1605
1802
  return params.fbclid || null;
1606
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
+ }
1607
1819
  /**
1608
1820
  * Get attribution data for event
1609
1821
  */
@@ -1621,11 +1833,18 @@ class AttributionManager {
1621
1833
  const hasRealAttribution = !!(current.clickId ||
1622
1834
  current.campaign ||
1623
1835
  (current.source && current.source !== 'direct'));
1624
- if (!hasRealAttribution && firstTouch) {
1625
- // Direct / internal navigation: fall back to persistent attribution (90-day
1626
- // window) so the event isn't mis-attributed to 'direct', keeping page context.
1627
- if (!firstTouch.expires_at || Date.now() < firstTouch.expires_at) {
1628
- 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 });
1629
1848
  }
1630
1849
  }
1631
1850
  // Capture advertising cookies automatically
@@ -1745,8 +1964,10 @@ class AttributionManager {
1745
1964
  * internal navs from being mis-classified as referral.
1746
1965
  */
1747
1966
  isSameRootDomain(a, b) {
1748
- const root = (h) => h.split('.').slice(-2).join('.');
1749
- 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);
1750
1971
  }
1751
1972
  /**
1752
1973
  * Extract hostname from URL
@@ -1794,6 +2015,16 @@ const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_ite
1794
2015
  // already-overloaded server). See sendBatch / the rateLimitedUntil gate.
1795
2016
  class RateLimitError extends Error {
1796
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
+ }
1797
2028
  class EventQueue {
1798
2029
  constructor(config) {
1799
2030
  this.queue = [];
@@ -1860,16 +2091,30 @@ class EventQueue {
1860
2091
  this.log('Duplicate event suppressed:', eventName);
1861
2092
  return;
1862
2093
  }
1863
- // CRITICAL FIX (CRITICAL-05): Critical events need proper error handling
1864
- // Instead of calling sendBatch() without await, we add them to queue
1865
- // 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.
1866
2101
  if (this.config.criticalEvents.includes(eventName)) {
1867
2102
  this.log('Critical event, sending immediately:', eventName);
1868
- // Send immediately with proper error handling
1869
- this.sendBatch([event]).catch((error) => {
1870
- this.log('Critical event send failed, adding to offline queue:', eventName, error);
1871
- // Move to offline queue to ensure it's not lost
1872
- 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);
1873
2118
  });
1874
2119
  return;
1875
2120
  }
@@ -2018,7 +2263,14 @@ class EventQueue {
2018
2263
  // Remove the same slice from the live queue; the offline queue now drains on
2019
2264
  // every periodic tick (WEB-1/WEB-2), so they are still retried, not stranded.
2020
2265
  this.queue.splice(0, batchSize);
2021
- 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
+ }
2022
2274
  }
2023
2275
  finally {
2024
2276
  this.inFlight = [];
@@ -2038,6 +2290,14 @@ class EventQueue {
2038
2290
  // Get current endpoint (main or fallback)
2039
2291
  const endpoints = [this.config.endpoint, ...this.config.fallbackEndpoints];
2040
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;
2041
2301
  try {
2042
2302
  const response = yield fetch(currentEndpoint, {
2043
2303
  method: 'POST',
@@ -2045,8 +2305,8 @@ class EventQueue {
2045
2305
  'Content-Type': 'application/json',
2046
2306
  'X-Batch-Size': events.length.toString()
2047
2307
  },
2048
- body: JSON.stringify(batchPayload),
2049
- keepalive: true
2308
+ body,
2309
+ keepalive: useKeepalive
2050
2310
  });
2051
2311
  if (!response.ok) {
2052
2312
  // Handle rate limiting
@@ -2062,15 +2322,21 @@ class EventQueue {
2062
2322
  this.log(`Rate limited; backing off ${retryAfter}s`);
2063
2323
  throw new RateLimitError('Rate limited (429)');
2064
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
+ }
2065
2331
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
2066
2332
  }
2067
2333
  this.log(`Batch sent successfully to ${currentEndpoint}: ${events.length} events`);
2068
2334
  }
2069
2335
  catch (error) {
2070
- // A 429 is deliberate backpressure — do NOT retry it here (neither fallback nor
2071
- // backoff). Propagate so the batch moves to the offline queue; the periodic drain
2072
- // resumes only after rateLimitedUntil. This is what prevents the retry storm.
2073
- 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) {
2074
2340
  throw error;
2075
2341
  }
2076
2342
  // Try next fallback endpoint if available
@@ -2174,6 +2440,38 @@ class EventQueue {
2174
2440
  this.offlineQueueLock = false;
2175
2441
  }
2176
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
+ }
2177
2475
  /**
2178
2476
  * Load offline queue from storage
2179
2477
  */
@@ -2228,8 +2526,17 @@ class EventQueue {
2228
2526
  this.saveOfflineQueue();
2229
2527
  }
2230
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
+ }
2231
2537
  this.log('Failed to send offline batch:', error);
2232
- // 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.
2233
2540
  this.offlineQueue.unshift(...batch);
2234
2541
  this.saveOfflineQueue();
2235
2542
  break;
@@ -2273,42 +2580,71 @@ class EventQueue {
2273
2580
  };
2274
2581
  }
2275
2582
  /**
2276
- * Force flush (for page unload)
2277
- * FIXED (WEB-3): include the offline queue, chunk under sendBeacon's ~64KB cap,
2278
- * and persist any chunk the browser refuses so it survives to the next page load.
2279
- * Previously this beaconed the live queue as one blob — ignoring the offline queue
2280
- * 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).
2281
2585
  *
2282
- * Two follow-up review fixes:
2283
- * - HIGH-1: exclude any batch currently in-flight in _flush(). That batch is being
2284
- * sent with a keepalive fetch that already survives unload; re-beaconing it would
2285
- * double-send. (`inFlight` is the front `inFlight.length` events of this.queue.)
2286
- * - HIGH-2: handleUnload is wired to visibilitychange+pagehide+beforeunload, so this
2287
- * runs multiple times per lifecycle. Detach what we beacon SYNCHRONOUSLY (clear the
2288
- * in-memory queues, persist a refused remainder straight to storage rather than back
2289
- * into this.offlineQueue) so a repeat call finds nothing to re-send.
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).
2603
+ *
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).
2290
2607
  */
2291
2608
  forceFlush() {
2292
- return __awaiter(this, void 0, void 0, function* () {
2609
+ return __awaiter(this, arguments, void 0, function* (isTerminal = false) {
2293
2610
  if (!this.enabled)
2294
2611
  return;
2295
- if (!navigator.sendBeacon) {
2296
- // 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;
2297
2619
  yield this.flush();
2298
2620
  yield this.processOfflineQueue();
2299
2621
  return;
2300
2622
  }
2623
+ // Terminal unload.
2301
2624
  // Exclude the in-flight batch (its keepalive fetch already carries it).
2302
2625
  const live = this.queue.slice(this.inFlight.length);
2303
2626
  const pending = [...live, ...this.offlineQueue];
2304
- // Detach synchronously: keep only the in-flight front (for _flush to settle) and
2305
- // empty the offline queue, so a second unload event this lifecycle re-enters with
2306
- // nothing to re-beacon. The refused remainder (if any) is persisted to STORAGE
2307
- // 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)
2308
2630
  this.queue = this.queue.slice(0, this.inFlight.length);
2309
2631
  this.offlineQueue = [];
2310
2632
  if (pending.length === 0)
2311
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
2312
2648
  const MAX_BEACON_BYTES = 60000; // headroom under the browser's ~64KB cap
2313
2649
  // Greedily pack events into chunks bounded by BOTH batchSize and byte size.
2314
2650
  const chunks = [];
@@ -2329,36 +2665,18 @@ class EventQueue {
2329
2665
  }
2330
2666
  if (current.length > 0)
2331
2667
  chunks.push(current);
2332
- // Send each chunk; the moment the browser refuses to enqueue one, keep it and
2333
- // everything after it for the next session.
2334
- let failedFrom = -1;
2335
- for (let c = 0; c < chunks.length; c++) {
2336
- 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))], {
2337
2673
  type: 'application/json'
2338
2674
  });
2339
- if (!navigator.sendBeacon(this.config.endpoint, blob)) {
2340
- failedFrom = c;
2675
+ if (!navigator.sendBeacon(this.config.endpoint, blob))
2341
2676
  break;
2342
- }
2343
- }
2344
- if (failedFrom >= 0) {
2345
- const remainder = chunks
2346
- .slice(failedFrom)
2347
- .reduce((acc, c) => acc.concat(c), []);
2348
- // Persist straight to storage (NOT this.offlineQueue) so a repeat unload event
2349
- // won't re-beacon it; the next page load's drain (which uses a normal fetch with
2350
- // no 64KB cap) delivers it. MED-2: log if the maxOfflineQueueSize cap truncates.
2351
- const toPersist = remainder.slice(-this.config.maxOfflineQueueSize);
2352
- if (remainder.length > toPersist.length) {
2353
- this.log(`Offline cap dropped ${remainder.length - toPersist.length} oldest events at unload`);
2354
- }
2355
- storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
2356
- this.log(`sendBeacon refused ${remainder.length} events; persisted ${toPersist.length} for next load`);
2357
- }
2358
- else {
2359
- this.log('Events sent via sendBeacon');
2360
- storage.remove(this.OFFLINE_QUEUE_KEY);
2677
+ beaconed += chunk.length;
2361
2678
  }
2679
+ this.log(`forceFlush(terminal): beaconed ${beaconed}/${pending.length} events; persisted copy retained for next-load drain`);
2362
2680
  });
2363
2681
  }
2364
2682
  /**
@@ -2598,17 +2916,33 @@ class ContainerManager {
2598
2916
  if (this.initialized)
2599
2917
  return;
2600
2918
  try {
2601
- // Fetch container configuration
2602
- const response = yield fetch(`${this.endpoint}/container-scripts`, {
2603
- method: 'POST',
2604
- headers: {
2605
- 'Content-Type': 'application/json',
2606
- 'X-Container-Version': '1.0'
2607
- },
2608
- body: JSON.stringify({
2609
- workspaceId: this.workspaceId
2610
- })
2611
- });
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
+ }
2612
2946
  if (!response.ok) {
2613
2947
  throw new Error(`Failed to fetch container scripts: ${response.status}`);
2614
2948
  }
@@ -3079,6 +3413,16 @@ class ContainerManager {
3079
3413
  this.log('Error initializing TikTok Pixel:', error);
3080
3414
  }
3081
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
+ }
3082
3426
  /**
3083
3427
  * Track event to all initialized pixels
3084
3428
  */
@@ -3386,28 +3730,59 @@ class AutoIdentifyManager {
3386
3730
  setupFormMonitoring() {
3387
3731
  // Monitor existing forms
3388
3732
  this.scanForEmailForms();
3389
- // Watch for new forms (SPAs)
3390
- 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(() => {
3391
3737
  this.scanForEmailForms();
3392
3738
  });
3393
- observer.observe(document.body, {
3739
+ this.formObserver.observe(document.body, {
3394
3740
  childList: true,
3395
3741
  subtree: true
3396
3742
  });
3397
3743
  this.log('Form monitoring active');
3398
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
+ }
3399
3772
  /**
3400
3773
  * Scan DOM for forms with email inputs
3401
3774
  */
3402
3775
  scanForEmailForms() {
3776
+ if (this.destroyed)
3777
+ return; // FSR-101: defense-in-depth after disconnect
3403
3778
  const forms = document.querySelectorAll('form');
3404
3779
  forms.forEach(form => {
3405
3780
  // Skip if already listening
3406
3781
  if (this.formListeners.some(l => l.element === form)) {
3407
3782
  return;
3408
3783
  }
3409
- // Check if form has email input
3410
- 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);
3411
3786
  if (emailInput) {
3412
3787
  const handler = (e) => this.handleFormSubmit(e, form);
3413
3788
  form.addEventListener('submit', handler);
@@ -3421,8 +3796,8 @@ class AutoIdentifyManager {
3421
3796
  */
3422
3797
  handleFormSubmit(_event, form) {
3423
3798
  try {
3424
- // Find email input
3425
- 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);
3426
3801
  if (!emailInput)
3427
3802
  return;
3428
3803
  const email = emailInput.value.trim();
@@ -3764,6 +4139,12 @@ class AutoIdentifyManager {
3764
4139
  XMLHttpRequest.prototype.send = this.originalXHRSend;
3765
4140
  this.originalXHRSend = undefined;
3766
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
+ }
3767
4148
  // Remove form listeners
3768
4149
  this.formListeners.forEach(({ element, handler }) => {
3769
4150
  element.removeEventListener('submit', handler);
@@ -3785,6 +4166,10 @@ class AutoIdentifyManager {
3785
4166
  }
3786
4167
  }
3787
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;
3788
4173
 
3789
4174
  /** Keys the remote config is allowed to fill on DatalyrConfig. */
3790
4175
  const REMOTE_KEYS = [
@@ -3896,8 +4281,11 @@ class Datalyr {
3896
4281
  if (this.config.platform === 'checkoutchamp') {
3897
4282
  this.restoreFromURL();
3898
4283
  }
3899
- // Initialize modules
3900
- 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() });
3901
4289
  this.session = new SessionManager(this.config.sessionTimeout);
3902
4290
  this.attribution = new AttributionManager({
3903
4291
  attributionWindow: this.config.attributionWindow,
@@ -4219,10 +4607,13 @@ class Datalyr {
4219
4607
  return;
4220
4608
  }
4221
4609
  try {
4222
- // FIXED (DATA-05): Rotate session ID on identify to prevent session fixation
4223
- if (this.session) {
4224
- this.session.rotateSessionId();
4225
- }
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.
4226
4617
  // Update identity
4227
4618
  const identityLink = this.identity.identify(userId, traits);
4228
4619
  // SEC-03 Fix: Store user properties encrypted (contains PII)
@@ -4372,6 +4763,15 @@ class Datalyr {
4372
4763
  // Clear the journey too, or the next user's touchpoints append to the previous
4373
4764
  // user's (cross-user contamination of touchpoint_count / days_since_first_touch).
4374
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');
4375
4775
  this.session.createNewSession();
4376
4776
  this.log('User reset');
4377
4777
  }
@@ -4548,8 +4948,17 @@ class Datalyr {
4548
4948
  * Set consent preferences
4549
4949
  */
4550
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.
4551
4957
  this.consent = consent;
4552
4958
  storage.set('dl_consent', consent);
4959
+ if (!this.initialized) {
4960
+ return;
4961
+ }
4553
4962
  // Enforce it live (not just persist it). Gate the queue by the full policy
4554
4963
  // (analytics consent + opt-out + DNT/GPC), and on withdrawal purge buffered events
4555
4964
  // too — mirroring optOut — so events captured before withdrawal can't drain if
@@ -4790,6 +5199,16 @@ class Datalyr {
4790
5199
  const orderId = order.orderId;
4791
5200
  if (!orderId)
4792
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
+ }
4793
5212
  // orderData persists across upsell page loads — fire the primary Purchase
4794
5213
  // Pixel once per order. (Meta also dedupes by event_id over 48h, so this is
4795
5214
  // belt-and-suspenders against a per-page re-fire.)
@@ -4849,14 +5268,20 @@ class Datalyr {
4849
5268
  }
4850
5269
  };
4851
5270
  const buildBridgeParams = () => {
5271
+ var _a, _b;
4852
5272
  const attribution = this.attribution.getAttributionData();
4853
5273
  const out = {};
4854
5274
  const vid = this.identity.getAnonymousId();
4855
5275
  const fbc = this.cookies.get("_fbc") || attribution._fbc;
4856
5276
  const fbp = this.cookies.get("_fbp") || attribution._fbp;
4857
- 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);
4858
5283
  const fbclidAt = this.cookies.get("_dl_fbclid_at");
4859
- 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);
4860
5285
  const gclidAt = this.cookies.get("_dl_gclid_at");
4861
5286
  if (vid)
4862
5287
  out._dl_vid = vid;
@@ -4959,18 +5384,32 @@ class Datalyr {
4959
5384
  */
4960
5385
  createEventPayload(eventName, properties, eventIdArg) {
4961
5386
  // NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
4962
- // The session id changes on identify() (rotateSessionId) and on session timeout, but
4963
- // identity only synced it at init/start — so the top-level payload.session_id (from
4964
- // identity) disagreed with event_data.session_id (from session.getMetrics()). Sync
4965
- // 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.
4966
5392
  this.identity.setSessionId(this.session.getSessionId());
4967
5393
  // Sanitize and merge properties
4968
5394
  const sanitizedProperties = sanitizeEventData(properties);
4969
5395
  const eventData = deepMerge({}, this.superProperties, sanitizedProperties);
4970
- // 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)
4971
5410
  const attributionData = this.attribution.getAttributionData();
4972
- Object.assign(eventData, attributionData);
4973
- // Add session metrics
5411
+ assignMissing(eventData, attributionData);
5412
+ // Add session metrics (SDK-authoritative — session_id et al. must not be overridden)
4974
5413
  const sessionMetrics = this.session.getMetrics();
4975
5414
  Object.assign(eventData, sessionMetrics);
4976
5415
  // Add fingerprint data if enabled
@@ -4980,8 +5419,8 @@ class Datalyr {
4980
5419
  device_fingerprint: fingerprintData // Use snake_case only (matches backend)
4981
5420
  });
4982
5421
  }
4983
- // Add browser context
4984
- Object.assign(eventData, {
5422
+ // Add browser context (caller wins on collisions)
5423
+ assignMissing(eventData, {
4985
5424
  url: window.location.href,
4986
5425
  path: window.location.pathname,
4987
5426
  referrer: document.referrer,
@@ -5019,8 +5458,10 @@ class Datalyr {
5019
5458
  // Resolution metadata
5020
5459
  resolution_method: 'browser_sdk',
5021
5460
  resolution_confidence: 1.0,
5022
- // SDK metadata (keep in sync with package.json version)
5023
- 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',
5024
5465
  sdk_name: 'datalyr-web-sdk'
5025
5466
  };
5026
5467
  return payload;
@@ -5105,10 +5546,13 @@ class Datalyr {
5105
5546
  setupUnloadHandler() {
5106
5547
  // Store refs so destroy() can remove these — otherwise a destroy()+re-init() cycle
5107
5548
  // leaks listeners that keep firing forceFlush() on the OLD (destroyed) queue. (H2)
5108
- 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); };
5109
5553
  this.visibilityHandler = () => {
5110
5554
  if (document.visibilityState === 'hidden')
5111
- this.queue.forceFlush();
5555
+ this.queue.forceFlush(false);
5112
5556
  };
5113
5557
  // Use multiple events for maximum compatibility.
5114
5558
  window.addEventListener('beforeunload', this.unloadHandler);
@@ -5196,7 +5640,11 @@ class Datalyr {
5196
5640
  * Debug logging
5197
5641
  */
5198
5642
  log(...args) {
5199
- 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) {
5200
5648
  console.log('[Datalyr]', ...args);
5201
5649
  }
5202
5650
  }