@datalyr/web 1.7.0 → 1.7.2

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.0
2
+ * @datalyr/web v1.7.2
3
3
  * Datalyr Web SDK - Modern attribution tracking for web applications
4
4
  * (c) 2026 Datalyr Inc.
5
5
  * Released under the MIT License
@@ -1362,7 +1362,7 @@ var Datalyr = (function (exports) {
1362
1362
  'msclkid', // Microsoft/Bing
1363
1363
  'twclid', // Twitter/X
1364
1364
  'li_fat_id', // LinkedIn
1365
- 'sclid', // Snapchat
1365
+ 'sclid', // Snapchat (canonical internal name — see CLICK_ID_ALIASES)
1366
1366
  'dclid', // Google Display/DoubleClick
1367
1367
  'epik', // Pinterest
1368
1368
  'rdt_cid', // Reddit
@@ -1370,6 +1370,15 @@ var Datalyr = (function (exports) {
1370
1370
  'irclid', // Impact Radius
1371
1371
  'ko_click_id' // Klaviyo
1372
1372
  ];
1373
+ // Real ad-platform URL params whose value we store under a canonical internal
1374
+ // name. Snapchat appends &ScCid= (alias `sccid`) to the landing URL — it does
1375
+ // NOT use `sclid`. Query-param lookup is case-sensitive, so capture the real
1376
+ // params and normalize to `sclid`, which ingest / the MV / server-side
1377
+ // attribution all key on. Without this, real Snap ad clicks captured nothing.
1378
+ this.CLICK_ID_ALIASES = {
1379
+ ScCid: 'sclid',
1380
+ sccid: 'sclid',
1381
+ };
1373
1382
  // Default tracked params matching dl.js
1374
1383
  this.DEFAULT_TRACKED_PARAMS = [
1375
1384
  'lyr', // Datalyr partner tracking
@@ -1411,13 +1420,31 @@ var Datalyr = (function (exports) {
1411
1420
  attribution[key] = value;
1412
1421
  }
1413
1422
  }
1414
- // Capture click IDs
1423
+ // Capture click IDs. The first present (by CLICK_IDS priority) is the primary
1424
+ // clickId/clickIdType, but ALSO capture every present click ID as its own named
1425
+ // field — a single URL can carry both fbclid and gclid (redirect chains, forwarded
1426
+ // / re-shared links), and keeping only the first dropped the others' platform
1427
+ // attribution entirely. (WEB NEW-3)
1415
1428
  for (const clickId of this.CLICK_IDS) {
1416
1429
  const value = params[clickId];
1417
1430
  if (value) {
1418
- attribution.clickId = value;
1419
- attribution.clickIdType = clickId;
1420
- break; // Use first found click ID
1431
+ if (!attribution.clickId) {
1432
+ attribution.clickId = value;
1433
+ attribution.clickIdType = clickId;
1434
+ }
1435
+ attribution[clickId] = value;
1436
+ }
1437
+ }
1438
+ // Aliased click IDs (e.g. Snap's ScCid/sccid → canonical sclid). Param lookup
1439
+ // above is case-sensitive and uses canonical names, so check the real params here.
1440
+ for (const [param, canonical] of Object.entries(this.CLICK_ID_ALIASES)) {
1441
+ const value = params[param];
1442
+ if (value && !attribution[canonical]) {
1443
+ if (!attribution.clickId) {
1444
+ attribution.clickId = value;
1445
+ attribution.clickIdType = canonical;
1446
+ }
1447
+ attribution[canonical] = value;
1421
1448
  }
1422
1449
  }
1423
1450
  // Capture custom tracked parameters
@@ -1544,10 +1571,16 @@ var Datalyr = (function (exports) {
1544
1571
  // TikTok cookies
1545
1572
  adCookies._ttp = cookies.get('_ttp');
1546
1573
  adCookies._ttc = cookies.get('_ttc');
1574
+ // Snapchat first-party cookie (Snap Pixel sets _scid). Flows to the server as
1575
+ // cookies._scid and is sent on the Snap CAPI event as sc_cookie1 (raw).
1576
+ adCookies._scid = cookies.get('_scid');
1547
1577
  // Generate _fbp if missing (Facebook browser ID)
1548
1578
  if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
1549
1579
  const timestamp = Date.now();
1550
- const randomId = Math.random().toString(36).substring(2, 15);
1580
+ // Meta's _fbp format is fb.1.<creationTimeMs>.<randomNumber> where the last
1581
+ // segment MUST be a decimal integer. The old base36 string was non-conformant —
1582
+ // Meta ignores it (and it can shadow a real _fbp), degrading EMQ. (WEB-17)
1583
+ const randomId = Math.floor(Math.random() * 1e10).toString();
1551
1584
  adCookies._fbp = `fb.1.${timestamp}.${randomId}`;
1552
1585
  // Optionally set the cookie for future use
1553
1586
  cookies.set('_fbp', adCookies._fbp, 90);
@@ -1606,23 +1639,30 @@ var Datalyr = (function (exports) {
1606
1639
  const lastTouch = this.getLastTouch();
1607
1640
  const journey = this.getJourney();
1608
1641
  let current = this.captureAttribution();
1609
- // CRITICAL FIX: If current session has no attribution (direct/organic),
1610
- // fallback to persistent attribution from localStorage (90-day window)
1611
- const hasCurrentAttribution = !!(current.source || current.medium || current.clickId || current.campaign);
1612
- if (!hasCurrentAttribution && firstTouch) {
1613
- // Check if persistent attribution is still valid
1642
+ // A "real" attribution signal on THIS pageview = a click ID, a UTM/campaign, or a
1643
+ // referrer/UTM-derived source. determineSource()/determineMedium() FLOOR to
1644
+ // 'direct'/'none', so the mere presence of current.source/medium is NOT a signal.
1645
+ // The old `hasCurrentAttribution` (truthy because source is always at least 'direct')
1646
+ // made the fallback below dead code AND caused storeLastTouch to overwrite a real
1647
+ // paid last-touch with 'direct'/'none' on the next internal navigation. (WEB NEW-1)
1648
+ const hasRealAttribution = !!(current.clickId ||
1649
+ current.campaign ||
1650
+ (current.source && current.source !== 'direct'));
1651
+ if (!hasRealAttribution && firstTouch) {
1652
+ // Direct / internal navigation: fall back to persistent attribution (90-day
1653
+ // window) so the event isn't mis-attributed to 'direct', keeping page context.
1614
1654
  if (!firstTouch.expires_at || Date.now() < firstTouch.expires_at) {
1615
- // Use persistent attribution but keep current page context
1616
1655
  current = Object.assign(Object.assign({}, firstTouch), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
1617
1656
  }
1618
1657
  }
1619
1658
  // Capture advertising cookies automatically
1620
1659
  const adCookies = this.captureAdCookies();
1621
- // Update first/last touch if needed
1622
- if (!firstTouch && Object.keys(current).length > 1) {
1660
+ // Only (re)write first/last touch when this pageview carried a REAL signal — never
1661
+ // overwrite stored attribution with the 'direct'/'none' floor of an internal nav.
1662
+ if (!firstTouch && hasRealAttribution) {
1623
1663
  this.storeFirstTouch(current);
1624
1664
  }
1625
- if (Object.keys(current).length > 1) {
1665
+ if (hasRealAttribution) {
1626
1666
  this.storeLastTouch(current);
1627
1667
  }
1628
1668
  return Object.assign(Object.assign(Object.assign({}, current), adCookies), {
@@ -1659,6 +1699,15 @@ var Datalyr = (function (exports) {
1659
1699
  // Check referrer
1660
1700
  if (attribution.referrerHost) {
1661
1701
  const host = attribution.referrerHost.toLowerCase();
1702
+ // Same-site referrer = internal navigation, NOT a new acquisition source.
1703
+ // Without this, a full-page internal nav (referrer = our own domain, common on
1704
+ // classic multi-page stores) classifies as 'referral', which makes
1705
+ // hasRealAttribution true and overwrites the real paid last-touch. Treat it as
1706
+ // direct so the last-touch guard preserves the genuine source.
1707
+ const currentHost = (typeof window !== 'undefined' ? window.location.hostname : '').toLowerCase();
1708
+ if (currentHost && (host === currentHost || this.isSameRootDomain(host, currentHost))) {
1709
+ return 'direct';
1710
+ }
1662
1711
  // Social sources
1663
1712
  if (host.includes('facebook.com') || host.includes('fb.com'))
1664
1713
  return 'facebook';
@@ -1716,6 +1765,16 @@ var Datalyr = (function (exports) {
1716
1765
  }
1717
1766
  return 'referral';
1718
1767
  }
1768
+ /**
1769
+ * Whether two hosts share the same root domain (eTLD+1 approximation), so that
1770
+ * cross-subdomain internal navigation (e.g. shop.example.com → checkout.example.com)
1771
+ * is also treated as same-site. Not a full public-suffix parse — good enough to keep
1772
+ * internal navs from being mis-classified as referral.
1773
+ */
1774
+ isSameRootDomain(a, b) {
1775
+ const root = (h) => h.split('.').slice(-2).join('.');
1776
+ return root(a) === root(b);
1777
+ }
1719
1778
  /**
1720
1779
  * Extract hostname from URL
1721
1780
  */
@@ -1757,6 +1816,11 @@ var Datalyr = (function (exports) {
1757
1816
  const DEFAULT_CRITICAL_EVENTS = ['purchase', 'signup', 'subscribe', 'lead', 'conversion'];
1758
1817
  // Default high priority events that use faster batching
1759
1818
  const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_item', 'search'];
1819
+ // A 429 is a deliberate backpressure signal, not a transient failure — tagged so the
1820
+ // send path can route it to the offline queue WITHOUT retrying (which would storm the
1821
+ // already-overloaded server). See sendBatch / the rateLimitedUntil gate.
1822
+ class RateLimitError extends Error {
1823
+ }
1760
1824
  class EventQueue {
1761
1825
  constructor(config) {
1762
1826
  this.queue = [];
@@ -1769,18 +1833,29 @@ var Datalyr = (function (exports) {
1769
1833
  this.OFFLINE_QUEUE_KEY = 'dl_offline_queue';
1770
1834
  this.flushLock = false; // FIXED (DATA-03): Mutex to prevent race conditions
1771
1835
  this.offlineQueueLock = false; // FIXED (DATA-03): Separate lock for offline queue operations
1836
+ this.offlineProcessing = false; // FIXED (WEB-1): re-entrancy guard for processOfflineQueue
1837
+ this.inFlight = []; // FIXED (WEB-3): batch currently in _flight's keepalive fetch — forceFlush must NOT re-beacon it
1838
+ this.enabled = true; // FIXED (consent): when false (opt-out / withdrawn analytics consent) all enqueue/flush/drain is a no-op
1839
+ this.rateLimitedUntil = 0; // FIXED (429): skip flush/drain until the server's Retry-After window passes
1840
+ // Coerce numeric config to sane values. The old `config.X || default` both turned a
1841
+ // legitimate 0 into the default AND let invalid values through — a NEGATIVE batchSize
1842
+ // made processOfflineQueue's `splice(0, -1)` loop forever and FREEZE the host tab.
1843
+ const clampInt = (v, def, min, max) => {
1844
+ const n = Number(v);
1845
+ return Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), min), max) : def;
1846
+ };
1772
1847
  this.config = {
1773
- batchSize: config.batchSize || 10,
1774
- flushInterval: config.flushInterval || 5000,
1775
- maxRetries: config.maxRetries || 5,
1776
- retryDelay: config.retryDelay || 1000,
1848
+ batchSize: clampInt(config.batchSize, 10, 1, 1000),
1849
+ flushInterval: clampInt(config.flushInterval, 5000, 250, 3600000),
1850
+ maxRetries: clampInt(config.maxRetries, 5, 0, 20),
1851
+ retryDelay: clampInt(config.retryDelay, 1000, 0, 60000),
1777
1852
  endpoint: config.endpoint || 'https://ingest.datalyr.com',
1778
- fallbackEndpoints: config.fallbackEndpoints || [],
1853
+ fallbackEndpoints: Array.isArray(config.fallbackEndpoints) ? config.fallbackEndpoints : [],
1779
1854
  workspaceId: config.workspaceId,
1780
1855
  debug: config.debug || false,
1781
1856
  criticalEvents: config.criticalEvents || DEFAULT_CRITICAL_EVENTS,
1782
1857
  highPriorityEvents: config.highPriorityEvents || DEFAULT_HIGH_PRIORITY_EVENTS,
1783
- maxOfflineQueueSize: config.maxOfflineQueueSize || 100
1858
+ maxOfflineQueueSize: clampInt(config.maxOfflineQueueSize, 100, 1, 100000)
1784
1859
  };
1785
1860
  this.networkStatus = {
1786
1861
  isOnline: navigator.onLine !== false,
@@ -1790,11 +1865,22 @@ var Datalyr = (function (exports) {
1790
1865
  this.loadOfflineQueue();
1791
1866
  this.setupNetworkListeners();
1792
1867
  this.startPeriodicFlush();
1868
+ // WEB-1: a previous session may have left events in the persisted offline queue.
1869
+ // The 'online' listener only fires on an offline→online TRANSITION, which never
1870
+ // happens for a visitor who returns already-online — so drain on startup too,
1871
+ // otherwise those events (incl. failed revenue conversions) just age out.
1872
+ if (this.networkStatus.isOnline && this.offlineQueue.length > 0) {
1873
+ setTimeout(() => this.processOfflineQueue(), 1000);
1874
+ }
1793
1875
  }
1794
1876
  /**
1795
1877
  * Add event to queue
1796
1878
  */
1797
1879
  enqueue(event) {
1880
+ // Consent gate: opt-out / withdrawn analytics consent stops all sending. (The SDK
1881
+ // also gates at track(), but this is the backstop for anything that reaches here.)
1882
+ if (!this.enabled)
1883
+ return;
1798
1884
  const eventName = event.event_name; // Use snake_case
1799
1885
  // Check for duplicates (within 500ms window)
1800
1886
  if (this.isDuplicateEvent(event)) {
@@ -1893,6 +1979,11 @@ var Datalyr = (function (exports) {
1893
1979
  */
1894
1980
  flush() {
1895
1981
  return __awaiter(this, void 0, void 0, function* () {
1982
+ if (!this.enabled)
1983
+ return;
1984
+ // FIXED (429): respect the server's Retry-After window instead of hammering it.
1985
+ if (Date.now() < this.rateLimitedUntil)
1986
+ return;
1896
1987
  // FIXED (DATA-03): Check both promise and lock for concurrent flush protection
1897
1988
  if (this.flushPromise || this.flushLock) {
1898
1989
  return this.flushPromise || Promise.resolve();
@@ -1934,6 +2025,12 @@ var Datalyr = (function (exports) {
1934
2025
  // Only remove after successful send to prevent data loss
1935
2026
  const batchSize = Math.min(this.config.batchSize, this.queue.length);
1936
2027
  const events = this.queue.slice(0, batchSize);
2028
+ // WEB-3 (HIGH-1): mark this batch as in-flight. sendBatch's fetch uses
2029
+ // keepalive:true, so it survives an unload that fires mid-flush — forceFlush
2030
+ // reads `inFlight` to avoid re-beaconing the very events this fetch is sending.
2031
+ // `inFlight` is always the FRONT `batchSize` of this.queue while flushLock holds
2032
+ // (enqueue only pushes to the back; flushLock serializes _flush bodies).
2033
+ this.inFlight = events;
1937
2034
  try {
1938
2035
  yield this.sendBatch(events);
1939
2036
  // SUCCESS: Now it's safe to remove events from queue
@@ -1942,10 +2039,17 @@ var Datalyr = (function (exports) {
1942
2039
  }
1943
2040
  catch (error) {
1944
2041
  this.log('Failed to send batch:', error);
1945
- // Don't remove from queue - events stay for retry
1946
- // Move to offline queue for persistent storage
2042
+ // WEB-8: make the offline queue the SINGLE owner of these events. Previously
2043
+ // they stayed in the live queue AND were copied to the offline queue, so a
2044
+ // later success on both paths double-sent (relying on server-side dedup).
2045
+ // Remove the same slice from the live queue; the offline queue now drains on
2046
+ // every periodic tick (WEB-1/WEB-2), so they are still retried, not stranded.
2047
+ this.queue.splice(0, batchSize);
1947
2048
  this.moveToOfflineQueue(events);
1948
2049
  }
2050
+ finally {
2051
+ this.inFlight = [];
2052
+ }
1949
2053
  });
1950
2054
  }
1951
2055
  /**
@@ -1975,21 +2079,27 @@ var Datalyr = (function (exports) {
1975
2079
  // Handle rate limiting
1976
2080
  if (response.status === 429) {
1977
2081
  const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
1978
- this.log(`Rate limited, retrying after ${retryAfter}s`);
1979
- // FIXED (CRITICAL-06): Don't unshift() - events are still in queue!
1980
- // With the new fix, events aren't removed until success, so they're
1981
- // already in the queue. Just schedule a retry flush.
1982
- setTimeout(() => {
1983
- this.flush();
1984
- }, retryAfter * 1000);
1985
- // Throw to trigger catch block which moves events to offline queue
1986
- throw new Error(`Rate limited (429), retry after ${retryAfter}s`);
2082
+ // FIXED (429 retry-storm): honor Retry-After as a SINGLE backoff window. The
2083
+ // old code scheduled a flush AND let the throw fall into the generic
2084
+ // exponential-backoff retry below firing ~6 requests inside the window the
2085
+ // server asked us to wait, amplifying an ingest overload. Now: record the
2086
+ // window and throw a RateLimitError (which the catch does NOT retry). The
2087
+ // events move to the offline queue and drain once the window passes.
2088
+ this.rateLimitedUntil = Date.now() + Math.max(retryAfter, 1) * 1000;
2089
+ this.log(`Rate limited; backing off ${retryAfter}s`);
2090
+ throw new RateLimitError('Rate limited (429)');
1987
2091
  }
1988
2092
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1989
2093
  }
1990
2094
  this.log(`Batch sent successfully to ${currentEndpoint}: ${events.length} events`);
1991
2095
  }
1992
2096
  catch (error) {
2097
+ // A 429 is deliberate backpressure — do NOT retry it here (neither fallback nor
2098
+ // backoff). Propagate so the batch moves to the offline queue; the periodic drain
2099
+ // resumes only after rateLimitedUntil. This is what prevents the retry storm.
2100
+ if (error instanceof RateLimitError) {
2101
+ throw error;
2102
+ }
1993
2103
  // Try next fallback endpoint if available
1994
2104
  if (endpointIndex < endpoints.length - 1) {
1995
2105
  this.log(`Failed on ${currentEndpoint}, trying fallback ${endpointIndex + 1}`);
@@ -2031,6 +2141,13 @@ var Datalyr = (function (exports) {
2031
2141
  if (this.queue.length > 0) {
2032
2142
  this.flush();
2033
2143
  }
2144
+ // WEB-1/WEB-2: also drain persisted offline events (including failed critical
2145
+ // events that enqueue() parked here) on every tick — not only on an 'online'
2146
+ // transition that may never fire. This is what gives parked purchase/lead
2147
+ // events a retry path while the user stays online.
2148
+ if (this.networkStatus.isOnline && this.offlineQueue.length > 0) {
2149
+ this.processOfflineQueue();
2150
+ }
2034
2151
  }, this.config.flushInterval);
2035
2152
  }
2036
2153
  /**
@@ -2049,6 +2166,12 @@ var Datalyr = (function (exports) {
2049
2166
  * FIXED (CRITICAL-06): Can now accept specific events or move entire queue
2050
2167
  */
2051
2168
  moveToOfflineQueue(events) {
2169
+ // Consent leak fix: do NOT persist for a disabled queue. Without this, an
2170
+ // in-flight _flush (or critical-event send) that FAILS after optOut() has purged
2171
+ // the offline queue would re-write the PII-bearing event to storage AFTER the
2172
+ // purge. Gating here (a persistence sink, not just the send sinks) closes it.
2173
+ if (!this.enabled)
2174
+ return;
2052
2175
  // FIXED (DATA-03): Check if offline queue operation already in progress
2053
2176
  if (this.offlineQueueLock) {
2054
2177
  console.warn('[Datalyr Queue] Offline queue operation already in progress');
@@ -2092,6 +2215,10 @@ var Datalyr = (function (exports) {
2092
2215
  * Save offline queue to storage
2093
2216
  */
2094
2217
  saveOfflineQueue() {
2218
+ // Defense-in-depth twin of the moveToOfflineQueue gate: never write PII to disk
2219
+ // for a disabled (opted-out) queue.
2220
+ if (!this.enabled)
2221
+ return;
2095
2222
  // Keep max events based on config
2096
2223
  const toSave = this.offlineQueue.slice(-this.config.maxOfflineQueueSize);
2097
2224
  storage.set(this.OFFLINE_QUEUE_KEY, toSave);
@@ -2101,25 +2228,46 @@ var Datalyr = (function (exports) {
2101
2228
  */
2102
2229
  processOfflineQueue() {
2103
2230
  return __awaiter(this, void 0, void 0, function* () {
2231
+ // WEB-1: guard against re-entrancy. This is now driven from three places (the
2232
+ // 'online' listener, the periodic flush, and the on-load kick); without a guard
2233
+ // two of them could splice the same offlineQueue concurrently and double-send.
2234
+ if (!this.enabled)
2235
+ return;
2236
+ if (Date.now() < this.rateLimitedUntil)
2237
+ return; // FIXED (429): honor Retry-After window
2238
+ if (this.offlineProcessing)
2239
+ return;
2104
2240
  if (this.offlineQueue.length === 0)
2105
2241
  return;
2106
- this.log(`Processing ${this.offlineQueue.length} offline events`);
2107
- while (this.offlineQueue.length > 0) {
2108
- const batch = this.offlineQueue.splice(0, this.config.batchSize);
2109
- try {
2110
- yield this.sendBatch(batch);
2111
- this.saveOfflineQueue();
2242
+ if (!this.networkStatus.isOnline)
2243
+ return;
2244
+ this.offlineProcessing = true;
2245
+ try {
2246
+ this.log(`Processing ${this.offlineQueue.length} offline events`);
2247
+ while (this.offlineQueue.length > 0) {
2248
+ // Stop mid-drain if disabled (opt-out during an active drain) — otherwise the
2249
+ // unshift-on-failure below would re-persist a batch after optOut's purge.
2250
+ if (!this.enabled)
2251
+ break;
2252
+ const batch = this.offlineQueue.splice(0, this.config.batchSize);
2253
+ try {
2254
+ yield this.sendBatch(batch);
2255
+ this.saveOfflineQueue();
2256
+ }
2257
+ catch (error) {
2258
+ this.log('Failed to send offline batch:', error);
2259
+ // Put back in queue
2260
+ this.offlineQueue.unshift(...batch);
2261
+ this.saveOfflineQueue();
2262
+ break;
2263
+ }
2112
2264
  }
2113
- catch (error) {
2114
- this.log('Failed to send offline batch:', error);
2115
- // Put back in queue
2116
- this.offlineQueue.unshift(...batch);
2117
- this.saveOfflineQueue();
2118
- break;
2265
+ if (this.offlineQueue.length === 0) {
2266
+ storage.remove(this.OFFLINE_QUEUE_KEY);
2119
2267
  }
2120
2268
  }
2121
- if (this.offlineQueue.length === 0) {
2122
- storage.remove(this.OFFLINE_QUEUE_KEY);
2269
+ finally {
2270
+ this.offlineProcessing = false;
2123
2271
  }
2124
2272
  });
2125
2273
  }
@@ -2141,30 +2289,103 @@ var Datalyr = (function (exports) {
2141
2289
  getNetworkStatus() {
2142
2290
  return Object.assign({}, this.networkStatus);
2143
2291
  }
2292
+ /**
2293
+ * Build a batch payload from a set of events.
2294
+ */
2295
+ buildBatch(events) {
2296
+ return {
2297
+ events,
2298
+ batchId: generateUUID(),
2299
+ timestamp: new Date().toISOString()
2300
+ };
2301
+ }
2144
2302
  /**
2145
2303
  * Force flush (for page unload)
2304
+ * FIXED (WEB-3): include the offline queue, chunk under sendBeacon's ~64KB cap,
2305
+ * and persist any chunk the browser refuses so it survives to the next page load.
2306
+ * Previously this beaconed the live queue as one blob — ignoring the offline queue
2307
+ * entirely and silently failing (returning false) whenever the payload exceeded 64KB.
2308
+ *
2309
+ * Two follow-up review fixes:
2310
+ * - HIGH-1: exclude any batch currently in-flight in _flush(). That batch is being
2311
+ * sent with a keepalive fetch that already survives unload; re-beaconing it would
2312
+ * double-send. (`inFlight` is the front `inFlight.length` events of this.queue.)
2313
+ * - HIGH-2: handleUnload is wired to visibilitychange+pagehide+beforeunload, so this
2314
+ * runs multiple times per lifecycle. Detach what we beacon SYNCHRONOUSLY (clear the
2315
+ * in-memory queues, persist a refused remainder straight to storage rather than back
2316
+ * into this.offlineQueue) so a repeat call finds nothing to re-send.
2146
2317
  */
2147
2318
  forceFlush() {
2148
2319
  return __awaiter(this, void 0, void 0, function* () {
2149
- // Try sendBeacon first for reliability
2150
- if (navigator.sendBeacon && this.queue.length > 0) {
2151
- const batchPayload = {
2152
- events: this.queue,
2153
- batchId: generateUUID(),
2154
- timestamp: new Date().toISOString()
2155
- };
2156
- const blob = new Blob([JSON.stringify(batchPayload)], {
2320
+ if (!this.enabled)
2321
+ return;
2322
+ if (!navigator.sendBeacon) {
2323
+ // No beacon API — best-effort keepalive-fetch drain of both queues.
2324
+ yield this.flush();
2325
+ yield this.processOfflineQueue();
2326
+ return;
2327
+ }
2328
+ // Exclude the in-flight batch (its keepalive fetch already carries it).
2329
+ const live = this.queue.slice(this.inFlight.length);
2330
+ const pending = [...live, ...this.offlineQueue];
2331
+ // Detach synchronously: keep only the in-flight front (for _flush to settle) and
2332
+ // empty the offline queue, so a second unload event this lifecycle re-enters with
2333
+ // nothing to re-beacon. The refused remainder (if any) is persisted to STORAGE
2334
+ // below, not back into this.offlineQueue, for exactly this reason.
2335
+ this.queue = this.queue.slice(0, this.inFlight.length);
2336
+ this.offlineQueue = [];
2337
+ if (pending.length === 0)
2338
+ return;
2339
+ const MAX_BEACON_BYTES = 60000; // headroom under the browser's ~64KB cap
2340
+ // Greedily pack events into chunks bounded by BOTH batchSize and byte size.
2341
+ const chunks = [];
2342
+ let current = [];
2343
+ let currentBytes = 2; // approx for the JSON array/object wrapper
2344
+ for (const ev of pending) {
2345
+ // Byte length (not UTF-16 .length) so multibyte product names / emoji can't
2346
+ // under-count and overflow the cap.
2347
+ const evBytes = new Blob([JSON.stringify(ev)]).size + 1;
2348
+ if (current.length > 0 &&
2349
+ (current.length >= this.config.batchSize || currentBytes + evBytes > MAX_BEACON_BYTES)) {
2350
+ chunks.push(current);
2351
+ current = [];
2352
+ currentBytes = 2;
2353
+ }
2354
+ current.push(ev);
2355
+ currentBytes += evBytes;
2356
+ }
2357
+ if (current.length > 0)
2358
+ chunks.push(current);
2359
+ // Send each chunk; the moment the browser refuses to enqueue one, keep it and
2360
+ // everything after it for the next session.
2361
+ let failedFrom = -1;
2362
+ for (let c = 0; c < chunks.length; c++) {
2363
+ const blob = new Blob([JSON.stringify(this.buildBatch(chunks[c]))], {
2157
2364
  type: 'application/json'
2158
2365
  });
2159
- const success = navigator.sendBeacon(this.config.endpoint, blob);
2160
- if (success) {
2161
- this.log('Events sent via sendBeacon');
2162
- this.queue = [];
2163
- return;
2366
+ if (!navigator.sendBeacon(this.config.endpoint, blob)) {
2367
+ failedFrom = c;
2368
+ break;
2164
2369
  }
2165
2370
  }
2166
- // Fallback to regular flush
2167
- yield this.flush();
2371
+ if (failedFrom >= 0) {
2372
+ const remainder = chunks
2373
+ .slice(failedFrom)
2374
+ .reduce((acc, c) => acc.concat(c), []);
2375
+ // Persist straight to storage (NOT this.offlineQueue) so a repeat unload event
2376
+ // won't re-beacon it; the next page load's drain (which uses a normal fetch with
2377
+ // no 64KB cap) delivers it. MED-2: log if the maxOfflineQueueSize cap truncates.
2378
+ const toPersist = remainder.slice(-this.config.maxOfflineQueueSize);
2379
+ if (remainder.length > toPersist.length) {
2380
+ this.log(`Offline cap dropped ${remainder.length - toPersist.length} oldest events at unload`);
2381
+ }
2382
+ storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
2383
+ this.log(`sendBeacon refused ${remainder.length} events; persisted ${toPersist.length} for next load`);
2384
+ }
2385
+ else {
2386
+ this.log('Events sent via sendBeacon');
2387
+ storage.remove(this.OFFLINE_QUEUE_KEY);
2388
+ }
2168
2389
  });
2169
2390
  }
2170
2391
  /**
@@ -2177,6 +2398,22 @@ var Datalyr = (function (exports) {
2177
2398
  this.batchTimer = null;
2178
2399
  }
2179
2400
  }
2401
+ /**
2402
+ * Enable/disable all sending. Used by opt-out / withdrawn analytics consent so that
2403
+ * events persisted BEFORE opt-out aren't drained afterwards (the periodic drain and
2404
+ * the on-load drain both honor this).
2405
+ */
2406
+ setEnabled(enabled) {
2407
+ this.enabled = enabled;
2408
+ }
2409
+ /**
2410
+ * Clear the offline queue and its persisted copy (used by opt-out to purge any
2411
+ * PII-bearing events that were parked before the user opted out).
2412
+ */
2413
+ clearOffline() {
2414
+ this.offlineQueue = [];
2415
+ storage.remove(this.OFFLINE_QUEUE_KEY);
2416
+ }
2180
2417
  /**
2181
2418
  * Debug logging
2182
2419
  */
@@ -2873,7 +3110,7 @@ var Datalyr = (function (exports) {
2873
3110
  * Track event to all initialized pixels
2874
3111
  */
2875
3112
  trackToPixels(eventName, properties = {}, eventId) {
2876
- var _a, _b, _c, _e, _f, _g, _h, _j;
3113
+ var _a, _b, _c, _e, _f, _g, _h, _j, _k, _l;
2877
3114
  // Datalyr-internal events ($identify, $group, $alias, $auto_identify,
2878
3115
  // $app_download_click, …) are not conversions — never forward them to ad
2879
3116
  // pixels. (Previously they fired as noise custom events with the $ stripped.)
@@ -2939,16 +3176,31 @@ var Datalyr = (function (exports) {
2939
3176
  // Track to TikTok Pixel
2940
3177
  if (((_j = (_h = this.pixels) === null || _h === void 0 ? void 0 : _h.tiktok) === null || _j === void 0 ? void 0 : _j.enabled) && window.ttq) {
2941
3178
  try {
2942
- // Map common events to TikTok names
3179
+ // Map our event names to TikTok's standard vocabulary. BUG FIX (TikTok-dead):
3180
+ // this map was keyed on Meta-standard names ('Purchase') but looked up with the
3181
+ // sanitized RAW event ('purchase'), so EVERY standard event missed and fired as
3182
+ // a literal custom event (e.g. "purchase" instead of "CompletePayment") — all
3183
+ // TikTok conversions were mis-categorized. Now keyed on the lowercased raw name
3184
+ // and looked up the same way the Meta block does, with a workspace rule map first.
2943
3185
  const tiktokEventMap = {
2944
- 'Purchase': 'CompletePayment',
2945
- 'AddToCart': 'AddToCart',
2946
- 'InitiateCheckout': 'InitiateCheckout',
2947
- 'ViewContent': 'ViewContent',
2948
- 'Search': 'Search',
2949
- 'Lead': 'SubmitForm'
3186
+ view_content: 'ViewContent', product_viewed: 'ViewContent', view_item: 'ViewContent',
3187
+ search: 'Search',
3188
+ add_to_wishlist: 'AddToWishlist',
3189
+ add_to_cart: 'AddToCart', product_added: 'AddToCart',
3190
+ initiate_checkout: 'InitiateCheckout', begin_checkout: 'InitiateCheckout', checkout_started: 'InitiateCheckout',
3191
+ add_payment_info: 'AddPaymentInfo',
3192
+ purchase: 'CompletePayment', order_completed: 'CompletePayment', order_paid: 'CompletePayment',
3193
+ place_an_order: 'PlaceAnOrder',
3194
+ contact: 'Contact',
3195
+ download: 'Download',
3196
+ lead: 'SubmitForm', submit_form: 'SubmitForm',
3197
+ complete_registration: 'CompleteRegistration', sign_up: 'CompleteRegistration', signup: 'CompleteRegistration',
3198
+ subscribe: 'Subscribe', subscription_created: 'Subscribe',
2950
3199
  };
2951
- const tiktokEvent = tiktokEventMap[sanitizedEventName] || sanitizedEventName;
3200
+ const tiktokRuleMap = (_l = (_k = this.pixels) === null || _k === void 0 ? void 0 : _k.tiktok) === null || _l === void 0 ? void 0 : _l.event_mappings;
3201
+ const tiktokEvent = (tiktokRuleMap === null || tiktokRuleMap === void 0 ? void 0 : tiktokRuleMap[eventName])
3202
+ || tiktokEventMap[String(eventName).toLowerCase()]
3203
+ || sanitizedEventName;
2952
3204
  window.ttq.track(tiktokEvent, sanitizedProperties);
2953
3205
  }
2954
3206
  catch (error) {
@@ -3097,6 +3349,7 @@ var Datalyr = (function (exports) {
3097
3349
  this.formListeners = [];
3098
3350
  this.lastIdentifyTime = 0;
3099
3351
  this.RATE_LIMIT_MS = 5000; // Don't auto-identify more than once per 5 seconds
3352
+ this.destroyed = false; // set by destroy() (e.g. on opt-out) — stops any in-flight capture from re-persisting email
3100
3353
  this.config = {
3101
3354
  enabled: config.enabled !== false,
3102
3355
  captureFromForms: config.captureFromForms !== false,
@@ -3110,29 +3363,49 @@ var Datalyr = (function (exports) {
3110
3363
  * Initialize auto-identify system
3111
3364
  */
3112
3365
  initialize(identifyCallback) {
3113
- if (!this.config.enabled) {
3114
- this.log('Auto-identify disabled');
3115
- return;
3116
- }
3117
- this.identifyCallback = identifyCallback;
3118
- // Check if already identified
3119
- const existingEmail = storage.get('dl_auto_identified_email');
3120
- if (existingEmail) {
3121
- this.log('User already auto-identified:', existingEmail);
3122
- return;
3123
- }
3124
- // Setup monitoring
3125
- if (this.config.captureFromForms) {
3126
- this.setupFormMonitoring();
3127
- }
3128
- if (this.config.captureFromAPI) {
3129
- this.setupFetchInterception();
3130
- this.setupXHRInterception();
3131
- }
3132
- if (this.config.captureFromShopify) {
3133
- this.setupShopifyMonitoring();
3134
- }
3135
- this.log('Auto-identify initialized');
3366
+ return __awaiter(this, void 0, void 0, function* () {
3367
+ if (!this.config.enabled) {
3368
+ this.log('Auto-identify disabled');
3369
+ return;
3370
+ }
3371
+ this.identifyCallback = identifyCallback;
3372
+ // Check if already identified.
3373
+ // WEB-5: the captured email is PII — store it AES-GCM-encrypted (consistent with
3374
+ // dl_user_traits), not plaintext localStorage.
3375
+ const existingEmail = yield storage.getEncrypted('dl_auto_identified_email');
3376
+ if (existingEmail) {
3377
+ // Migration: a value written by a pre-1.7.1 build is PLAINTEXT. getEncrypted
3378
+ // returns it anyway — decrypt() has a backwards-compat fallback that hands back
3379
+ // the raw string when AES decryption fails — so without this step the PII would
3380
+ // stay in plaintext localStorage forever. Detect a legacy plaintext email by
3381
+ // inspecting the RAW stored bytes (ciphertext is base64, so an '@' means it was
3382
+ // never encrypted) and re-write it encrypted in place.
3383
+ const raw = storage.get('dl_auto_identified_email');
3384
+ if (typeof raw === 'string' && raw.includes('@')) {
3385
+ try {
3386
+ yield storage.setEncrypted('dl_auto_identified_email', existingEmail);
3387
+ this.log('Migrated legacy plaintext auto-identified email to encrypted storage');
3388
+ }
3389
+ catch (error) {
3390
+ this.log('Failed to migrate legacy auto-identified email:', error);
3391
+ }
3392
+ }
3393
+ this.log('User already auto-identified');
3394
+ return;
3395
+ }
3396
+ // Setup monitoring
3397
+ if (this.config.captureFromForms) {
3398
+ this.setupFormMonitoring();
3399
+ }
3400
+ if (this.config.captureFromAPI) {
3401
+ this.setupFetchInterception();
3402
+ this.setupXHRInterception();
3403
+ }
3404
+ if (this.config.captureFromShopify) {
3405
+ this.setupShopifyMonitoring();
3406
+ }
3407
+ this.log('Auto-identify initialized');
3408
+ });
3136
3409
  }
3137
3410
  /**
3138
3411
  * Setup form monitoring for email capture
@@ -3288,10 +3561,21 @@ var Datalyr = (function (exports) {
3288
3561
  this.log('Shopify detected, setting up monitoring');
3289
3562
  // Try to fetch customer data immediately
3290
3563
  this.checkShopifyCustomer();
3291
- // Check periodically (in case customer logs in later)
3564
+ // Check periodically (in case the customer logs in later), but CAP the attempts so a
3565
+ // visitor who never logs in — the vast majority of storefront traffic — doesn't poll
3566
+ // /account.json forever (it used to run every 10s for the whole session). A logged-in
3567
+ // customer surfaces well within ~2 minutes. (WEB-10)
3568
+ let checks = 0;
3569
+ const MAX_CHECKS = 12; // ~2 minutes at 10s
3292
3570
  this.shopifyCheckInterval = setInterval(() => {
3571
+ checks++;
3293
3572
  this.checkShopifyCustomer();
3294
- }, 10000); // Check every 10 seconds
3573
+ if (checks >= MAX_CHECKS && this.shopifyCheckInterval) {
3574
+ clearInterval(this.shopifyCheckInterval);
3575
+ this.shopifyCheckInterval = undefined;
3576
+ this.log('Shopify monitoring stopped after max checks (no login detected)');
3577
+ }
3578
+ }, 10000);
3295
3579
  this.log('Shopify monitoring active');
3296
3580
  }
3297
3581
  /**
@@ -3448,31 +3732,51 @@ var Datalyr = (function (exports) {
3448
3732
  * Trigger identify with rate limiting
3449
3733
  */
3450
3734
  triggerIdentify(email, source) {
3451
- // Rate limiting
3452
- const now = Date.now();
3453
- if (now - this.lastIdentifyTime < this.RATE_LIMIT_MS) {
3454
- this.log('Rate limited, skipping identify');
3455
- return;
3456
- }
3457
- // Check if already identified
3458
- const existingEmail = storage.get('dl_auto_identified_email');
3459
- if (existingEmail === email) {
3460
- this.log('Already identified with this email');
3461
- return;
3462
- }
3463
- // Store email to prevent duplicate identification
3464
- storage.set('dl_auto_identified_email', email);
3465
- this.lastIdentifyTime = now;
3466
- // Trigger callback
3467
- if (this.identifyCallback) {
3468
- this.log(`Auto-identifying user: ${email} (source: ${source})`);
3469
- this.identifyCallback(email, source);
3470
- }
3735
+ return __awaiter(this, void 0, void 0, function* () {
3736
+ if (this.destroyed)
3737
+ return;
3738
+ // Rate limiting
3739
+ const now = Date.now();
3740
+ if (now - this.lastIdentifyTime < this.RATE_LIMIT_MS) {
3741
+ this.log('Rate limited, skipping identify');
3742
+ return;
3743
+ }
3744
+ // Stamp the rate-limit timer synchronously, BEFORE the async storage calls below,
3745
+ // so two near-simultaneous triggers can't both slip past into a double-identify
3746
+ // (the storage read/write is now async — a window the old sync code didn't have).
3747
+ this.lastIdentifyTime = now;
3748
+ // Check if already identified.
3749
+ // WEB-5: encrypted store (the email is PII at rest).
3750
+ const existingEmail = yield storage.getEncrypted('dl_auto_identified_email');
3751
+ if (existingEmail === email) {
3752
+ this.log('Already identified with this email');
3753
+ return;
3754
+ }
3755
+ // If we were destroyed (e.g. user opted out) during the async read above, do NOT
3756
+ // re-persist the email or fire the callback.
3757
+ if (this.destroyed)
3758
+ return;
3759
+ // Store email (encrypted) to prevent duplicate identification. Best-effort: a
3760
+ // storage/encryption failure must not block the identify, since the identify
3761
+ // event carries the email to the backend regardless.
3762
+ try {
3763
+ yield storage.setEncrypted('dl_auto_identified_email', email);
3764
+ }
3765
+ catch (error) {
3766
+ this.log('Failed to persist auto-identified email:', error);
3767
+ }
3768
+ // Trigger callback
3769
+ if (this.identifyCallback) {
3770
+ this.log(`Auto-identifying user: ${email} (source: ${source})`);
3771
+ this.identifyCallback(email, source);
3772
+ }
3773
+ });
3471
3774
  }
3472
3775
  /**
3473
3776
  * Destroy and cleanup
3474
3777
  */
3475
3778
  destroy() {
3779
+ this.destroyed = true;
3476
3780
  // Restore original fetch
3477
3781
  if (this.originalFetch) {
3478
3782
  window.fetch = this.originalFetch;
@@ -3563,9 +3867,12 @@ var Datalyr = (function (exports) {
3563
3867
  this.superProperties = {};
3564
3868
  this.userProperties = {};
3565
3869
  this.optedOut = false;
3870
+ // Consent preferences (setConsent). null = not set → no consent-based restriction.
3871
+ this.consent = null;
3566
3872
  this.initialized = false;
3567
3873
  this.errors = [];
3568
3874
  this.MAX_ERRORS = 50;
3875
+ this.lastSpaPath = null; // dedups SPA pageviews (replaceState-on-mount double-fire)
3569
3876
  // FIXED (ISSUE-01): Async initialization promise to prevent race conditions
3570
3877
  this.initializationPromise = null;
3571
3878
  // Opt-out check moved to init() after cookies configured (Issue #14)
@@ -3607,6 +3914,9 @@ var Datalyr = (function (exports) {
3607
3914
  storage.migrateFromLegacyPrefix();
3608
3915
  // Check opt-out AFTER cookies configured (Issue #14)
3609
3916
  this.optedOut = this.cookies.get('__dl_opt_out') === 'true';
3917
+ // Load persisted consent so shouldTrack()/container gating honor it from the
3918
+ // first event (setConsent persists it; see optOut for the enforcement on opt-out).
3919
+ this.consent = storage.get('dl_consent', null);
3610
3920
  // CC funnel page entry: restore the _dl_* URL bridge BEFORE IdentityManager
3611
3921
  // initializes so the storefront's visitor_id is the one used here (instead
3612
3922
  // of auto-generating a fresh one on this domain).
@@ -3628,6 +3938,9 @@ var Datalyr = (function (exports) {
3628
3938
  // Set session ID in identity manager
3629
3939
  const sessionId = this.session.getSessionId();
3630
3940
  this.identity.setSessionId(sessionId);
3941
+ // Gate the queue by the FULL tracking policy (opt-out + analytics consent + DNT +
3942
+ // GPC) so a returning opted-out / DNT / GPC visitor's persisted events don't drain.
3943
+ this.queue.setEnabled(this.shouldTrack());
3631
3944
  // FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
3632
3945
  // This allows encryption to initialize before any events are tracked
3633
3946
  this.initializeAsync();
@@ -3664,18 +3977,33 @@ var Datalyr = (function (exports) {
3664
3977
  this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
3665
3978
  var _a;
3666
3979
  try {
3667
- // SEC-03 Fix: Initialize encryption for PII data
3668
- const deviceId = this.identity.getAnonymousId();
3669
- yield dataEncryption.initialize(this.config.workspaceId, deviceId);
3670
- // Load encrypted user properties
3671
- this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
3672
- this.log('Encryption initialized, user properties loaded');
3980
+ // SEC-03: encryption is for PII-AT-REST only — it must NOT gate event delivery,
3981
+ // pageviews, or pixels. On a non-secure context (http://) or an old browser,
3982
+ // crypto.subtle is absent and initialize() throws; isolate it so the rest of init
3983
+ // (SPA tracking, container/pixels, initial pageview) still runs. (http:// fix)
3984
+ try {
3985
+ const deviceId = this.identity.getAnonymousId();
3986
+ yield dataEncryption.initialize(this.config.workspaceId, deviceId);
3987
+ this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
3988
+ this.log('Encryption initialized, user properties loaded');
3989
+ }
3990
+ catch (encErr) {
3991
+ console.warn('[Datalyr] Encryption unavailable (non-secure context?); continuing WITHOUT PII-at-rest encryption:', encErr);
3992
+ this.userProperties = storage.get('dl_user_traits', {});
3993
+ }
3673
3994
  // Setup SPA tracking if enabled
3674
3995
  if (this.config.trackSPA) {
3675
3996
  this.setupSPATracking();
3676
3997
  }
3677
- // Initialize container manager if enabled
3678
- if (this.config.enableContainer !== false) {
3998
+ // Initialize container manager if enabled.
3999
+ // WEB-4 (privacy gate): the container loads third-party pixels (Meta/Google/
4000
+ // TikTok) and calls fbq('init', …, advancedMatching) with the visitor's
4001
+ // SHA-256 email/id — so it must NOT initialize for opted-out / DNT / GPC
4002
+ // visitors, nor when privacyMode is explicitly 'strict'. (Remote config can't
4003
+ // gate this: the container fetch is what delivers the remote config, so only
4004
+ // local consent signals + an explicit strict init() are knowable here.)
4005
+ const privacyStrict = this.config.privacyMode === 'strict';
4006
+ if (this.config.enableContainer !== false && this.shouldTrack() && !privacyStrict && this.consentAllowsMarketing()) {
3679
4007
  this.container = new ContainerManager({
3680
4008
  workspaceId: this.config.workspaceId,
3681
4009
  endpoint: this.config.endpoint,
@@ -3765,7 +4093,9 @@ var Datalyr = (function (exports) {
3765
4093
  // Storefront → CC bridge: stamp _dl_* params on outbound links to the
3766
4094
  // configured CC domains so visitor_id + click signals cross the domain
3767
4095
  // boundary. Inert unless checkoutChampDomains is set.
3768
- if (Array.isArray(this.config.checkoutChampDomains) && this.config.checkoutChampDomains.length > 0) {
4096
+ if (Array.isArray(this.config.checkoutChampDomains) && this.config.checkoutChampDomains.length > 0 && this.shouldTrack()) {
4097
+ // MED-4: gated on shouldTrack() (like syncShopifyCartAttributes) so an
4098
+ // opted-out / DNT / GPC visitor's id + click-ids aren't stamped on outbound links.
3769
4099
  this.syncOutboundLinkParams(this.config.checkoutChampDomains);
3770
4100
  }
3771
4101
  // Track initial page view if enabled (AFTER encryption ready)
@@ -3850,51 +4180,56 @@ var Datalyr = (function (exports) {
3850
4180
  * so the mobile SDK can retrieve them deterministically after install.
3851
4181
  */
3852
4182
  trackAppDownloadClick(options) {
3853
- if (!this.initialized) {
3854
- console.warn('[Datalyr] SDK not initialized. Call init() first.');
3855
- return;
3856
- }
3857
- if (!this.shouldTrack())
3858
- return;
3859
- // Fire the event with target platform info — attribution data is
3860
- // automatically merged by createEventPayload via getAttributionData()
3861
- this.track('$app_download_click', {
3862
- target_platform: options.targetPlatform,
3863
- app_store_url: options.appStoreUrl,
3864
- });
3865
- // Flush immediately via sendBeacon before page navigates away
3866
- this.queue.forceFlush();
3867
- // For Android: append referrer param to Play Store URL with click attribution
3868
- if (options.targetPlatform === 'android' && options.appStoreUrl.includes('play.google.com')) {
3869
- const lastTouch = this.attribution.getLastTouch();
3870
- const referrerParams = new URLSearchParams();
3871
- if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickId)
3872
- referrerParams.set('dl_click_id', lastTouch.clickId);
3873
- if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickIdType)
3874
- referrerParams.set('dl_click_id_type', lastTouch.clickIdType);
3875
- if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.source)
3876
- referrerParams.set('utm_source', lastTouch.source);
3877
- if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.medium)
3878
- referrerParams.set('utm_medium', lastTouch.medium);
3879
- if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.campaign)
3880
- referrerParams.set('utm_campaign', lastTouch.campaign);
3881
- if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.content)
3882
- referrerParams.set('utm_content', lastTouch.content);
3883
- if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.term)
3884
- referrerParams.set('utm_term', lastTouch.term);
3885
- try {
3886
- const url = new URL(options.appStoreUrl);
3887
- url.searchParams.set('referrer', referrerParams.toString());
3888
- window.location.href = url.toString();
4183
+ return __awaiter(this, void 0, void 0, function* () {
4184
+ if (!this.initialized) {
4185
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
4186
+ return;
3889
4187
  }
3890
- catch (_a) {
3891
- // If URL parsing fails, redirect without referrer
4188
+ if (!this.shouldTrack())
4189
+ return;
4190
+ // Fire the event with target platform info — attribution data is
4191
+ // automatically merged by createEventPayload via getAttributionData()
4192
+ this.track('$app_download_click', {
4193
+ target_platform: options.targetPlatform,
4194
+ app_store_url: options.appStoreUrl,
4195
+ });
4196
+ // AWAIT the flush before navigating away (M5). sendBeacon dispatches synchronously,
4197
+ // but on browsers WITHOUT sendBeacon forceFlush falls back to a keepalive fetch —
4198
+ // and the synchronous window.location assignment below would tear the page down
4199
+ // before that fetch dispatched, losing the click event.
4200
+ yield this.queue.forceFlush();
4201
+ // For Android: append referrer param to Play Store URL with click attribution
4202
+ if (options.targetPlatform === 'android' && options.appStoreUrl.includes('play.google.com')) {
4203
+ const lastTouch = this.attribution.getLastTouch();
4204
+ const referrerParams = new URLSearchParams();
4205
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickId)
4206
+ referrerParams.set('dl_click_id', lastTouch.clickId);
4207
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickIdType)
4208
+ referrerParams.set('dl_click_id_type', lastTouch.clickIdType);
4209
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.source)
4210
+ referrerParams.set('utm_source', lastTouch.source);
4211
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.medium)
4212
+ referrerParams.set('utm_medium', lastTouch.medium);
4213
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.campaign)
4214
+ referrerParams.set('utm_campaign', lastTouch.campaign);
4215
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.content)
4216
+ referrerParams.set('utm_content', lastTouch.content);
4217
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.term)
4218
+ referrerParams.set('utm_term', lastTouch.term);
4219
+ try {
4220
+ const url = new URL(options.appStoreUrl);
4221
+ url.searchParams.set('referrer', referrerParams.toString());
4222
+ window.location.href = url.toString();
4223
+ }
4224
+ catch (_a) {
4225
+ // If URL parsing fails, redirect without referrer
4226
+ window.location.href = options.appStoreUrl;
4227
+ }
4228
+ }
4229
+ else {
3892
4230
  window.location.href = options.appStoreUrl;
3893
4231
  }
3894
- }
3895
- else {
3896
- window.location.href = options.appStoreUrl;
3897
- }
4232
+ });
3898
4233
  }
3899
4234
  /**
3900
4235
  * Identify a user
@@ -3959,6 +4294,17 @@ var Datalyr = (function (exports) {
3959
4294
  }
3960
4295
  if (!this.shouldTrack())
3961
4296
  return;
4297
+ // WEB-6: record one customer-journey touchpoint per session so touchpoint_count /
4298
+ // days_since_first_touch become real multi-touch signals (addTouchpoint had no
4299
+ // callers before, so the journey was always empty). Dedup against the PERSISTED
4300
+ // journey (not an in-memory field) so a hard page reload within the same session
4301
+ // doesn't append a duplicate touchpoint on a classic multi-page site.
4302
+ const sid = this.session.getSessionId();
4303
+ const journey = this.attribution.getJourney();
4304
+ const lastTouchpoint = journey.length ? journey[journey.length - 1] : null;
4305
+ if (!lastTouchpoint || lastTouchpoint.sessionId !== sid) {
4306
+ this.attribution.addTouchpoint(sid, this.attribution.captureAttribution());
4307
+ }
3962
4308
  const pageData = Object.assign({ title: document.title, url: window.location.href, path: window.location.pathname, search: window.location.search, referrer: document.referrer }, properties);
3963
4309
  // Add referrer data
3964
4310
  const referrerData = getReferrerData();
@@ -4007,6 +4353,8 @@ var Datalyr = (function (exports) {
4007
4353
  console.warn('[Datalyr] SDK not initialized. Call init() first.');
4008
4354
  return;
4009
4355
  }
4356
+ if (!this.shouldTrack())
4357
+ return;
4010
4358
  this.track('$group', {
4011
4359
  group_id: groupId,
4012
4360
  traits
@@ -4020,6 +4368,13 @@ var Datalyr = (function (exports) {
4020
4368
  console.warn('[Datalyr] SDK not initialized. Call init() first.');
4021
4369
  return;
4022
4370
  }
4371
+ if (!userId) {
4372
+ console.warn('[Datalyr] alias() requires a non-empty userId');
4373
+ return;
4374
+ }
4375
+ // Gate before mutating persisted identity (identity.alias writes dl_user_id).
4376
+ if (!this.shouldTrack())
4377
+ return;
4023
4378
  const aliasData = this.identity.alias(userId, previousId);
4024
4379
  this.track('$alias', aliasData);
4025
4380
  }
@@ -4033,7 +4388,17 @@ var Datalyr = (function (exports) {
4033
4388
  }
4034
4389
  this.identity.reset();
4035
4390
  this.userProperties = {};
4391
+ // Clear super properties too — they'd otherwise keep attaching the previous user's
4392
+ // values to the next user's events (cross-user contamination on shared devices).
4393
+ this.superProperties = {};
4036
4394
  storage.remove('dl_user_traits');
4395
+ // Clear the auto-identify guard so a different user on the same browser is
4396
+ // re-captured (it short-circuits while dl_auto_identified_email is present), and so
4397
+ // the prior user's email isn't left at rest after logout.
4398
+ storage.remove('dl_auto_identified_email');
4399
+ // Clear the journey too, or the next user's touchpoints append to the previous
4400
+ // user's (cross-user contamination of touchpoint_count / days_since_first_touch).
4401
+ storage.remove('dl_journey');
4037
4402
  this.session.createNewSession();
4038
4403
  this.log('User reset');
4039
4404
  }
@@ -4144,19 +4509,44 @@ var Datalyr = (function (exports) {
4144
4509
  setAttribution(attribution) {
4145
4510
  const current = this.attribution.captureAttribution();
4146
4511
  const merged = Object.assign(Object.assign({}, current), attribution);
4512
+ // M6: actually make this affect events. Previously it only wrote a session key that
4513
+ // NOTHING in the event path reads, so setAttribution() was a silent no-op. Route it
4514
+ // through the AttributionManager so last_touch_* reflects it (and first_touch_* when
4515
+ // none is set yet — storeFirstTouch is immutable-unless-expired, so it won't clobber
4516
+ // an existing first touch).
4517
+ this.attribution.storeLastTouch(merged);
4518
+ this.attribution.storeFirstTouch(merged);
4147
4519
  this.session.storeAttribution(merged);
4148
4520
  }
4149
4521
  /**
4150
4522
  * Opt out of tracking
4151
4523
  */
4152
4524
  optOut() {
4525
+ var _a;
4153
4526
  if (!this.initialized) {
4154
4527
  console.warn('[Datalyr] SDK not initialized. Call init() first.');
4155
4528
  return;
4156
4529
  }
4157
4530
  this.optedOut = true;
4158
4531
  this.cookies.set('__dl_opt_out', 'true', this.config.cookieExpires);
4532
+ // Stop the queue from sending OR draining anything persisted before opt-out, and
4533
+ // purge what's already buffered (the periodic/on-load drain has no other gate).
4534
+ this.queue.setEnabled(false);
4159
4535
  this.queue.clear();
4536
+ this.queue.clearOffline();
4537
+ // Tear down auto-identify so it stops capturing email into storage post-opt-out.
4538
+ (_a = this.autoIdentify) === null || _a === void 0 ? void 0 : _a.destroy();
4539
+ this.autoIdentify = undefined;
4540
+ // Stop forwarding to (and drop) third-party pixels for this visitor.
4541
+ if (this.container) {
4542
+ this.container.cleanupAllIframes();
4543
+ this.container = undefined;
4544
+ }
4545
+ // Purge PII at rest.
4546
+ this.userProperties = {};
4547
+ storage.remove('dl_user_traits');
4548
+ storage.remove('dl_auto_identified_email');
4549
+ storage.remove('dl_journey');
4160
4550
  this.log('User opted out');
4161
4551
  }
4162
4552
  /**
@@ -4169,6 +4559,10 @@ var Datalyr = (function (exports) {
4169
4559
  }
4170
4560
  this.optedOut = false;
4171
4561
  this.cookies.set('__dl_opt_out', 'false', this.config.cookieExpires);
4562
+ // Re-enable only if the full policy now allows (analytics consent + DNT/GPC), not
4563
+ // merely because opt-out was lifted — otherwise a GPC/DNT visitor's persisted
4564
+ // events would start draining again. (Pixels / auto-identify resume on next load.)
4565
+ this.queue.setEnabled(this.shouldTrack());
4172
4566
  this.log('User opted in');
4173
4567
  }
4174
4568
  /**
@@ -4181,7 +4575,26 @@ var Datalyr = (function (exports) {
4181
4575
  * Set consent preferences
4182
4576
  */
4183
4577
  setConsent(consent) {
4578
+ this.consent = consent;
4184
4579
  storage.set('dl_consent', consent);
4580
+ // Enforce it live (not just persist it). Gate the queue by the full policy
4581
+ // (analytics consent + opt-out + DNT/GPC), and on withdrawal purge buffered events
4582
+ // too — mirroring optOut — so events captured before withdrawal can't drain if
4583
+ // consent is later re-granted.
4584
+ const allowed = this.shouldTrack();
4585
+ this.queue.setEnabled(allowed);
4586
+ if (!allowed) {
4587
+ this.queue.clear();
4588
+ this.queue.clearOffline();
4589
+ }
4590
+ // Marketing / "do not sell" withdrawal: stop FEEDING the third-party pixels and
4591
+ // prevent them from being initialized on the next page load. NOTE: an
4592
+ // already-injected pixel global (fbq/gtag/ttq) keeps running in the page — we
4593
+ // can't fully unload a third-party script mid-session; full removal is on reload.
4594
+ if (!this.consentAllowsMarketing() && this.container) {
4595
+ this.container.cleanupAllIframes();
4596
+ this.container = undefined;
4597
+ }
4185
4598
  this.log('Consent updated:', consent);
4186
4599
  }
4187
4600
  /**
@@ -4547,18 +4960,21 @@ var Datalyr = (function (exports) {
4547
4960
  };
4548
4961
  const observer = new MutationObserver(scheduleRestamp);
4549
4962
  observer.observe(document.documentElement, { childList: true, subtree: true });
4550
- // Observer + click listener live for the session; pagehide cleans up on
4551
- // full-page unload. SPA route changes are fine we WANT stamping to keep
4552
- // working across virtual navigations.
4553
- window.addEventListener("pagehide", () => {
4963
+ // Observer + click listener live for the session; pagehide cleans up on full-page
4964
+ // unload, and destroy() can tear them down early via this disposer (H2 — otherwise
4965
+ // a destroy()+re-init() leaks the observer + capturing click listener).
4966
+ this.outboundDisposer = () => {
4554
4967
  try {
4555
4968
  observer.disconnect();
4556
4969
  }
4557
4970
  catch ( /* idempotent */_a) { /* idempotent */ }
4558
- if (restampTimer != null)
4971
+ if (restampTimer != null) {
4559
4972
  clearTimeout(restampTimer);
4973
+ restampTimer = null;
4974
+ }
4560
4975
  document.removeEventListener("click", onClick, true);
4561
- }, { once: true });
4976
+ };
4977
+ window.addEventListener("pagehide", () => { var _a; return (_a = this.outboundDisposer) === null || _a === void 0 ? void 0 : _a.call(this); }, { once: true });
4562
4978
  }
4563
4979
  catch (error) {
4564
4980
  this.log("MutationObserver setup failed (CC link sync):", error);
@@ -4569,6 +4985,12 @@ var Datalyr = (function (exports) {
4569
4985
  * Create event payload
4570
4986
  */
4571
4987
  createEventPayload(eventName, properties, eventIdArg) {
4988
+ // NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
4989
+ // The session id changes on identify() (rotateSessionId) and on session timeout, but
4990
+ // identity only synced it at init/start — so the top-level payload.session_id (from
4991
+ // identity) disagreed with event_data.session_id (from session.getMetrics()). Sync
4992
+ // here, before reading either, so every event carries a single consistent session id.
4993
+ this.identity.setSessionId(this.session.getSessionId());
4572
4994
  // Sanitize and merge properties
4573
4995
  const sanitizedProperties = sanitizeEventData(properties);
4574
4996
  const eventData = deepMerge({}, this.superProperties, sanitizedProperties);
@@ -4625,7 +5047,7 @@ var Datalyr = (function (exports) {
4625
5047
  resolution_method: 'browser_sdk',
4626
5048
  resolution_confidence: 1.0,
4627
5049
  // SDK metadata (keep in sync with package.json version)
4628
- sdk_version: '1.6.5',
5050
+ sdk_version: '1.7.1',
4629
5051
  sdk_name: 'datalyr-web-sdk'
4630
5052
  };
4631
5053
  return payload;
@@ -4638,6 +5060,10 @@ var Datalyr = (function (exports) {
4638
5060
  if (this.optedOut) {
4639
5061
  return false;
4640
5062
  }
5063
+ // Explicit analytics-consent withdrawal (setConsent) blocks first-party tracking.
5064
+ if (this.consent && this.consent.analytics === false) {
5065
+ return false;
5066
+ }
4641
5067
  // Check Do Not Track
4642
5068
  if (this.config.respectDoNotTrack && isDoNotTrackEnabled()) {
4643
5069
  return false;
@@ -4648,6 +5074,14 @@ var Datalyr = (function (exports) {
4648
5074
  }
4649
5075
  return true;
4650
5076
  }
5077
+ /**
5078
+ * Whether marketing/third-party pixels are allowed. Withdrawing `marketing` or `sale`
5079
+ * (CCPA "do not sell") consent via setConsent() blocks loading the Meta/Google/TikTok
5080
+ * pixels (which share data). No consent set = allowed (default).
5081
+ */
5082
+ consentAllowsMarketing() {
5083
+ return !this.consent || (this.consent.marketing !== false && this.consent.sale !== false);
5084
+ }
4651
5085
  /**
4652
5086
  * Setup SPA tracking
4653
5087
  * Fixed Issue #15: Store original methods for cleanup
@@ -4658,56 +5092,55 @@ var Datalyr = (function (exports) {
4658
5092
  this.originalPushState = history.pushState;
4659
5093
  this.originalReplaceState = history.replaceState;
4660
5094
  const self = this;
5095
+ // Seed with the current URL so a router's replaceState-on-mount (same URL) doesn't
5096
+ // fire a duplicate pageview on top of the initial page() call. (M2/M3)
5097
+ this.lastSpaPath = window.location.pathname + window.location.search + window.location.hash;
4661
5098
  // Override pushState
4662
5099
  history.pushState = function (...args) {
4663
5100
  self.originalPushState.apply(history, args);
4664
- setTimeout(() => {
4665
- // Clear attribution cache to capture fresh URL params
4666
- self.attribution.clearCache();
4667
- self.page();
4668
- }, 0);
5101
+ setTimeout(() => self.handleSpaNavigation(), 0);
4669
5102
  };
4670
5103
  // Override replaceState
4671
5104
  history.replaceState = function (...args) {
4672
5105
  self.originalReplaceState.apply(history, args);
4673
- setTimeout(() => {
4674
- // Clear attribution cache to capture fresh URL params
4675
- self.attribution.clearCache();
4676
- self.page();
4677
- }, 0);
5106
+ setTimeout(() => self.handleSpaNavigation(), 0);
4678
5107
  };
4679
5108
  // Listen for popstate
4680
- this.popstateHandler = () => {
4681
- setTimeout(() => {
4682
- // Clear attribution cache to capture fresh URL params
4683
- self.attribution.clearCache();
4684
- self.page();
4685
- }, 0);
4686
- };
5109
+ this.popstateHandler = () => { setTimeout(() => self.handleSpaNavigation(), 0); };
4687
5110
  window.addEventListener('popstate', this.popstateHandler);
4688
5111
  // Listen for hashchange
4689
- this.hashchangeHandler = () => {
4690
- // Clear attribution cache to capture fresh URL params
4691
- self.attribution.clearCache();
4692
- self.page();
4693
- };
5112
+ this.hashchangeHandler = () => { self.handleSpaNavigation(); };
4694
5113
  window.addEventListener('hashchange', this.hashchangeHandler);
4695
5114
  }
5115
+ /**
5116
+ * Handle an SPA virtual navigation. Dedups consecutive same-URL fires (M2/M3): many
5117
+ * routers call history.replaceState on mount/hydration, which previously fired a
5118
+ * SECOND `pageview` on top of the initial `page()`. Only fires when the full URL
5119
+ * (path+search+hash) actually changed.
5120
+ */
5121
+ handleSpaNavigation() {
5122
+ const path = window.location.pathname + window.location.search + window.location.hash;
5123
+ if (path === this.lastSpaPath)
5124
+ return;
5125
+ this.lastSpaPath = path;
5126
+ this.attribution.clearCache();
5127
+ this.page();
5128
+ }
4696
5129
  /**
4697
5130
  * Setup page unload handler
4698
5131
  */
4699
5132
  setupUnloadHandler() {
4700
- // Use both events for maximum compatibility
4701
- const handleUnload = () => {
4702
- this.queue.forceFlush();
5133
+ // Store refs so destroy() can remove these — otherwise a destroy()+re-init() cycle
5134
+ // leaks listeners that keep firing forceFlush() on the OLD (destroyed) queue. (H2)
5135
+ this.unloadHandler = () => { this.queue.forceFlush(); };
5136
+ this.visibilityHandler = () => {
5137
+ if (document.visibilityState === 'hidden')
5138
+ this.queue.forceFlush();
4703
5139
  };
4704
- window.addEventListener('beforeunload', handleUnload);
4705
- window.addEventListener('pagehide', handleUnload);
4706
- window.addEventListener('visibilitychange', () => {
4707
- if (document.visibilityState === 'hidden') {
4708
- handleUnload();
4709
- }
4710
- });
5140
+ // Use multiple events for maximum compatibility.
5141
+ window.addEventListener('beforeunload', this.unloadHandler);
5142
+ window.addEventListener('pagehide', this.unloadHandler);
5143
+ window.addEventListener('visibilitychange', this.visibilityHandler);
4711
5144
  }
4712
5145
  /**
4713
5146
  * Get performance metrics
@@ -4812,6 +5245,22 @@ var Datalyr = (function (exports) {
4812
5245
  if (this.hashchangeHandler) {
4813
5246
  window.removeEventListener('hashchange', this.hashchangeHandler);
4814
5247
  }
5248
+ // H2: also remove the unload/visibility listeners and tear down the CC outbound-link
5249
+ // observer/listener — these were leaking across destroy()+re-init(), keeping the OLD
5250
+ // queue alive and firing on every navigation.
5251
+ if (this.unloadHandler) {
5252
+ window.removeEventListener('beforeunload', this.unloadHandler);
5253
+ window.removeEventListener('pagehide', this.unloadHandler);
5254
+ this.unloadHandler = undefined;
5255
+ }
5256
+ if (this.visibilityHandler) {
5257
+ window.removeEventListener('visibilitychange', this.visibilityHandler);
5258
+ this.visibilityHandler = undefined;
5259
+ }
5260
+ if (this.outboundDisposer) {
5261
+ this.outboundDisposer();
5262
+ this.outboundDisposer = undefined;
5263
+ }
4815
5264
  // Clean up queue
4816
5265
  if (this.queue) {
4817
5266
  this.queue.destroy();
@@ -4830,6 +5279,13 @@ var Datalyr = (function (exports) {
4830
5279
  }
4831
5280
  // SEC-03 Fix: Clean up encryption keys
4832
5281
  dataEncryption.destroy();
5282
+ // H3: reset init state so a subsequent init() fully re-runs. Without nulling the
5283
+ // initializationPromise, initializeAsync() early-returns the stale (resolved)
5284
+ // promise and the SDK comes back half-initialized (no container/pixels/SPA/pageview).
5285
+ this.initializationPromise = null;
5286
+ this.container = undefined;
5287
+ this.autoIdentify = undefined;
5288
+ this.lastSpaPath = null;
4833
5289
  // Clear any remaining data
4834
5290
  this.superProperties = {};
4835
5291
  this.userProperties = {};