@datalyr/web 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/attribution.d.ts +7 -0
- package/dist/attribution.d.ts.map +1 -1
- package/dist/attribution.test.d.ts +8 -0
- package/dist/attribution.test.d.ts.map +1 -0
- package/dist/auto-identify.d.ts +2 -1
- package/dist/auto-identify.d.ts.map +1 -1
- package/dist/container.d.ts.map +1 -1
- package/dist/datalyr.cjs.js +637 -205
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +637 -205
- package/dist/datalyr.esm.js.map +1 -1
- package/dist/datalyr.esm.min.js +1 -1
- package/dist/datalyr.esm.min.js.map +1 -1
- package/dist/datalyr.js +637 -205
- package/dist/datalyr.js.map +1 -1
- package/dist/datalyr.min.js +1 -1
- package/dist/datalyr.min.js.map +1 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/queue.d.ts +32 -0
- package/dist/queue.d.ts.map +1 -1
- package/dist/queue.test.d.ts +8 -0
- package/dist/queue.test.d.ts.map +1 -0
- package/package.json +1 -1
package/dist/datalyr.cjs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @datalyr/web v1.7.
|
|
2
|
+
* @datalyr/web v1.7.1
|
|
3
3
|
* Datalyr Web SDK - Modern attribution tracking for web applications
|
|
4
4
|
* (c) 2026 Datalyr Inc.
|
|
5
5
|
* Released under the MIT License
|
|
@@ -1412,13 +1412,19 @@ class AttributionManager {
|
|
|
1412
1412
|
attribution[key] = value;
|
|
1413
1413
|
}
|
|
1414
1414
|
}
|
|
1415
|
-
// Capture click IDs
|
|
1415
|
+
// Capture click IDs. The first present (by CLICK_IDS priority) is the primary
|
|
1416
|
+
// clickId/clickIdType, but ALSO capture every present click ID as its own named
|
|
1417
|
+
// field — a single URL can carry both fbclid and gclid (redirect chains, forwarded
|
|
1418
|
+
// / re-shared links), and keeping only the first dropped the others' platform
|
|
1419
|
+
// attribution entirely. (WEB NEW-3)
|
|
1416
1420
|
for (const clickId of this.CLICK_IDS) {
|
|
1417
1421
|
const value = params[clickId];
|
|
1418
1422
|
if (value) {
|
|
1419
|
-
attribution.clickId
|
|
1420
|
-
|
|
1421
|
-
|
|
1423
|
+
if (!attribution.clickId) {
|
|
1424
|
+
attribution.clickId = value;
|
|
1425
|
+
attribution.clickIdType = clickId;
|
|
1426
|
+
}
|
|
1427
|
+
attribution[clickId] = value;
|
|
1422
1428
|
}
|
|
1423
1429
|
}
|
|
1424
1430
|
// Capture custom tracked parameters
|
|
@@ -1548,7 +1554,10 @@ class AttributionManager {
|
|
|
1548
1554
|
// Generate _fbp if missing (Facebook browser ID)
|
|
1549
1555
|
if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
|
|
1550
1556
|
const timestamp = Date.now();
|
|
1551
|
-
|
|
1557
|
+
// Meta's _fbp format is fb.1.<creationTimeMs>.<randomNumber> where the last
|
|
1558
|
+
// segment MUST be a decimal integer. The old base36 string was non-conformant —
|
|
1559
|
+
// Meta ignores it (and it can shadow a real _fbp), degrading EMQ. (WEB-17)
|
|
1560
|
+
const randomId = Math.floor(Math.random() * 1e10).toString();
|
|
1552
1561
|
adCookies._fbp = `fb.1.${timestamp}.${randomId}`;
|
|
1553
1562
|
// Optionally set the cookie for future use
|
|
1554
1563
|
cookies.set('_fbp', adCookies._fbp, 90);
|
|
@@ -1607,23 +1616,30 @@ class AttributionManager {
|
|
|
1607
1616
|
const lastTouch = this.getLastTouch();
|
|
1608
1617
|
const journey = this.getJourney();
|
|
1609
1618
|
let current = this.captureAttribution();
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1619
|
+
// A "real" attribution signal on THIS pageview = a click ID, a UTM/campaign, or a
|
|
1620
|
+
// referrer/UTM-derived source. determineSource()/determineMedium() FLOOR to
|
|
1621
|
+
// 'direct'/'none', so the mere presence of current.source/medium is NOT a signal.
|
|
1622
|
+
// The old `hasCurrentAttribution` (truthy because source is always at least 'direct')
|
|
1623
|
+
// made the fallback below dead code AND caused storeLastTouch to overwrite a real
|
|
1624
|
+
// paid last-touch with 'direct'/'none' on the next internal navigation. (WEB NEW-1)
|
|
1625
|
+
const hasRealAttribution = !!(current.clickId ||
|
|
1626
|
+
current.campaign ||
|
|
1627
|
+
(current.source && current.source !== 'direct'));
|
|
1628
|
+
if (!hasRealAttribution && firstTouch) {
|
|
1629
|
+
// Direct / internal navigation: fall back to persistent attribution (90-day
|
|
1630
|
+
// window) so the event isn't mis-attributed to 'direct', keeping page context.
|
|
1615
1631
|
if (!firstTouch.expires_at || Date.now() < firstTouch.expires_at) {
|
|
1616
|
-
// Use persistent attribution but keep current page context
|
|
1617
1632
|
current = Object.assign(Object.assign({}, firstTouch), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
|
|
1618
1633
|
}
|
|
1619
1634
|
}
|
|
1620
1635
|
// Capture advertising cookies automatically
|
|
1621
1636
|
const adCookies = this.captureAdCookies();
|
|
1622
|
-
//
|
|
1623
|
-
|
|
1637
|
+
// Only (re)write first/last touch when this pageview carried a REAL signal — never
|
|
1638
|
+
// overwrite stored attribution with the 'direct'/'none' floor of an internal nav.
|
|
1639
|
+
if (!firstTouch && hasRealAttribution) {
|
|
1624
1640
|
this.storeFirstTouch(current);
|
|
1625
1641
|
}
|
|
1626
|
-
if (
|
|
1642
|
+
if (hasRealAttribution) {
|
|
1627
1643
|
this.storeLastTouch(current);
|
|
1628
1644
|
}
|
|
1629
1645
|
return Object.assign(Object.assign(Object.assign({}, current), adCookies), {
|
|
@@ -1660,6 +1676,15 @@ class AttributionManager {
|
|
|
1660
1676
|
// Check referrer
|
|
1661
1677
|
if (attribution.referrerHost) {
|
|
1662
1678
|
const host = attribution.referrerHost.toLowerCase();
|
|
1679
|
+
// Same-site referrer = internal navigation, NOT a new acquisition source.
|
|
1680
|
+
// Without this, a full-page internal nav (referrer = our own domain, common on
|
|
1681
|
+
// classic multi-page stores) classifies as 'referral', which makes
|
|
1682
|
+
// hasRealAttribution true and overwrites the real paid last-touch. Treat it as
|
|
1683
|
+
// direct so the last-touch guard preserves the genuine source.
|
|
1684
|
+
const currentHost = (typeof window !== 'undefined' ? window.location.hostname : '').toLowerCase();
|
|
1685
|
+
if (currentHost && (host === currentHost || this.isSameRootDomain(host, currentHost))) {
|
|
1686
|
+
return 'direct';
|
|
1687
|
+
}
|
|
1663
1688
|
// Social sources
|
|
1664
1689
|
if (host.includes('facebook.com') || host.includes('fb.com'))
|
|
1665
1690
|
return 'facebook';
|
|
@@ -1717,6 +1742,16 @@ class AttributionManager {
|
|
|
1717
1742
|
}
|
|
1718
1743
|
return 'referral';
|
|
1719
1744
|
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Whether two hosts share the same root domain (eTLD+1 approximation), so that
|
|
1747
|
+
* cross-subdomain internal navigation (e.g. shop.example.com → checkout.example.com)
|
|
1748
|
+
* is also treated as same-site. Not a full public-suffix parse — good enough to keep
|
|
1749
|
+
* internal navs from being mis-classified as referral.
|
|
1750
|
+
*/
|
|
1751
|
+
isSameRootDomain(a, b) {
|
|
1752
|
+
const root = (h) => h.split('.').slice(-2).join('.');
|
|
1753
|
+
return root(a) === root(b);
|
|
1754
|
+
}
|
|
1720
1755
|
/**
|
|
1721
1756
|
* Extract hostname from URL
|
|
1722
1757
|
*/
|
|
@@ -1758,6 +1793,11 @@ class AttributionManager {
|
|
|
1758
1793
|
const DEFAULT_CRITICAL_EVENTS = ['purchase', 'signup', 'subscribe', 'lead', 'conversion'];
|
|
1759
1794
|
// Default high priority events that use faster batching
|
|
1760
1795
|
const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_item', 'search'];
|
|
1796
|
+
// A 429 is a deliberate backpressure signal, not a transient failure — tagged so the
|
|
1797
|
+
// send path can route it to the offline queue WITHOUT retrying (which would storm the
|
|
1798
|
+
// already-overloaded server). See sendBatch / the rateLimitedUntil gate.
|
|
1799
|
+
class RateLimitError extends Error {
|
|
1800
|
+
}
|
|
1761
1801
|
class EventQueue {
|
|
1762
1802
|
constructor(config) {
|
|
1763
1803
|
this.queue = [];
|
|
@@ -1770,18 +1810,29 @@ class EventQueue {
|
|
|
1770
1810
|
this.OFFLINE_QUEUE_KEY = 'dl_offline_queue';
|
|
1771
1811
|
this.flushLock = false; // FIXED (DATA-03): Mutex to prevent race conditions
|
|
1772
1812
|
this.offlineQueueLock = false; // FIXED (DATA-03): Separate lock for offline queue operations
|
|
1813
|
+
this.offlineProcessing = false; // FIXED (WEB-1): re-entrancy guard for processOfflineQueue
|
|
1814
|
+
this.inFlight = []; // FIXED (WEB-3): batch currently in _flight's keepalive fetch — forceFlush must NOT re-beacon it
|
|
1815
|
+
this.enabled = true; // FIXED (consent): when false (opt-out / withdrawn analytics consent) all enqueue/flush/drain is a no-op
|
|
1816
|
+
this.rateLimitedUntil = 0; // FIXED (429): skip flush/drain until the server's Retry-After window passes
|
|
1817
|
+
// Coerce numeric config to sane values. The old `config.X || default` both turned a
|
|
1818
|
+
// legitimate 0 into the default AND let invalid values through — a NEGATIVE batchSize
|
|
1819
|
+
// made processOfflineQueue's `splice(0, -1)` loop forever and FREEZE the host tab.
|
|
1820
|
+
const clampInt = (v, def, min, max) => {
|
|
1821
|
+
const n = Number(v);
|
|
1822
|
+
return Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), min), max) : def;
|
|
1823
|
+
};
|
|
1773
1824
|
this.config = {
|
|
1774
|
-
batchSize: config.batchSize
|
|
1775
|
-
flushInterval: config.flushInterval
|
|
1776
|
-
maxRetries: config.maxRetries
|
|
1777
|
-
retryDelay: config.retryDelay
|
|
1825
|
+
batchSize: clampInt(config.batchSize, 10, 1, 1000),
|
|
1826
|
+
flushInterval: clampInt(config.flushInterval, 5000, 250, 3600000),
|
|
1827
|
+
maxRetries: clampInt(config.maxRetries, 5, 0, 20),
|
|
1828
|
+
retryDelay: clampInt(config.retryDelay, 1000, 0, 60000),
|
|
1778
1829
|
endpoint: config.endpoint || 'https://ingest.datalyr.com',
|
|
1779
|
-
fallbackEndpoints: config.fallbackEndpoints
|
|
1830
|
+
fallbackEndpoints: Array.isArray(config.fallbackEndpoints) ? config.fallbackEndpoints : [],
|
|
1780
1831
|
workspaceId: config.workspaceId,
|
|
1781
1832
|
debug: config.debug || false,
|
|
1782
1833
|
criticalEvents: config.criticalEvents || DEFAULT_CRITICAL_EVENTS,
|
|
1783
1834
|
highPriorityEvents: config.highPriorityEvents || DEFAULT_HIGH_PRIORITY_EVENTS,
|
|
1784
|
-
maxOfflineQueueSize: config.maxOfflineQueueSize
|
|
1835
|
+
maxOfflineQueueSize: clampInt(config.maxOfflineQueueSize, 100, 1, 100000)
|
|
1785
1836
|
};
|
|
1786
1837
|
this.networkStatus = {
|
|
1787
1838
|
isOnline: navigator.onLine !== false,
|
|
@@ -1791,11 +1842,22 @@ class EventQueue {
|
|
|
1791
1842
|
this.loadOfflineQueue();
|
|
1792
1843
|
this.setupNetworkListeners();
|
|
1793
1844
|
this.startPeriodicFlush();
|
|
1845
|
+
// WEB-1: a previous session may have left events in the persisted offline queue.
|
|
1846
|
+
// The 'online' listener only fires on an offline→online TRANSITION, which never
|
|
1847
|
+
// happens for a visitor who returns already-online — so drain on startup too,
|
|
1848
|
+
// otherwise those events (incl. failed revenue conversions) just age out.
|
|
1849
|
+
if (this.networkStatus.isOnline && this.offlineQueue.length > 0) {
|
|
1850
|
+
setTimeout(() => this.processOfflineQueue(), 1000);
|
|
1851
|
+
}
|
|
1794
1852
|
}
|
|
1795
1853
|
/**
|
|
1796
1854
|
* Add event to queue
|
|
1797
1855
|
*/
|
|
1798
1856
|
enqueue(event) {
|
|
1857
|
+
// Consent gate: opt-out / withdrawn analytics consent stops all sending. (The SDK
|
|
1858
|
+
// also gates at track(), but this is the backstop for anything that reaches here.)
|
|
1859
|
+
if (!this.enabled)
|
|
1860
|
+
return;
|
|
1799
1861
|
const eventName = event.event_name; // Use snake_case
|
|
1800
1862
|
// Check for duplicates (within 500ms window)
|
|
1801
1863
|
if (this.isDuplicateEvent(event)) {
|
|
@@ -1894,6 +1956,11 @@ class EventQueue {
|
|
|
1894
1956
|
*/
|
|
1895
1957
|
flush() {
|
|
1896
1958
|
return __awaiter(this, void 0, void 0, function* () {
|
|
1959
|
+
if (!this.enabled)
|
|
1960
|
+
return;
|
|
1961
|
+
// FIXED (429): respect the server's Retry-After window instead of hammering it.
|
|
1962
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
1963
|
+
return;
|
|
1897
1964
|
// FIXED (DATA-03): Check both promise and lock for concurrent flush protection
|
|
1898
1965
|
if (this.flushPromise || this.flushLock) {
|
|
1899
1966
|
return this.flushPromise || Promise.resolve();
|
|
@@ -1935,6 +2002,12 @@ class EventQueue {
|
|
|
1935
2002
|
// Only remove after successful send to prevent data loss
|
|
1936
2003
|
const batchSize = Math.min(this.config.batchSize, this.queue.length);
|
|
1937
2004
|
const events = this.queue.slice(0, batchSize);
|
|
2005
|
+
// WEB-3 (HIGH-1): mark this batch as in-flight. sendBatch's fetch uses
|
|
2006
|
+
// keepalive:true, so it survives an unload that fires mid-flush — forceFlush
|
|
2007
|
+
// reads `inFlight` to avoid re-beaconing the very events this fetch is sending.
|
|
2008
|
+
// `inFlight` is always the FRONT `batchSize` of this.queue while flushLock holds
|
|
2009
|
+
// (enqueue only pushes to the back; flushLock serializes _flush bodies).
|
|
2010
|
+
this.inFlight = events;
|
|
1938
2011
|
try {
|
|
1939
2012
|
yield this.sendBatch(events);
|
|
1940
2013
|
// SUCCESS: Now it's safe to remove events from queue
|
|
@@ -1943,10 +2016,17 @@ class EventQueue {
|
|
|
1943
2016
|
}
|
|
1944
2017
|
catch (error) {
|
|
1945
2018
|
this.log('Failed to send batch:', error);
|
|
1946
|
-
//
|
|
1947
|
-
//
|
|
2019
|
+
// WEB-8: make the offline queue the SINGLE owner of these events. Previously
|
|
2020
|
+
// they stayed in the live queue AND were copied to the offline queue, so a
|
|
2021
|
+
// later success on both paths double-sent (relying on server-side dedup).
|
|
2022
|
+
// Remove the same slice from the live queue; the offline queue now drains on
|
|
2023
|
+
// every periodic tick (WEB-1/WEB-2), so they are still retried, not stranded.
|
|
2024
|
+
this.queue.splice(0, batchSize);
|
|
1948
2025
|
this.moveToOfflineQueue(events);
|
|
1949
2026
|
}
|
|
2027
|
+
finally {
|
|
2028
|
+
this.inFlight = [];
|
|
2029
|
+
}
|
|
1950
2030
|
});
|
|
1951
2031
|
}
|
|
1952
2032
|
/**
|
|
@@ -1976,21 +2056,27 @@ class EventQueue {
|
|
|
1976
2056
|
// Handle rate limiting
|
|
1977
2057
|
if (response.status === 429) {
|
|
1978
2058
|
const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
|
|
1979
|
-
|
|
1980
|
-
//
|
|
1981
|
-
//
|
|
1982
|
-
//
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
throw new
|
|
2059
|
+
// FIXED (429 retry-storm): honor Retry-After as a SINGLE backoff window. The
|
|
2060
|
+
// old code scheduled a flush AND let the throw fall into the generic
|
|
2061
|
+
// exponential-backoff retry below — firing ~6 requests inside the window the
|
|
2062
|
+
// server asked us to wait, amplifying an ingest overload. Now: record the
|
|
2063
|
+
// window and throw a RateLimitError (which the catch does NOT retry). The
|
|
2064
|
+
// events move to the offline queue and drain once the window passes.
|
|
2065
|
+
this.rateLimitedUntil = Date.now() + Math.max(retryAfter, 1) * 1000;
|
|
2066
|
+
this.log(`Rate limited; backing off ${retryAfter}s`);
|
|
2067
|
+
throw new RateLimitError('Rate limited (429)');
|
|
1988
2068
|
}
|
|
1989
2069
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1990
2070
|
}
|
|
1991
2071
|
this.log(`Batch sent successfully to ${currentEndpoint}: ${events.length} events`);
|
|
1992
2072
|
}
|
|
1993
2073
|
catch (error) {
|
|
2074
|
+
// A 429 is deliberate backpressure — do NOT retry it here (neither fallback nor
|
|
2075
|
+
// backoff). Propagate so the batch moves to the offline queue; the periodic drain
|
|
2076
|
+
// resumes only after rateLimitedUntil. This is what prevents the retry storm.
|
|
2077
|
+
if (error instanceof RateLimitError) {
|
|
2078
|
+
throw error;
|
|
2079
|
+
}
|
|
1994
2080
|
// Try next fallback endpoint if available
|
|
1995
2081
|
if (endpointIndex < endpoints.length - 1) {
|
|
1996
2082
|
this.log(`Failed on ${currentEndpoint}, trying fallback ${endpointIndex + 1}`);
|
|
@@ -2032,6 +2118,13 @@ class EventQueue {
|
|
|
2032
2118
|
if (this.queue.length > 0) {
|
|
2033
2119
|
this.flush();
|
|
2034
2120
|
}
|
|
2121
|
+
// WEB-1/WEB-2: also drain persisted offline events (including failed critical
|
|
2122
|
+
// events that enqueue() parked here) on every tick — not only on an 'online'
|
|
2123
|
+
// transition that may never fire. This is what gives parked purchase/lead
|
|
2124
|
+
// events a retry path while the user stays online.
|
|
2125
|
+
if (this.networkStatus.isOnline && this.offlineQueue.length > 0) {
|
|
2126
|
+
this.processOfflineQueue();
|
|
2127
|
+
}
|
|
2035
2128
|
}, this.config.flushInterval);
|
|
2036
2129
|
}
|
|
2037
2130
|
/**
|
|
@@ -2050,6 +2143,12 @@ class EventQueue {
|
|
|
2050
2143
|
* FIXED (CRITICAL-06): Can now accept specific events or move entire queue
|
|
2051
2144
|
*/
|
|
2052
2145
|
moveToOfflineQueue(events) {
|
|
2146
|
+
// Consent leak fix: do NOT persist for a disabled queue. Without this, an
|
|
2147
|
+
// in-flight _flush (or critical-event send) that FAILS after optOut() has purged
|
|
2148
|
+
// the offline queue would re-write the PII-bearing event to storage AFTER the
|
|
2149
|
+
// purge. Gating here (a persistence sink, not just the send sinks) closes it.
|
|
2150
|
+
if (!this.enabled)
|
|
2151
|
+
return;
|
|
2053
2152
|
// FIXED (DATA-03): Check if offline queue operation already in progress
|
|
2054
2153
|
if (this.offlineQueueLock) {
|
|
2055
2154
|
console.warn('[Datalyr Queue] Offline queue operation already in progress');
|
|
@@ -2093,6 +2192,10 @@ class EventQueue {
|
|
|
2093
2192
|
* Save offline queue to storage
|
|
2094
2193
|
*/
|
|
2095
2194
|
saveOfflineQueue() {
|
|
2195
|
+
// Defense-in-depth twin of the moveToOfflineQueue gate: never write PII to disk
|
|
2196
|
+
// for a disabled (opted-out) queue.
|
|
2197
|
+
if (!this.enabled)
|
|
2198
|
+
return;
|
|
2096
2199
|
// Keep max events based on config
|
|
2097
2200
|
const toSave = this.offlineQueue.slice(-this.config.maxOfflineQueueSize);
|
|
2098
2201
|
storage.set(this.OFFLINE_QUEUE_KEY, toSave);
|
|
@@ -2102,25 +2205,46 @@ class EventQueue {
|
|
|
2102
2205
|
*/
|
|
2103
2206
|
processOfflineQueue() {
|
|
2104
2207
|
return __awaiter(this, void 0, void 0, function* () {
|
|
2208
|
+
// WEB-1: guard against re-entrancy. This is now driven from three places (the
|
|
2209
|
+
// 'online' listener, the periodic flush, and the on-load kick); without a guard
|
|
2210
|
+
// two of them could splice the same offlineQueue concurrently and double-send.
|
|
2211
|
+
if (!this.enabled)
|
|
2212
|
+
return;
|
|
2213
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
2214
|
+
return; // FIXED (429): honor Retry-After window
|
|
2215
|
+
if (this.offlineProcessing)
|
|
2216
|
+
return;
|
|
2105
2217
|
if (this.offlineQueue.length === 0)
|
|
2106
2218
|
return;
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2219
|
+
if (!this.networkStatus.isOnline)
|
|
2220
|
+
return;
|
|
2221
|
+
this.offlineProcessing = true;
|
|
2222
|
+
try {
|
|
2223
|
+
this.log(`Processing ${this.offlineQueue.length} offline events`);
|
|
2224
|
+
while (this.offlineQueue.length > 0) {
|
|
2225
|
+
// Stop mid-drain if disabled (opt-out during an active drain) — otherwise the
|
|
2226
|
+
// unshift-on-failure below would re-persist a batch after optOut's purge.
|
|
2227
|
+
if (!this.enabled)
|
|
2228
|
+
break;
|
|
2229
|
+
const batch = this.offlineQueue.splice(0, this.config.batchSize);
|
|
2230
|
+
try {
|
|
2231
|
+
yield this.sendBatch(batch);
|
|
2232
|
+
this.saveOfflineQueue();
|
|
2233
|
+
}
|
|
2234
|
+
catch (error) {
|
|
2235
|
+
this.log('Failed to send offline batch:', error);
|
|
2236
|
+
// Put back in queue
|
|
2237
|
+
this.offlineQueue.unshift(...batch);
|
|
2238
|
+
this.saveOfflineQueue();
|
|
2239
|
+
break;
|
|
2240
|
+
}
|
|
2113
2241
|
}
|
|
2114
|
-
|
|
2115
|
-
this.
|
|
2116
|
-
// Put back in queue
|
|
2117
|
-
this.offlineQueue.unshift(...batch);
|
|
2118
|
-
this.saveOfflineQueue();
|
|
2119
|
-
break;
|
|
2242
|
+
if (this.offlineQueue.length === 0) {
|
|
2243
|
+
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2120
2244
|
}
|
|
2121
2245
|
}
|
|
2122
|
-
|
|
2123
|
-
|
|
2246
|
+
finally {
|
|
2247
|
+
this.offlineProcessing = false;
|
|
2124
2248
|
}
|
|
2125
2249
|
});
|
|
2126
2250
|
}
|
|
@@ -2142,30 +2266,103 @@ class EventQueue {
|
|
|
2142
2266
|
getNetworkStatus() {
|
|
2143
2267
|
return Object.assign({}, this.networkStatus);
|
|
2144
2268
|
}
|
|
2269
|
+
/**
|
|
2270
|
+
* Build a batch payload from a set of events.
|
|
2271
|
+
*/
|
|
2272
|
+
buildBatch(events) {
|
|
2273
|
+
return {
|
|
2274
|
+
events,
|
|
2275
|
+
batchId: generateUUID(),
|
|
2276
|
+
timestamp: new Date().toISOString()
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2145
2279
|
/**
|
|
2146
2280
|
* Force flush (for page unload)
|
|
2281
|
+
* FIXED (WEB-3): include the offline queue, chunk under sendBeacon's ~64KB cap,
|
|
2282
|
+
* and persist any chunk the browser refuses so it survives to the next page load.
|
|
2283
|
+
* Previously this beaconed the live queue as one blob — ignoring the offline queue
|
|
2284
|
+
* entirely and silently failing (returning false) whenever the payload exceeded 64KB.
|
|
2285
|
+
*
|
|
2286
|
+
* Two follow-up review fixes:
|
|
2287
|
+
* - HIGH-1: exclude any batch currently in-flight in _flush(). That batch is being
|
|
2288
|
+
* sent with a keepalive fetch that already survives unload; re-beaconing it would
|
|
2289
|
+
* double-send. (`inFlight` is the front `inFlight.length` events of this.queue.)
|
|
2290
|
+
* - HIGH-2: handleUnload is wired to visibilitychange+pagehide+beforeunload, so this
|
|
2291
|
+
* runs multiple times per lifecycle. Detach what we beacon SYNCHRONOUSLY (clear the
|
|
2292
|
+
* in-memory queues, persist a refused remainder straight to storage rather than back
|
|
2293
|
+
* into this.offlineQueue) so a repeat call finds nothing to re-send.
|
|
2147
2294
|
*/
|
|
2148
2295
|
forceFlush() {
|
|
2149
2296
|
return __awaiter(this, void 0, void 0, function* () {
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2297
|
+
if (!this.enabled)
|
|
2298
|
+
return;
|
|
2299
|
+
if (!navigator.sendBeacon) {
|
|
2300
|
+
// No beacon API — best-effort keepalive-fetch drain of both queues.
|
|
2301
|
+
yield this.flush();
|
|
2302
|
+
yield this.processOfflineQueue();
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
// Exclude the in-flight batch (its keepalive fetch already carries it).
|
|
2306
|
+
const live = this.queue.slice(this.inFlight.length);
|
|
2307
|
+
const pending = [...live, ...this.offlineQueue];
|
|
2308
|
+
// Detach synchronously: keep only the in-flight front (for _flush to settle) and
|
|
2309
|
+
// empty the offline queue, so a second unload event this lifecycle re-enters with
|
|
2310
|
+
// nothing to re-beacon. The refused remainder (if any) is persisted to STORAGE
|
|
2311
|
+
// below, not back into this.offlineQueue, for exactly this reason.
|
|
2312
|
+
this.queue = this.queue.slice(0, this.inFlight.length);
|
|
2313
|
+
this.offlineQueue = [];
|
|
2314
|
+
if (pending.length === 0)
|
|
2315
|
+
return;
|
|
2316
|
+
const MAX_BEACON_BYTES = 60000; // headroom under the browser's ~64KB cap
|
|
2317
|
+
// Greedily pack events into chunks bounded by BOTH batchSize and byte size.
|
|
2318
|
+
const chunks = [];
|
|
2319
|
+
let current = [];
|
|
2320
|
+
let currentBytes = 2; // approx for the JSON array/object wrapper
|
|
2321
|
+
for (const ev of pending) {
|
|
2322
|
+
// Byte length (not UTF-16 .length) so multibyte product names / emoji can't
|
|
2323
|
+
// under-count and overflow the cap.
|
|
2324
|
+
const evBytes = new Blob([JSON.stringify(ev)]).size + 1;
|
|
2325
|
+
if (current.length > 0 &&
|
|
2326
|
+
(current.length >= this.config.batchSize || currentBytes + evBytes > MAX_BEACON_BYTES)) {
|
|
2327
|
+
chunks.push(current);
|
|
2328
|
+
current = [];
|
|
2329
|
+
currentBytes = 2;
|
|
2330
|
+
}
|
|
2331
|
+
current.push(ev);
|
|
2332
|
+
currentBytes += evBytes;
|
|
2333
|
+
}
|
|
2334
|
+
if (current.length > 0)
|
|
2335
|
+
chunks.push(current);
|
|
2336
|
+
// Send each chunk; the moment the browser refuses to enqueue one, keep it and
|
|
2337
|
+
// everything after it for the next session.
|
|
2338
|
+
let failedFrom = -1;
|
|
2339
|
+
for (let c = 0; c < chunks.length; c++) {
|
|
2340
|
+
const blob = new Blob([JSON.stringify(this.buildBatch(chunks[c]))], {
|
|
2158
2341
|
type: 'application/json'
|
|
2159
2342
|
});
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2343
|
+
if (!navigator.sendBeacon(this.config.endpoint, blob)) {
|
|
2344
|
+
failedFrom = c;
|
|
2345
|
+
break;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
if (failedFrom >= 0) {
|
|
2349
|
+
const remainder = chunks
|
|
2350
|
+
.slice(failedFrom)
|
|
2351
|
+
.reduce((acc, c) => acc.concat(c), []);
|
|
2352
|
+
// Persist straight to storage (NOT this.offlineQueue) so a repeat unload event
|
|
2353
|
+
// won't re-beacon it; the next page load's drain (which uses a normal fetch with
|
|
2354
|
+
// no 64KB cap) delivers it. MED-2: log if the maxOfflineQueueSize cap truncates.
|
|
2355
|
+
const toPersist = remainder.slice(-this.config.maxOfflineQueueSize);
|
|
2356
|
+
if (remainder.length > toPersist.length) {
|
|
2357
|
+
this.log(`Offline cap dropped ${remainder.length - toPersist.length} oldest events at unload`);
|
|
2165
2358
|
}
|
|
2359
|
+
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2360
|
+
this.log(`sendBeacon refused ${remainder.length} events; persisted ${toPersist.length} for next load`);
|
|
2361
|
+
}
|
|
2362
|
+
else {
|
|
2363
|
+
this.log('Events sent via sendBeacon');
|
|
2364
|
+
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2166
2365
|
}
|
|
2167
|
-
// Fallback to regular flush
|
|
2168
|
-
yield this.flush();
|
|
2169
2366
|
});
|
|
2170
2367
|
}
|
|
2171
2368
|
/**
|
|
@@ -2178,6 +2375,22 @@ class EventQueue {
|
|
|
2178
2375
|
this.batchTimer = null;
|
|
2179
2376
|
}
|
|
2180
2377
|
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Enable/disable all sending. Used by opt-out / withdrawn analytics consent so that
|
|
2380
|
+
* events persisted BEFORE opt-out aren't drained afterwards (the periodic drain and
|
|
2381
|
+
* the on-load drain both honor this).
|
|
2382
|
+
*/
|
|
2383
|
+
setEnabled(enabled) {
|
|
2384
|
+
this.enabled = enabled;
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Clear the offline queue and its persisted copy (used by opt-out to purge any
|
|
2388
|
+
* PII-bearing events that were parked before the user opted out).
|
|
2389
|
+
*/
|
|
2390
|
+
clearOffline() {
|
|
2391
|
+
this.offlineQueue = [];
|
|
2392
|
+
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2393
|
+
}
|
|
2181
2394
|
/**
|
|
2182
2395
|
* Debug logging
|
|
2183
2396
|
*/
|
|
@@ -2874,7 +3087,7 @@ class ContainerManager {
|
|
|
2874
3087
|
* Track event to all initialized pixels
|
|
2875
3088
|
*/
|
|
2876
3089
|
trackToPixels(eventName, properties = {}, eventId) {
|
|
2877
|
-
var _a, _b, _c, _e, _f, _g, _h, _j;
|
|
3090
|
+
var _a, _b, _c, _e, _f, _g, _h, _j, _k, _l;
|
|
2878
3091
|
// Datalyr-internal events ($identify, $group, $alias, $auto_identify,
|
|
2879
3092
|
// $app_download_click, …) are not conversions — never forward them to ad
|
|
2880
3093
|
// pixels. (Previously they fired as noise custom events with the $ stripped.)
|
|
@@ -2940,16 +3153,31 @@ class ContainerManager {
|
|
|
2940
3153
|
// Track to TikTok Pixel
|
|
2941
3154
|
if (((_j = (_h = this.pixels) === null || _h === void 0 ? void 0 : _h.tiktok) === null || _j === void 0 ? void 0 : _j.enabled) && window.ttq) {
|
|
2942
3155
|
try {
|
|
2943
|
-
// Map
|
|
3156
|
+
// Map our event names to TikTok's standard vocabulary. BUG FIX (TikTok-dead):
|
|
3157
|
+
// this map was keyed on Meta-standard names ('Purchase') but looked up with the
|
|
3158
|
+
// sanitized RAW event ('purchase'), so EVERY standard event missed and fired as
|
|
3159
|
+
// a literal custom event (e.g. "purchase" instead of "CompletePayment") — all
|
|
3160
|
+
// TikTok conversions were mis-categorized. Now keyed on the lowercased raw name
|
|
3161
|
+
// and looked up the same way the Meta block does, with a workspace rule map first.
|
|
2944
3162
|
const tiktokEventMap = {
|
|
2945
|
-
'
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
'
|
|
2949
|
-
'
|
|
2950
|
-
|
|
3163
|
+
view_content: 'ViewContent', product_viewed: 'ViewContent', view_item: 'ViewContent',
|
|
3164
|
+
search: 'Search',
|
|
3165
|
+
add_to_wishlist: 'AddToWishlist',
|
|
3166
|
+
add_to_cart: 'AddToCart', product_added: 'AddToCart',
|
|
3167
|
+
initiate_checkout: 'InitiateCheckout', begin_checkout: 'InitiateCheckout', checkout_started: 'InitiateCheckout',
|
|
3168
|
+
add_payment_info: 'AddPaymentInfo',
|
|
3169
|
+
purchase: 'CompletePayment', order_completed: 'CompletePayment', order_paid: 'CompletePayment',
|
|
3170
|
+
place_an_order: 'PlaceAnOrder',
|
|
3171
|
+
contact: 'Contact',
|
|
3172
|
+
download: 'Download',
|
|
3173
|
+
lead: 'SubmitForm', submit_form: 'SubmitForm',
|
|
3174
|
+
complete_registration: 'CompleteRegistration', sign_up: 'CompleteRegistration', signup: 'CompleteRegistration',
|
|
3175
|
+
subscribe: 'Subscribe', subscription_created: 'Subscribe',
|
|
2951
3176
|
};
|
|
2952
|
-
const
|
|
3177
|
+
const tiktokRuleMap = (_l = (_k = this.pixels) === null || _k === void 0 ? void 0 : _k.tiktok) === null || _l === void 0 ? void 0 : _l.event_mappings;
|
|
3178
|
+
const tiktokEvent = (tiktokRuleMap === null || tiktokRuleMap === void 0 ? void 0 : tiktokRuleMap[eventName])
|
|
3179
|
+
|| tiktokEventMap[String(eventName).toLowerCase()]
|
|
3180
|
+
|| sanitizedEventName;
|
|
2953
3181
|
window.ttq.track(tiktokEvent, sanitizedProperties);
|
|
2954
3182
|
}
|
|
2955
3183
|
catch (error) {
|
|
@@ -3098,6 +3326,7 @@ class AutoIdentifyManager {
|
|
|
3098
3326
|
this.formListeners = [];
|
|
3099
3327
|
this.lastIdentifyTime = 0;
|
|
3100
3328
|
this.RATE_LIMIT_MS = 5000; // Don't auto-identify more than once per 5 seconds
|
|
3329
|
+
this.destroyed = false; // set by destroy() (e.g. on opt-out) — stops any in-flight capture from re-persisting email
|
|
3101
3330
|
this.config = {
|
|
3102
3331
|
enabled: config.enabled !== false,
|
|
3103
3332
|
captureFromForms: config.captureFromForms !== false,
|
|
@@ -3111,29 +3340,49 @@ class AutoIdentifyManager {
|
|
|
3111
3340
|
* Initialize auto-identify system
|
|
3112
3341
|
*/
|
|
3113
3342
|
initialize(identifyCallback) {
|
|
3114
|
-
|
|
3115
|
-
this.
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3343
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
3344
|
+
if (!this.config.enabled) {
|
|
3345
|
+
this.log('Auto-identify disabled');
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
this.identifyCallback = identifyCallback;
|
|
3349
|
+
// Check if already identified.
|
|
3350
|
+
// WEB-5: the captured email is PII — store it AES-GCM-encrypted (consistent with
|
|
3351
|
+
// dl_user_traits), not plaintext localStorage.
|
|
3352
|
+
const existingEmail = yield storage.getEncrypted('dl_auto_identified_email');
|
|
3353
|
+
if (existingEmail) {
|
|
3354
|
+
// Migration: a value written by a pre-1.7.1 build is PLAINTEXT. getEncrypted
|
|
3355
|
+
// returns it anyway — decrypt() has a backwards-compat fallback that hands back
|
|
3356
|
+
// the raw string when AES decryption fails — so without this step the PII would
|
|
3357
|
+
// stay in plaintext localStorage forever. Detect a legacy plaintext email by
|
|
3358
|
+
// inspecting the RAW stored bytes (ciphertext is base64, so an '@' means it was
|
|
3359
|
+
// never encrypted) and re-write it encrypted in place.
|
|
3360
|
+
const raw = storage.get('dl_auto_identified_email');
|
|
3361
|
+
if (typeof raw === 'string' && raw.includes('@')) {
|
|
3362
|
+
try {
|
|
3363
|
+
yield storage.setEncrypted('dl_auto_identified_email', existingEmail);
|
|
3364
|
+
this.log('Migrated legacy plaintext auto-identified email to encrypted storage');
|
|
3365
|
+
}
|
|
3366
|
+
catch (error) {
|
|
3367
|
+
this.log('Failed to migrate legacy auto-identified email:', error);
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
this.log('User already auto-identified');
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
// Setup monitoring
|
|
3374
|
+
if (this.config.captureFromForms) {
|
|
3375
|
+
this.setupFormMonitoring();
|
|
3376
|
+
}
|
|
3377
|
+
if (this.config.captureFromAPI) {
|
|
3378
|
+
this.setupFetchInterception();
|
|
3379
|
+
this.setupXHRInterception();
|
|
3380
|
+
}
|
|
3381
|
+
if (this.config.captureFromShopify) {
|
|
3382
|
+
this.setupShopifyMonitoring();
|
|
3383
|
+
}
|
|
3384
|
+
this.log('Auto-identify initialized');
|
|
3385
|
+
});
|
|
3137
3386
|
}
|
|
3138
3387
|
/**
|
|
3139
3388
|
* Setup form monitoring for email capture
|
|
@@ -3289,10 +3538,21 @@ class AutoIdentifyManager {
|
|
|
3289
3538
|
this.log('Shopify detected, setting up monitoring');
|
|
3290
3539
|
// Try to fetch customer data immediately
|
|
3291
3540
|
this.checkShopifyCustomer();
|
|
3292
|
-
// Check periodically (in case customer logs in later)
|
|
3541
|
+
// Check periodically (in case the customer logs in later), but CAP the attempts so a
|
|
3542
|
+
// visitor who never logs in — the vast majority of storefront traffic — doesn't poll
|
|
3543
|
+
// /account.json forever (it used to run every 10s for the whole session). A logged-in
|
|
3544
|
+
// customer surfaces well within ~2 minutes. (WEB-10)
|
|
3545
|
+
let checks = 0;
|
|
3546
|
+
const MAX_CHECKS = 12; // ~2 minutes at 10s
|
|
3293
3547
|
this.shopifyCheckInterval = setInterval(() => {
|
|
3548
|
+
checks++;
|
|
3294
3549
|
this.checkShopifyCustomer();
|
|
3295
|
-
|
|
3550
|
+
if (checks >= MAX_CHECKS && this.shopifyCheckInterval) {
|
|
3551
|
+
clearInterval(this.shopifyCheckInterval);
|
|
3552
|
+
this.shopifyCheckInterval = undefined;
|
|
3553
|
+
this.log('Shopify monitoring stopped after max checks (no login detected)');
|
|
3554
|
+
}
|
|
3555
|
+
}, 10000);
|
|
3296
3556
|
this.log('Shopify monitoring active');
|
|
3297
3557
|
}
|
|
3298
3558
|
/**
|
|
@@ -3449,31 +3709,51 @@ class AutoIdentifyManager {
|
|
|
3449
3709
|
* Trigger identify with rate limiting
|
|
3450
3710
|
*/
|
|
3451
3711
|
triggerIdentify(email, source) {
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3712
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
3713
|
+
if (this.destroyed)
|
|
3714
|
+
return;
|
|
3715
|
+
// Rate limiting
|
|
3716
|
+
const now = Date.now();
|
|
3717
|
+
if (now - this.lastIdentifyTime < this.RATE_LIMIT_MS) {
|
|
3718
|
+
this.log('Rate limited, skipping identify');
|
|
3719
|
+
return;
|
|
3720
|
+
}
|
|
3721
|
+
// Stamp the rate-limit timer synchronously, BEFORE the async storage calls below,
|
|
3722
|
+
// so two near-simultaneous triggers can't both slip past into a double-identify
|
|
3723
|
+
// (the storage read/write is now async — a window the old sync code didn't have).
|
|
3724
|
+
this.lastIdentifyTime = now;
|
|
3725
|
+
// Check if already identified.
|
|
3726
|
+
// WEB-5: encrypted store (the email is PII at rest).
|
|
3727
|
+
const existingEmail = yield storage.getEncrypted('dl_auto_identified_email');
|
|
3728
|
+
if (existingEmail === email) {
|
|
3729
|
+
this.log('Already identified with this email');
|
|
3730
|
+
return;
|
|
3731
|
+
}
|
|
3732
|
+
// If we were destroyed (e.g. user opted out) during the async read above, do NOT
|
|
3733
|
+
// re-persist the email or fire the callback.
|
|
3734
|
+
if (this.destroyed)
|
|
3735
|
+
return;
|
|
3736
|
+
// Store email (encrypted) to prevent duplicate identification. Best-effort: a
|
|
3737
|
+
// storage/encryption failure must not block the identify, since the identify
|
|
3738
|
+
// event carries the email to the backend regardless.
|
|
3739
|
+
try {
|
|
3740
|
+
yield storage.setEncrypted('dl_auto_identified_email', email);
|
|
3741
|
+
}
|
|
3742
|
+
catch (error) {
|
|
3743
|
+
this.log('Failed to persist auto-identified email:', error);
|
|
3744
|
+
}
|
|
3745
|
+
// Trigger callback
|
|
3746
|
+
if (this.identifyCallback) {
|
|
3747
|
+
this.log(`Auto-identifying user: ${email} (source: ${source})`);
|
|
3748
|
+
this.identifyCallback(email, source);
|
|
3749
|
+
}
|
|
3750
|
+
});
|
|
3472
3751
|
}
|
|
3473
3752
|
/**
|
|
3474
3753
|
* Destroy and cleanup
|
|
3475
3754
|
*/
|
|
3476
3755
|
destroy() {
|
|
3756
|
+
this.destroyed = true;
|
|
3477
3757
|
// Restore original fetch
|
|
3478
3758
|
if (this.originalFetch) {
|
|
3479
3759
|
window.fetch = this.originalFetch;
|
|
@@ -3564,9 +3844,12 @@ class Datalyr {
|
|
|
3564
3844
|
this.superProperties = {};
|
|
3565
3845
|
this.userProperties = {};
|
|
3566
3846
|
this.optedOut = false;
|
|
3847
|
+
// Consent preferences (setConsent). null = not set → no consent-based restriction.
|
|
3848
|
+
this.consent = null;
|
|
3567
3849
|
this.initialized = false;
|
|
3568
3850
|
this.errors = [];
|
|
3569
3851
|
this.MAX_ERRORS = 50;
|
|
3852
|
+
this.lastSpaPath = null; // dedups SPA pageviews (replaceState-on-mount double-fire)
|
|
3570
3853
|
// FIXED (ISSUE-01): Async initialization promise to prevent race conditions
|
|
3571
3854
|
this.initializationPromise = null;
|
|
3572
3855
|
// Opt-out check moved to init() after cookies configured (Issue #14)
|
|
@@ -3608,6 +3891,9 @@ class Datalyr {
|
|
|
3608
3891
|
storage.migrateFromLegacyPrefix();
|
|
3609
3892
|
// Check opt-out AFTER cookies configured (Issue #14)
|
|
3610
3893
|
this.optedOut = this.cookies.get('__dl_opt_out') === 'true';
|
|
3894
|
+
// Load persisted consent so shouldTrack()/container gating honor it from the
|
|
3895
|
+
// first event (setConsent persists it; see optOut for the enforcement on opt-out).
|
|
3896
|
+
this.consent = storage.get('dl_consent', null);
|
|
3611
3897
|
// CC funnel page entry: restore the _dl_* URL bridge BEFORE IdentityManager
|
|
3612
3898
|
// initializes so the storefront's visitor_id is the one used here (instead
|
|
3613
3899
|
// of auto-generating a fresh one on this domain).
|
|
@@ -3629,6 +3915,9 @@ class Datalyr {
|
|
|
3629
3915
|
// Set session ID in identity manager
|
|
3630
3916
|
const sessionId = this.session.getSessionId();
|
|
3631
3917
|
this.identity.setSessionId(sessionId);
|
|
3918
|
+
// Gate the queue by the FULL tracking policy (opt-out + analytics consent + DNT +
|
|
3919
|
+
// GPC) so a returning opted-out / DNT / GPC visitor's persisted events don't drain.
|
|
3920
|
+
this.queue.setEnabled(this.shouldTrack());
|
|
3632
3921
|
// FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
|
|
3633
3922
|
// This allows encryption to initialize before any events are tracked
|
|
3634
3923
|
this.initializeAsync();
|
|
@@ -3665,18 +3954,33 @@ class Datalyr {
|
|
|
3665
3954
|
this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
|
|
3666
3955
|
var _a;
|
|
3667
3956
|
try {
|
|
3668
|
-
// SEC-03
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
//
|
|
3672
|
-
|
|
3673
|
-
|
|
3957
|
+
// SEC-03: encryption is for PII-AT-REST only — it must NOT gate event delivery,
|
|
3958
|
+
// pageviews, or pixels. On a non-secure context (http://) or an old browser,
|
|
3959
|
+
// crypto.subtle is absent and initialize() throws; isolate it so the rest of init
|
|
3960
|
+
// (SPA tracking, container/pixels, initial pageview) still runs. (http:// fix)
|
|
3961
|
+
try {
|
|
3962
|
+
const deviceId = this.identity.getAnonymousId();
|
|
3963
|
+
yield dataEncryption.initialize(this.config.workspaceId, deviceId);
|
|
3964
|
+
this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
|
|
3965
|
+
this.log('Encryption initialized, user properties loaded');
|
|
3966
|
+
}
|
|
3967
|
+
catch (encErr) {
|
|
3968
|
+
console.warn('[Datalyr] Encryption unavailable (non-secure context?); continuing WITHOUT PII-at-rest encryption:', encErr);
|
|
3969
|
+
this.userProperties = storage.get('dl_user_traits', {});
|
|
3970
|
+
}
|
|
3674
3971
|
// Setup SPA tracking if enabled
|
|
3675
3972
|
if (this.config.trackSPA) {
|
|
3676
3973
|
this.setupSPATracking();
|
|
3677
3974
|
}
|
|
3678
|
-
// Initialize container manager if enabled
|
|
3679
|
-
|
|
3975
|
+
// Initialize container manager if enabled.
|
|
3976
|
+
// WEB-4 (privacy gate): the container loads third-party pixels (Meta/Google/
|
|
3977
|
+
// TikTok) and calls fbq('init', …, advancedMatching) with the visitor's
|
|
3978
|
+
// SHA-256 email/id — so it must NOT initialize for opted-out / DNT / GPC
|
|
3979
|
+
// visitors, nor when privacyMode is explicitly 'strict'. (Remote config can't
|
|
3980
|
+
// gate this: the container fetch is what delivers the remote config, so only
|
|
3981
|
+
// local consent signals + an explicit strict init() are knowable here.)
|
|
3982
|
+
const privacyStrict = this.config.privacyMode === 'strict';
|
|
3983
|
+
if (this.config.enableContainer !== false && this.shouldTrack() && !privacyStrict && this.consentAllowsMarketing()) {
|
|
3680
3984
|
this.container = new ContainerManager({
|
|
3681
3985
|
workspaceId: this.config.workspaceId,
|
|
3682
3986
|
endpoint: this.config.endpoint,
|
|
@@ -3766,7 +4070,9 @@ class Datalyr {
|
|
|
3766
4070
|
// Storefront → CC bridge: stamp _dl_* params on outbound links to the
|
|
3767
4071
|
// configured CC domains so visitor_id + click signals cross the domain
|
|
3768
4072
|
// boundary. Inert unless checkoutChampDomains is set.
|
|
3769
|
-
if (Array.isArray(this.config.checkoutChampDomains) && this.config.checkoutChampDomains.length > 0) {
|
|
4073
|
+
if (Array.isArray(this.config.checkoutChampDomains) && this.config.checkoutChampDomains.length > 0 && this.shouldTrack()) {
|
|
4074
|
+
// MED-4: gated on shouldTrack() (like syncShopifyCartAttributes) so an
|
|
4075
|
+
// opted-out / DNT / GPC visitor's id + click-ids aren't stamped on outbound links.
|
|
3770
4076
|
this.syncOutboundLinkParams(this.config.checkoutChampDomains);
|
|
3771
4077
|
}
|
|
3772
4078
|
// Track initial page view if enabled (AFTER encryption ready)
|
|
@@ -3851,51 +4157,56 @@ class Datalyr {
|
|
|
3851
4157
|
* so the mobile SDK can retrieve them deterministically after install.
|
|
3852
4158
|
*/
|
|
3853
4159
|
trackAppDownloadClick(options) {
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
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();
|
|
4160
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
4161
|
+
if (!this.initialized) {
|
|
4162
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
4163
|
+
return;
|
|
3890
4164
|
}
|
|
3891
|
-
|
|
3892
|
-
|
|
4165
|
+
if (!this.shouldTrack())
|
|
4166
|
+
return;
|
|
4167
|
+
// Fire the event with target platform info — attribution data is
|
|
4168
|
+
// automatically merged by createEventPayload via getAttributionData()
|
|
4169
|
+
this.track('$app_download_click', {
|
|
4170
|
+
target_platform: options.targetPlatform,
|
|
4171
|
+
app_store_url: options.appStoreUrl,
|
|
4172
|
+
});
|
|
4173
|
+
// AWAIT the flush before navigating away (M5). sendBeacon dispatches synchronously,
|
|
4174
|
+
// but on browsers WITHOUT sendBeacon forceFlush falls back to a keepalive fetch —
|
|
4175
|
+
// and the synchronous window.location assignment below would tear the page down
|
|
4176
|
+
// before that fetch dispatched, losing the click event.
|
|
4177
|
+
yield this.queue.forceFlush();
|
|
4178
|
+
// For Android: append referrer param to Play Store URL with click attribution
|
|
4179
|
+
if (options.targetPlatform === 'android' && options.appStoreUrl.includes('play.google.com')) {
|
|
4180
|
+
const lastTouch = this.attribution.getLastTouch();
|
|
4181
|
+
const referrerParams = new URLSearchParams();
|
|
4182
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickId)
|
|
4183
|
+
referrerParams.set('dl_click_id', lastTouch.clickId);
|
|
4184
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickIdType)
|
|
4185
|
+
referrerParams.set('dl_click_id_type', lastTouch.clickIdType);
|
|
4186
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.source)
|
|
4187
|
+
referrerParams.set('utm_source', lastTouch.source);
|
|
4188
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.medium)
|
|
4189
|
+
referrerParams.set('utm_medium', lastTouch.medium);
|
|
4190
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.campaign)
|
|
4191
|
+
referrerParams.set('utm_campaign', lastTouch.campaign);
|
|
4192
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.content)
|
|
4193
|
+
referrerParams.set('utm_content', lastTouch.content);
|
|
4194
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.term)
|
|
4195
|
+
referrerParams.set('utm_term', lastTouch.term);
|
|
4196
|
+
try {
|
|
4197
|
+
const url = new URL(options.appStoreUrl);
|
|
4198
|
+
url.searchParams.set('referrer', referrerParams.toString());
|
|
4199
|
+
window.location.href = url.toString();
|
|
4200
|
+
}
|
|
4201
|
+
catch (_a) {
|
|
4202
|
+
// If URL parsing fails, redirect without referrer
|
|
4203
|
+
window.location.href = options.appStoreUrl;
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
else {
|
|
3893
4207
|
window.location.href = options.appStoreUrl;
|
|
3894
4208
|
}
|
|
3895
|
-
}
|
|
3896
|
-
else {
|
|
3897
|
-
window.location.href = options.appStoreUrl;
|
|
3898
|
-
}
|
|
4209
|
+
});
|
|
3899
4210
|
}
|
|
3900
4211
|
/**
|
|
3901
4212
|
* Identify a user
|
|
@@ -3960,6 +4271,17 @@ class Datalyr {
|
|
|
3960
4271
|
}
|
|
3961
4272
|
if (!this.shouldTrack())
|
|
3962
4273
|
return;
|
|
4274
|
+
// WEB-6: record one customer-journey touchpoint per session so touchpoint_count /
|
|
4275
|
+
// days_since_first_touch become real multi-touch signals (addTouchpoint had no
|
|
4276
|
+
// callers before, so the journey was always empty). Dedup against the PERSISTED
|
|
4277
|
+
// journey (not an in-memory field) so a hard page reload within the same session
|
|
4278
|
+
// doesn't append a duplicate touchpoint on a classic multi-page site.
|
|
4279
|
+
const sid = this.session.getSessionId();
|
|
4280
|
+
const journey = this.attribution.getJourney();
|
|
4281
|
+
const lastTouchpoint = journey.length ? journey[journey.length - 1] : null;
|
|
4282
|
+
if (!lastTouchpoint || lastTouchpoint.sessionId !== sid) {
|
|
4283
|
+
this.attribution.addTouchpoint(sid, this.attribution.captureAttribution());
|
|
4284
|
+
}
|
|
3963
4285
|
const pageData = Object.assign({ title: document.title, url: window.location.href, path: window.location.pathname, search: window.location.search, referrer: document.referrer }, properties);
|
|
3964
4286
|
// Add referrer data
|
|
3965
4287
|
const referrerData = getReferrerData();
|
|
@@ -4008,6 +4330,8 @@ class Datalyr {
|
|
|
4008
4330
|
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
4009
4331
|
return;
|
|
4010
4332
|
}
|
|
4333
|
+
if (!this.shouldTrack())
|
|
4334
|
+
return;
|
|
4011
4335
|
this.track('$group', {
|
|
4012
4336
|
group_id: groupId,
|
|
4013
4337
|
traits
|
|
@@ -4021,6 +4345,13 @@ class Datalyr {
|
|
|
4021
4345
|
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
4022
4346
|
return;
|
|
4023
4347
|
}
|
|
4348
|
+
if (!userId) {
|
|
4349
|
+
console.warn('[Datalyr] alias() requires a non-empty userId');
|
|
4350
|
+
return;
|
|
4351
|
+
}
|
|
4352
|
+
// Gate before mutating persisted identity (identity.alias writes dl_user_id).
|
|
4353
|
+
if (!this.shouldTrack())
|
|
4354
|
+
return;
|
|
4024
4355
|
const aliasData = this.identity.alias(userId, previousId);
|
|
4025
4356
|
this.track('$alias', aliasData);
|
|
4026
4357
|
}
|
|
@@ -4034,7 +4365,17 @@ class Datalyr {
|
|
|
4034
4365
|
}
|
|
4035
4366
|
this.identity.reset();
|
|
4036
4367
|
this.userProperties = {};
|
|
4368
|
+
// Clear super properties too — they'd otherwise keep attaching the previous user's
|
|
4369
|
+
// values to the next user's events (cross-user contamination on shared devices).
|
|
4370
|
+
this.superProperties = {};
|
|
4037
4371
|
storage.remove('dl_user_traits');
|
|
4372
|
+
// Clear the auto-identify guard so a different user on the same browser is
|
|
4373
|
+
// re-captured (it short-circuits while dl_auto_identified_email is present), and so
|
|
4374
|
+
// the prior user's email isn't left at rest after logout.
|
|
4375
|
+
storage.remove('dl_auto_identified_email');
|
|
4376
|
+
// Clear the journey too, or the next user's touchpoints append to the previous
|
|
4377
|
+
// user's (cross-user contamination of touchpoint_count / days_since_first_touch).
|
|
4378
|
+
storage.remove('dl_journey');
|
|
4038
4379
|
this.session.createNewSession();
|
|
4039
4380
|
this.log('User reset');
|
|
4040
4381
|
}
|
|
@@ -4145,19 +4486,44 @@ class Datalyr {
|
|
|
4145
4486
|
setAttribution(attribution) {
|
|
4146
4487
|
const current = this.attribution.captureAttribution();
|
|
4147
4488
|
const merged = Object.assign(Object.assign({}, current), attribution);
|
|
4489
|
+
// M6: actually make this affect events. Previously it only wrote a session key that
|
|
4490
|
+
// NOTHING in the event path reads, so setAttribution() was a silent no-op. Route it
|
|
4491
|
+
// through the AttributionManager so last_touch_* reflects it (and first_touch_* when
|
|
4492
|
+
// none is set yet — storeFirstTouch is immutable-unless-expired, so it won't clobber
|
|
4493
|
+
// an existing first touch).
|
|
4494
|
+
this.attribution.storeLastTouch(merged);
|
|
4495
|
+
this.attribution.storeFirstTouch(merged);
|
|
4148
4496
|
this.session.storeAttribution(merged);
|
|
4149
4497
|
}
|
|
4150
4498
|
/**
|
|
4151
4499
|
* Opt out of tracking
|
|
4152
4500
|
*/
|
|
4153
4501
|
optOut() {
|
|
4502
|
+
var _a;
|
|
4154
4503
|
if (!this.initialized) {
|
|
4155
4504
|
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
4156
4505
|
return;
|
|
4157
4506
|
}
|
|
4158
4507
|
this.optedOut = true;
|
|
4159
4508
|
this.cookies.set('__dl_opt_out', 'true', this.config.cookieExpires);
|
|
4509
|
+
// Stop the queue from sending OR draining anything persisted before opt-out, and
|
|
4510
|
+
// purge what's already buffered (the periodic/on-load drain has no other gate).
|
|
4511
|
+
this.queue.setEnabled(false);
|
|
4160
4512
|
this.queue.clear();
|
|
4513
|
+
this.queue.clearOffline();
|
|
4514
|
+
// Tear down auto-identify so it stops capturing email into storage post-opt-out.
|
|
4515
|
+
(_a = this.autoIdentify) === null || _a === void 0 ? void 0 : _a.destroy();
|
|
4516
|
+
this.autoIdentify = undefined;
|
|
4517
|
+
// Stop forwarding to (and drop) third-party pixels for this visitor.
|
|
4518
|
+
if (this.container) {
|
|
4519
|
+
this.container.cleanupAllIframes();
|
|
4520
|
+
this.container = undefined;
|
|
4521
|
+
}
|
|
4522
|
+
// Purge PII at rest.
|
|
4523
|
+
this.userProperties = {};
|
|
4524
|
+
storage.remove('dl_user_traits');
|
|
4525
|
+
storage.remove('dl_auto_identified_email');
|
|
4526
|
+
storage.remove('dl_journey');
|
|
4161
4527
|
this.log('User opted out');
|
|
4162
4528
|
}
|
|
4163
4529
|
/**
|
|
@@ -4170,6 +4536,10 @@ class Datalyr {
|
|
|
4170
4536
|
}
|
|
4171
4537
|
this.optedOut = false;
|
|
4172
4538
|
this.cookies.set('__dl_opt_out', 'false', this.config.cookieExpires);
|
|
4539
|
+
// Re-enable only if the full policy now allows (analytics consent + DNT/GPC), not
|
|
4540
|
+
// merely because opt-out was lifted — otherwise a GPC/DNT visitor's persisted
|
|
4541
|
+
// events would start draining again. (Pixels / auto-identify resume on next load.)
|
|
4542
|
+
this.queue.setEnabled(this.shouldTrack());
|
|
4173
4543
|
this.log('User opted in');
|
|
4174
4544
|
}
|
|
4175
4545
|
/**
|
|
@@ -4182,7 +4552,26 @@ class Datalyr {
|
|
|
4182
4552
|
* Set consent preferences
|
|
4183
4553
|
*/
|
|
4184
4554
|
setConsent(consent) {
|
|
4555
|
+
this.consent = consent;
|
|
4185
4556
|
storage.set('dl_consent', consent);
|
|
4557
|
+
// Enforce it live (not just persist it). Gate the queue by the full policy
|
|
4558
|
+
// (analytics consent + opt-out + DNT/GPC), and on withdrawal purge buffered events
|
|
4559
|
+
// too — mirroring optOut — so events captured before withdrawal can't drain if
|
|
4560
|
+
// consent is later re-granted.
|
|
4561
|
+
const allowed = this.shouldTrack();
|
|
4562
|
+
this.queue.setEnabled(allowed);
|
|
4563
|
+
if (!allowed) {
|
|
4564
|
+
this.queue.clear();
|
|
4565
|
+
this.queue.clearOffline();
|
|
4566
|
+
}
|
|
4567
|
+
// Marketing / "do not sell" withdrawal: stop FEEDING the third-party pixels and
|
|
4568
|
+
// prevent them from being initialized on the next page load. NOTE: an
|
|
4569
|
+
// already-injected pixel global (fbq/gtag/ttq) keeps running in the page — we
|
|
4570
|
+
// can't fully unload a third-party script mid-session; full removal is on reload.
|
|
4571
|
+
if (!this.consentAllowsMarketing() && this.container) {
|
|
4572
|
+
this.container.cleanupAllIframes();
|
|
4573
|
+
this.container = undefined;
|
|
4574
|
+
}
|
|
4186
4575
|
this.log('Consent updated:', consent);
|
|
4187
4576
|
}
|
|
4188
4577
|
/**
|
|
@@ -4548,18 +4937,21 @@ class Datalyr {
|
|
|
4548
4937
|
};
|
|
4549
4938
|
const observer = new MutationObserver(scheduleRestamp);
|
|
4550
4939
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
4551
|
-
// Observer + click listener live for the session; pagehide cleans up on
|
|
4552
|
-
//
|
|
4553
|
-
//
|
|
4554
|
-
|
|
4940
|
+
// Observer + click listener live for the session; pagehide cleans up on full-page
|
|
4941
|
+
// unload, and destroy() can tear them down early via this disposer (H2 — otherwise
|
|
4942
|
+
// a destroy()+re-init() leaks the observer + capturing click listener).
|
|
4943
|
+
this.outboundDisposer = () => {
|
|
4555
4944
|
try {
|
|
4556
4945
|
observer.disconnect();
|
|
4557
4946
|
}
|
|
4558
4947
|
catch ( /* idempotent */_a) { /* idempotent */ }
|
|
4559
|
-
if (restampTimer != null)
|
|
4948
|
+
if (restampTimer != null) {
|
|
4560
4949
|
clearTimeout(restampTimer);
|
|
4950
|
+
restampTimer = null;
|
|
4951
|
+
}
|
|
4561
4952
|
document.removeEventListener("click", onClick, true);
|
|
4562
|
-
}
|
|
4953
|
+
};
|
|
4954
|
+
window.addEventListener("pagehide", () => { var _a; return (_a = this.outboundDisposer) === null || _a === void 0 ? void 0 : _a.call(this); }, { once: true });
|
|
4563
4955
|
}
|
|
4564
4956
|
catch (error) {
|
|
4565
4957
|
this.log("MutationObserver setup failed (CC link sync):", error);
|
|
@@ -4570,6 +4962,12 @@ class Datalyr {
|
|
|
4570
4962
|
* Create event payload
|
|
4571
4963
|
*/
|
|
4572
4964
|
createEventPayload(eventName, properties, eventIdArg) {
|
|
4965
|
+
// NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
|
|
4966
|
+
// The session id changes on identify() (rotateSessionId) and on session timeout, but
|
|
4967
|
+
// identity only synced it at init/start — so the top-level payload.session_id (from
|
|
4968
|
+
// identity) disagreed with event_data.session_id (from session.getMetrics()). Sync
|
|
4969
|
+
// here, before reading either, so every event carries a single consistent session id.
|
|
4970
|
+
this.identity.setSessionId(this.session.getSessionId());
|
|
4573
4971
|
// Sanitize and merge properties
|
|
4574
4972
|
const sanitizedProperties = sanitizeEventData(properties);
|
|
4575
4973
|
const eventData = deepMerge({}, this.superProperties, sanitizedProperties);
|
|
@@ -4626,7 +5024,7 @@ class Datalyr {
|
|
|
4626
5024
|
resolution_method: 'browser_sdk',
|
|
4627
5025
|
resolution_confidence: 1.0,
|
|
4628
5026
|
// SDK metadata (keep in sync with package.json version)
|
|
4629
|
-
sdk_version: '1.
|
|
5027
|
+
sdk_version: '1.7.1',
|
|
4630
5028
|
sdk_name: 'datalyr-web-sdk'
|
|
4631
5029
|
};
|
|
4632
5030
|
return payload;
|
|
@@ -4639,6 +5037,10 @@ class Datalyr {
|
|
|
4639
5037
|
if (this.optedOut) {
|
|
4640
5038
|
return false;
|
|
4641
5039
|
}
|
|
5040
|
+
// Explicit analytics-consent withdrawal (setConsent) blocks first-party tracking.
|
|
5041
|
+
if (this.consent && this.consent.analytics === false) {
|
|
5042
|
+
return false;
|
|
5043
|
+
}
|
|
4642
5044
|
// Check Do Not Track
|
|
4643
5045
|
if (this.config.respectDoNotTrack && isDoNotTrackEnabled()) {
|
|
4644
5046
|
return false;
|
|
@@ -4649,6 +5051,14 @@ class Datalyr {
|
|
|
4649
5051
|
}
|
|
4650
5052
|
return true;
|
|
4651
5053
|
}
|
|
5054
|
+
/**
|
|
5055
|
+
* Whether marketing/third-party pixels are allowed. Withdrawing `marketing` or `sale`
|
|
5056
|
+
* (CCPA "do not sell") consent via setConsent() blocks loading the Meta/Google/TikTok
|
|
5057
|
+
* pixels (which share data). No consent set = allowed (default).
|
|
5058
|
+
*/
|
|
5059
|
+
consentAllowsMarketing() {
|
|
5060
|
+
return !this.consent || (this.consent.marketing !== false && this.consent.sale !== false);
|
|
5061
|
+
}
|
|
4652
5062
|
/**
|
|
4653
5063
|
* Setup SPA tracking
|
|
4654
5064
|
* Fixed Issue #15: Store original methods for cleanup
|
|
@@ -4659,56 +5069,55 @@ class Datalyr {
|
|
|
4659
5069
|
this.originalPushState = history.pushState;
|
|
4660
5070
|
this.originalReplaceState = history.replaceState;
|
|
4661
5071
|
const self = this;
|
|
5072
|
+
// Seed with the current URL so a router's replaceState-on-mount (same URL) doesn't
|
|
5073
|
+
// fire a duplicate pageview on top of the initial page() call. (M2/M3)
|
|
5074
|
+
this.lastSpaPath = window.location.pathname + window.location.search + window.location.hash;
|
|
4662
5075
|
// Override pushState
|
|
4663
5076
|
history.pushState = function (...args) {
|
|
4664
5077
|
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);
|
|
5078
|
+
setTimeout(() => self.handleSpaNavigation(), 0);
|
|
4670
5079
|
};
|
|
4671
5080
|
// Override replaceState
|
|
4672
5081
|
history.replaceState = function (...args) {
|
|
4673
5082
|
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);
|
|
5083
|
+
setTimeout(() => self.handleSpaNavigation(), 0);
|
|
4679
5084
|
};
|
|
4680
5085
|
// 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
|
-
};
|
|
5086
|
+
this.popstateHandler = () => { setTimeout(() => self.handleSpaNavigation(), 0); };
|
|
4688
5087
|
window.addEventListener('popstate', this.popstateHandler);
|
|
4689
5088
|
// Listen for hashchange
|
|
4690
|
-
this.hashchangeHandler = () => {
|
|
4691
|
-
// Clear attribution cache to capture fresh URL params
|
|
4692
|
-
self.attribution.clearCache();
|
|
4693
|
-
self.page();
|
|
4694
|
-
};
|
|
5089
|
+
this.hashchangeHandler = () => { self.handleSpaNavigation(); };
|
|
4695
5090
|
window.addEventListener('hashchange', this.hashchangeHandler);
|
|
4696
5091
|
}
|
|
5092
|
+
/**
|
|
5093
|
+
* Handle an SPA virtual navigation. Dedups consecutive same-URL fires (M2/M3): many
|
|
5094
|
+
* routers call history.replaceState on mount/hydration, which previously fired a
|
|
5095
|
+
* SECOND `pageview` on top of the initial `page()`. Only fires when the full URL
|
|
5096
|
+
* (path+search+hash) actually changed.
|
|
5097
|
+
*/
|
|
5098
|
+
handleSpaNavigation() {
|
|
5099
|
+
const path = window.location.pathname + window.location.search + window.location.hash;
|
|
5100
|
+
if (path === this.lastSpaPath)
|
|
5101
|
+
return;
|
|
5102
|
+
this.lastSpaPath = path;
|
|
5103
|
+
this.attribution.clearCache();
|
|
5104
|
+
this.page();
|
|
5105
|
+
}
|
|
4697
5106
|
/**
|
|
4698
5107
|
* Setup page unload handler
|
|
4699
5108
|
*/
|
|
4700
5109
|
setupUnloadHandler() {
|
|
4701
|
-
//
|
|
4702
|
-
|
|
4703
|
-
|
|
5110
|
+
// Store refs so destroy() can remove these — otherwise a destroy()+re-init() cycle
|
|
5111
|
+
// leaks listeners that keep firing forceFlush() on the OLD (destroyed) queue. (H2)
|
|
5112
|
+
this.unloadHandler = () => { this.queue.forceFlush(); };
|
|
5113
|
+
this.visibilityHandler = () => {
|
|
5114
|
+
if (document.visibilityState === 'hidden')
|
|
5115
|
+
this.queue.forceFlush();
|
|
4704
5116
|
};
|
|
4705
|
-
|
|
4706
|
-
window.addEventListener('
|
|
4707
|
-
window.addEventListener('
|
|
4708
|
-
|
|
4709
|
-
handleUnload();
|
|
4710
|
-
}
|
|
4711
|
-
});
|
|
5117
|
+
// Use multiple events for maximum compatibility.
|
|
5118
|
+
window.addEventListener('beforeunload', this.unloadHandler);
|
|
5119
|
+
window.addEventListener('pagehide', this.unloadHandler);
|
|
5120
|
+
window.addEventListener('visibilitychange', this.visibilityHandler);
|
|
4712
5121
|
}
|
|
4713
5122
|
/**
|
|
4714
5123
|
* Get performance metrics
|
|
@@ -4813,6 +5222,22 @@ class Datalyr {
|
|
|
4813
5222
|
if (this.hashchangeHandler) {
|
|
4814
5223
|
window.removeEventListener('hashchange', this.hashchangeHandler);
|
|
4815
5224
|
}
|
|
5225
|
+
// H2: also remove the unload/visibility listeners and tear down the CC outbound-link
|
|
5226
|
+
// observer/listener — these were leaking across destroy()+re-init(), keeping the OLD
|
|
5227
|
+
// queue alive and firing on every navigation.
|
|
5228
|
+
if (this.unloadHandler) {
|
|
5229
|
+
window.removeEventListener('beforeunload', this.unloadHandler);
|
|
5230
|
+
window.removeEventListener('pagehide', this.unloadHandler);
|
|
5231
|
+
this.unloadHandler = undefined;
|
|
5232
|
+
}
|
|
5233
|
+
if (this.visibilityHandler) {
|
|
5234
|
+
window.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
5235
|
+
this.visibilityHandler = undefined;
|
|
5236
|
+
}
|
|
5237
|
+
if (this.outboundDisposer) {
|
|
5238
|
+
this.outboundDisposer();
|
|
5239
|
+
this.outboundDisposer = undefined;
|
|
5240
|
+
}
|
|
4816
5241
|
// Clean up queue
|
|
4817
5242
|
if (this.queue) {
|
|
4818
5243
|
this.queue.destroy();
|
|
@@ -4831,6 +5256,13 @@ class Datalyr {
|
|
|
4831
5256
|
}
|
|
4832
5257
|
// SEC-03 Fix: Clean up encryption keys
|
|
4833
5258
|
dataEncryption.destroy();
|
|
5259
|
+
// H3: reset init state so a subsequent init() fully re-runs. Without nulling the
|
|
5260
|
+
// initializationPromise, initializeAsync() early-returns the stale (resolved)
|
|
5261
|
+
// promise and the SDK comes back half-initialized (no container/pixels/SPA/pageview).
|
|
5262
|
+
this.initializationPromise = null;
|
|
5263
|
+
this.container = undefined;
|
|
5264
|
+
this.autoIdentify = undefined;
|
|
5265
|
+
this.lastSpaPath = null;
|
|
4834
5266
|
// Clear any remaining data
|
|
4835
5267
|
this.superProperties = {};
|
|
4836
5268
|
this.userProperties = {};
|