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