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