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