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