@datalyr/web 1.6.5 → 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 +20 -5
- 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/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/container.d.ts +10 -0
- package/dist/container.d.ts.map +1 -1
- package/dist/datalyr.cjs.js +712 -210
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +712 -210
- 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 +712 -210
- 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 +20 -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.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @datalyr/web v1.
|
|
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
|
|
@@ -1408,13 +1408,19 @@ class AttributionManager {
|
|
|
1408
1408
|
attribution[key] = value;
|
|
1409
1409
|
}
|
|
1410
1410
|
}
|
|
1411
|
-
// Capture click IDs
|
|
1411
|
+
// Capture click IDs. The first present (by CLICK_IDS priority) is the primary
|
|
1412
|
+
// clickId/clickIdType, but ALSO capture every present click ID as its own named
|
|
1413
|
+
// field — a single URL can carry both fbclid and gclid (redirect chains, forwarded
|
|
1414
|
+
// / re-shared links), and keeping only the first dropped the others' platform
|
|
1415
|
+
// attribution entirely. (WEB NEW-3)
|
|
1412
1416
|
for (const clickId of this.CLICK_IDS) {
|
|
1413
1417
|
const value = params[clickId];
|
|
1414
1418
|
if (value) {
|
|
1415
|
-
attribution.clickId
|
|
1416
|
-
|
|
1417
|
-
|
|
1419
|
+
if (!attribution.clickId) {
|
|
1420
|
+
attribution.clickId = value;
|
|
1421
|
+
attribution.clickIdType = clickId;
|
|
1422
|
+
}
|
|
1423
|
+
attribution[clickId] = value;
|
|
1418
1424
|
}
|
|
1419
1425
|
}
|
|
1420
1426
|
// Capture custom tracked parameters
|
|
@@ -1544,7 +1550,10 @@ class AttributionManager {
|
|
|
1544
1550
|
// Generate _fbp if missing (Facebook browser ID)
|
|
1545
1551
|
if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
|
|
1546
1552
|
const timestamp = Date.now();
|
|
1547
|
-
|
|
1553
|
+
// Meta's _fbp format is fb.1.<creationTimeMs>.<randomNumber> where the last
|
|
1554
|
+
// segment MUST be a decimal integer. The old base36 string was non-conformant —
|
|
1555
|
+
// Meta ignores it (and it can shadow a real _fbp), degrading EMQ. (WEB-17)
|
|
1556
|
+
const randomId = Math.floor(Math.random() * 1e10).toString();
|
|
1548
1557
|
adCookies._fbp = `fb.1.${timestamp}.${randomId}`;
|
|
1549
1558
|
// Optionally set the cookie for future use
|
|
1550
1559
|
cookies.set('_fbp', adCookies._fbp, 90);
|
|
@@ -1603,23 +1612,30 @@ class AttributionManager {
|
|
|
1603
1612
|
const lastTouch = this.getLastTouch();
|
|
1604
1613
|
const journey = this.getJourney();
|
|
1605
1614
|
let current = this.captureAttribution();
|
|
1606
|
-
//
|
|
1607
|
-
//
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1615
|
+
// A "real" attribution signal on THIS pageview = a click ID, a UTM/campaign, or a
|
|
1616
|
+
// referrer/UTM-derived source. determineSource()/determineMedium() FLOOR to
|
|
1617
|
+
// 'direct'/'none', so the mere presence of current.source/medium is NOT a signal.
|
|
1618
|
+
// The old `hasCurrentAttribution` (truthy because source is always at least 'direct')
|
|
1619
|
+
// made the fallback below dead code AND caused storeLastTouch to overwrite a real
|
|
1620
|
+
// paid last-touch with 'direct'/'none' on the next internal navigation. (WEB NEW-1)
|
|
1621
|
+
const hasRealAttribution = !!(current.clickId ||
|
|
1622
|
+
current.campaign ||
|
|
1623
|
+
(current.source && current.source !== 'direct'));
|
|
1624
|
+
if (!hasRealAttribution && firstTouch) {
|
|
1625
|
+
// Direct / internal navigation: fall back to persistent attribution (90-day
|
|
1626
|
+
// window) so the event isn't mis-attributed to 'direct', keeping page context.
|
|
1611
1627
|
if (!firstTouch.expires_at || Date.now() < firstTouch.expires_at) {
|
|
1612
|
-
// Use persistent attribution but keep current page context
|
|
1613
1628
|
current = Object.assign(Object.assign({}, firstTouch), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
|
|
1614
1629
|
}
|
|
1615
1630
|
}
|
|
1616
1631
|
// Capture advertising cookies automatically
|
|
1617
1632
|
const adCookies = this.captureAdCookies();
|
|
1618
|
-
//
|
|
1619
|
-
|
|
1633
|
+
// Only (re)write first/last touch when this pageview carried a REAL signal — never
|
|
1634
|
+
// overwrite stored attribution with the 'direct'/'none' floor of an internal nav.
|
|
1635
|
+
if (!firstTouch && hasRealAttribution) {
|
|
1620
1636
|
this.storeFirstTouch(current);
|
|
1621
1637
|
}
|
|
1622
|
-
if (
|
|
1638
|
+
if (hasRealAttribution) {
|
|
1623
1639
|
this.storeLastTouch(current);
|
|
1624
1640
|
}
|
|
1625
1641
|
return Object.assign(Object.assign(Object.assign({}, current), adCookies), {
|
|
@@ -1656,6 +1672,15 @@ class AttributionManager {
|
|
|
1656
1672
|
// Check referrer
|
|
1657
1673
|
if (attribution.referrerHost) {
|
|
1658
1674
|
const host = attribution.referrerHost.toLowerCase();
|
|
1675
|
+
// Same-site referrer = internal navigation, NOT a new acquisition source.
|
|
1676
|
+
// Without this, a full-page internal nav (referrer = our own domain, common on
|
|
1677
|
+
// classic multi-page stores) classifies as 'referral', which makes
|
|
1678
|
+
// hasRealAttribution true and overwrites the real paid last-touch. Treat it as
|
|
1679
|
+
// direct so the last-touch guard preserves the genuine source.
|
|
1680
|
+
const currentHost = (typeof window !== 'undefined' ? window.location.hostname : '').toLowerCase();
|
|
1681
|
+
if (currentHost && (host === currentHost || this.isSameRootDomain(host, currentHost))) {
|
|
1682
|
+
return 'direct';
|
|
1683
|
+
}
|
|
1659
1684
|
// Social sources
|
|
1660
1685
|
if (host.includes('facebook.com') || host.includes('fb.com'))
|
|
1661
1686
|
return 'facebook';
|
|
@@ -1713,6 +1738,16 @@ class AttributionManager {
|
|
|
1713
1738
|
}
|
|
1714
1739
|
return 'referral';
|
|
1715
1740
|
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Whether two hosts share the same root domain (eTLD+1 approximation), so that
|
|
1743
|
+
* cross-subdomain internal navigation (e.g. shop.example.com → checkout.example.com)
|
|
1744
|
+
* is also treated as same-site. Not a full public-suffix parse — good enough to keep
|
|
1745
|
+
* internal navs from being mis-classified as referral.
|
|
1746
|
+
*/
|
|
1747
|
+
isSameRootDomain(a, b) {
|
|
1748
|
+
const root = (h) => h.split('.').slice(-2).join('.');
|
|
1749
|
+
return root(a) === root(b);
|
|
1750
|
+
}
|
|
1716
1751
|
/**
|
|
1717
1752
|
* Extract hostname from URL
|
|
1718
1753
|
*/
|
|
@@ -1754,6 +1789,11 @@ class AttributionManager {
|
|
|
1754
1789
|
const DEFAULT_CRITICAL_EVENTS = ['purchase', 'signup', 'subscribe', 'lead', 'conversion'];
|
|
1755
1790
|
// Default high priority events that use faster batching
|
|
1756
1791
|
const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_item', 'search'];
|
|
1792
|
+
// A 429 is a deliberate backpressure signal, not a transient failure — tagged so the
|
|
1793
|
+
// send path can route it to the offline queue WITHOUT retrying (which would storm the
|
|
1794
|
+
// already-overloaded server). See sendBatch / the rateLimitedUntil gate.
|
|
1795
|
+
class RateLimitError extends Error {
|
|
1796
|
+
}
|
|
1757
1797
|
class EventQueue {
|
|
1758
1798
|
constructor(config) {
|
|
1759
1799
|
this.queue = [];
|
|
@@ -1766,18 +1806,29 @@ class EventQueue {
|
|
|
1766
1806
|
this.OFFLINE_QUEUE_KEY = 'dl_offline_queue';
|
|
1767
1807
|
this.flushLock = false; // FIXED (DATA-03): Mutex to prevent race conditions
|
|
1768
1808
|
this.offlineQueueLock = false; // FIXED (DATA-03): Separate lock for offline queue operations
|
|
1809
|
+
this.offlineProcessing = false; // FIXED (WEB-1): re-entrancy guard for processOfflineQueue
|
|
1810
|
+
this.inFlight = []; // FIXED (WEB-3): batch currently in _flight's keepalive fetch — forceFlush must NOT re-beacon it
|
|
1811
|
+
this.enabled = true; // FIXED (consent): when false (opt-out / withdrawn analytics consent) all enqueue/flush/drain is a no-op
|
|
1812
|
+
this.rateLimitedUntil = 0; // FIXED (429): skip flush/drain until the server's Retry-After window passes
|
|
1813
|
+
// Coerce numeric config to sane values. The old `config.X || default` both turned a
|
|
1814
|
+
// legitimate 0 into the default AND let invalid values through — a NEGATIVE batchSize
|
|
1815
|
+
// made processOfflineQueue's `splice(0, -1)` loop forever and FREEZE the host tab.
|
|
1816
|
+
const clampInt = (v, def, min, max) => {
|
|
1817
|
+
const n = Number(v);
|
|
1818
|
+
return Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), min), max) : def;
|
|
1819
|
+
};
|
|
1769
1820
|
this.config = {
|
|
1770
|
-
batchSize: config.batchSize
|
|
1771
|
-
flushInterval: config.flushInterval
|
|
1772
|
-
maxRetries: config.maxRetries
|
|
1773
|
-
retryDelay: config.retryDelay
|
|
1821
|
+
batchSize: clampInt(config.batchSize, 10, 1, 1000),
|
|
1822
|
+
flushInterval: clampInt(config.flushInterval, 5000, 250, 3600000),
|
|
1823
|
+
maxRetries: clampInt(config.maxRetries, 5, 0, 20),
|
|
1824
|
+
retryDelay: clampInt(config.retryDelay, 1000, 0, 60000),
|
|
1774
1825
|
endpoint: config.endpoint || 'https://ingest.datalyr.com',
|
|
1775
|
-
fallbackEndpoints: config.fallbackEndpoints
|
|
1826
|
+
fallbackEndpoints: Array.isArray(config.fallbackEndpoints) ? config.fallbackEndpoints : [],
|
|
1776
1827
|
workspaceId: config.workspaceId,
|
|
1777
1828
|
debug: config.debug || false,
|
|
1778
1829
|
criticalEvents: config.criticalEvents || DEFAULT_CRITICAL_EVENTS,
|
|
1779
1830
|
highPriorityEvents: config.highPriorityEvents || DEFAULT_HIGH_PRIORITY_EVENTS,
|
|
1780
|
-
maxOfflineQueueSize: config.maxOfflineQueueSize
|
|
1831
|
+
maxOfflineQueueSize: clampInt(config.maxOfflineQueueSize, 100, 1, 100000)
|
|
1781
1832
|
};
|
|
1782
1833
|
this.networkStatus = {
|
|
1783
1834
|
isOnline: navigator.onLine !== false,
|
|
@@ -1787,11 +1838,22 @@ class EventQueue {
|
|
|
1787
1838
|
this.loadOfflineQueue();
|
|
1788
1839
|
this.setupNetworkListeners();
|
|
1789
1840
|
this.startPeriodicFlush();
|
|
1841
|
+
// WEB-1: a previous session may have left events in the persisted offline queue.
|
|
1842
|
+
// The 'online' listener only fires on an offline→online TRANSITION, which never
|
|
1843
|
+
// happens for a visitor who returns already-online — so drain on startup too,
|
|
1844
|
+
// otherwise those events (incl. failed revenue conversions) just age out.
|
|
1845
|
+
if (this.networkStatus.isOnline && this.offlineQueue.length > 0) {
|
|
1846
|
+
setTimeout(() => this.processOfflineQueue(), 1000);
|
|
1847
|
+
}
|
|
1790
1848
|
}
|
|
1791
1849
|
/**
|
|
1792
1850
|
* Add event to queue
|
|
1793
1851
|
*/
|
|
1794
1852
|
enqueue(event) {
|
|
1853
|
+
// Consent gate: opt-out / withdrawn analytics consent stops all sending. (The SDK
|
|
1854
|
+
// also gates at track(), but this is the backstop for anything that reaches here.)
|
|
1855
|
+
if (!this.enabled)
|
|
1856
|
+
return;
|
|
1795
1857
|
const eventName = event.event_name; // Use snake_case
|
|
1796
1858
|
// Check for duplicates (within 500ms window)
|
|
1797
1859
|
if (this.isDuplicateEvent(event)) {
|
|
@@ -1890,6 +1952,11 @@ class EventQueue {
|
|
|
1890
1952
|
*/
|
|
1891
1953
|
flush() {
|
|
1892
1954
|
return __awaiter(this, void 0, void 0, function* () {
|
|
1955
|
+
if (!this.enabled)
|
|
1956
|
+
return;
|
|
1957
|
+
// FIXED (429): respect the server's Retry-After window instead of hammering it.
|
|
1958
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
1959
|
+
return;
|
|
1893
1960
|
// FIXED (DATA-03): Check both promise and lock for concurrent flush protection
|
|
1894
1961
|
if (this.flushPromise || this.flushLock) {
|
|
1895
1962
|
return this.flushPromise || Promise.resolve();
|
|
@@ -1931,6 +1998,12 @@ class EventQueue {
|
|
|
1931
1998
|
// Only remove after successful send to prevent data loss
|
|
1932
1999
|
const batchSize = Math.min(this.config.batchSize, this.queue.length);
|
|
1933
2000
|
const events = this.queue.slice(0, batchSize);
|
|
2001
|
+
// WEB-3 (HIGH-1): mark this batch as in-flight. sendBatch's fetch uses
|
|
2002
|
+
// keepalive:true, so it survives an unload that fires mid-flush — forceFlush
|
|
2003
|
+
// reads `inFlight` to avoid re-beaconing the very events this fetch is sending.
|
|
2004
|
+
// `inFlight` is always the FRONT `batchSize` of this.queue while flushLock holds
|
|
2005
|
+
// (enqueue only pushes to the back; flushLock serializes _flush bodies).
|
|
2006
|
+
this.inFlight = events;
|
|
1934
2007
|
try {
|
|
1935
2008
|
yield this.sendBatch(events);
|
|
1936
2009
|
// SUCCESS: Now it's safe to remove events from queue
|
|
@@ -1939,10 +2012,17 @@ class EventQueue {
|
|
|
1939
2012
|
}
|
|
1940
2013
|
catch (error) {
|
|
1941
2014
|
this.log('Failed to send batch:', error);
|
|
1942
|
-
//
|
|
1943
|
-
//
|
|
2015
|
+
// WEB-8: make the offline queue the SINGLE owner of these events. Previously
|
|
2016
|
+
// they stayed in the live queue AND were copied to the offline queue, so a
|
|
2017
|
+
// later success on both paths double-sent (relying on server-side dedup).
|
|
2018
|
+
// Remove the same slice from the live queue; the offline queue now drains on
|
|
2019
|
+
// every periodic tick (WEB-1/WEB-2), so they are still retried, not stranded.
|
|
2020
|
+
this.queue.splice(0, batchSize);
|
|
1944
2021
|
this.moveToOfflineQueue(events);
|
|
1945
2022
|
}
|
|
2023
|
+
finally {
|
|
2024
|
+
this.inFlight = [];
|
|
2025
|
+
}
|
|
1946
2026
|
});
|
|
1947
2027
|
}
|
|
1948
2028
|
/**
|
|
@@ -1972,21 +2052,27 @@ class EventQueue {
|
|
|
1972
2052
|
// Handle rate limiting
|
|
1973
2053
|
if (response.status === 429) {
|
|
1974
2054
|
const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
|
|
1975
|
-
|
|
1976
|
-
//
|
|
1977
|
-
//
|
|
1978
|
-
//
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
throw new
|
|
2055
|
+
// FIXED (429 retry-storm): honor Retry-After as a SINGLE backoff window. The
|
|
2056
|
+
// old code scheduled a flush AND let the throw fall into the generic
|
|
2057
|
+
// exponential-backoff retry below — firing ~6 requests inside the window the
|
|
2058
|
+
// server asked us to wait, amplifying an ingest overload. Now: record the
|
|
2059
|
+
// window and throw a RateLimitError (which the catch does NOT retry). The
|
|
2060
|
+
// events move to the offline queue and drain once the window passes.
|
|
2061
|
+
this.rateLimitedUntil = Date.now() + Math.max(retryAfter, 1) * 1000;
|
|
2062
|
+
this.log(`Rate limited; backing off ${retryAfter}s`);
|
|
2063
|
+
throw new RateLimitError('Rate limited (429)');
|
|
1984
2064
|
}
|
|
1985
2065
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1986
2066
|
}
|
|
1987
2067
|
this.log(`Batch sent successfully to ${currentEndpoint}: ${events.length} events`);
|
|
1988
2068
|
}
|
|
1989
2069
|
catch (error) {
|
|
2070
|
+
// A 429 is deliberate backpressure — do NOT retry it here (neither fallback nor
|
|
2071
|
+
// backoff). Propagate so the batch moves to the offline queue; the periodic drain
|
|
2072
|
+
// resumes only after rateLimitedUntil. This is what prevents the retry storm.
|
|
2073
|
+
if (error instanceof RateLimitError) {
|
|
2074
|
+
throw error;
|
|
2075
|
+
}
|
|
1990
2076
|
// Try next fallback endpoint if available
|
|
1991
2077
|
if (endpointIndex < endpoints.length - 1) {
|
|
1992
2078
|
this.log(`Failed on ${currentEndpoint}, trying fallback ${endpointIndex + 1}`);
|
|
@@ -2028,6 +2114,13 @@ class EventQueue {
|
|
|
2028
2114
|
if (this.queue.length > 0) {
|
|
2029
2115
|
this.flush();
|
|
2030
2116
|
}
|
|
2117
|
+
// WEB-1/WEB-2: also drain persisted offline events (including failed critical
|
|
2118
|
+
// events that enqueue() parked here) on every tick — not only on an 'online'
|
|
2119
|
+
// transition that may never fire. This is what gives parked purchase/lead
|
|
2120
|
+
// events a retry path while the user stays online.
|
|
2121
|
+
if (this.networkStatus.isOnline && this.offlineQueue.length > 0) {
|
|
2122
|
+
this.processOfflineQueue();
|
|
2123
|
+
}
|
|
2031
2124
|
}, this.config.flushInterval);
|
|
2032
2125
|
}
|
|
2033
2126
|
/**
|
|
@@ -2046,6 +2139,12 @@ class EventQueue {
|
|
|
2046
2139
|
* FIXED (CRITICAL-06): Can now accept specific events or move entire queue
|
|
2047
2140
|
*/
|
|
2048
2141
|
moveToOfflineQueue(events) {
|
|
2142
|
+
// Consent leak fix: do NOT persist for a disabled queue. Without this, an
|
|
2143
|
+
// in-flight _flush (or critical-event send) that FAILS after optOut() has purged
|
|
2144
|
+
// the offline queue would re-write the PII-bearing event to storage AFTER the
|
|
2145
|
+
// purge. Gating here (a persistence sink, not just the send sinks) closes it.
|
|
2146
|
+
if (!this.enabled)
|
|
2147
|
+
return;
|
|
2049
2148
|
// FIXED (DATA-03): Check if offline queue operation already in progress
|
|
2050
2149
|
if (this.offlineQueueLock) {
|
|
2051
2150
|
console.warn('[Datalyr Queue] Offline queue operation already in progress');
|
|
@@ -2089,6 +2188,10 @@ class EventQueue {
|
|
|
2089
2188
|
* Save offline queue to storage
|
|
2090
2189
|
*/
|
|
2091
2190
|
saveOfflineQueue() {
|
|
2191
|
+
// Defense-in-depth twin of the moveToOfflineQueue gate: never write PII to disk
|
|
2192
|
+
// for a disabled (opted-out) queue.
|
|
2193
|
+
if (!this.enabled)
|
|
2194
|
+
return;
|
|
2092
2195
|
// Keep max events based on config
|
|
2093
2196
|
const toSave = this.offlineQueue.slice(-this.config.maxOfflineQueueSize);
|
|
2094
2197
|
storage.set(this.OFFLINE_QUEUE_KEY, toSave);
|
|
@@ -2098,25 +2201,46 @@ class EventQueue {
|
|
|
2098
2201
|
*/
|
|
2099
2202
|
processOfflineQueue() {
|
|
2100
2203
|
return __awaiter(this, void 0, void 0, function* () {
|
|
2204
|
+
// WEB-1: guard against re-entrancy. This is now driven from three places (the
|
|
2205
|
+
// 'online' listener, the periodic flush, and the on-load kick); without a guard
|
|
2206
|
+
// two of them could splice the same offlineQueue concurrently and double-send.
|
|
2207
|
+
if (!this.enabled)
|
|
2208
|
+
return;
|
|
2209
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
2210
|
+
return; // FIXED (429): honor Retry-After window
|
|
2211
|
+
if (this.offlineProcessing)
|
|
2212
|
+
return;
|
|
2101
2213
|
if (this.offlineQueue.length === 0)
|
|
2102
2214
|
return;
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2215
|
+
if (!this.networkStatus.isOnline)
|
|
2216
|
+
return;
|
|
2217
|
+
this.offlineProcessing = true;
|
|
2218
|
+
try {
|
|
2219
|
+
this.log(`Processing ${this.offlineQueue.length} offline events`);
|
|
2220
|
+
while (this.offlineQueue.length > 0) {
|
|
2221
|
+
// Stop mid-drain if disabled (opt-out during an active drain) — otherwise the
|
|
2222
|
+
// unshift-on-failure below would re-persist a batch after optOut's purge.
|
|
2223
|
+
if (!this.enabled)
|
|
2224
|
+
break;
|
|
2225
|
+
const batch = this.offlineQueue.splice(0, this.config.batchSize);
|
|
2226
|
+
try {
|
|
2227
|
+
yield this.sendBatch(batch);
|
|
2228
|
+
this.saveOfflineQueue();
|
|
2229
|
+
}
|
|
2230
|
+
catch (error) {
|
|
2231
|
+
this.log('Failed to send offline batch:', error);
|
|
2232
|
+
// Put back in queue
|
|
2233
|
+
this.offlineQueue.unshift(...batch);
|
|
2234
|
+
this.saveOfflineQueue();
|
|
2235
|
+
break;
|
|
2236
|
+
}
|
|
2109
2237
|
}
|
|
2110
|
-
|
|
2111
|
-
this.
|
|
2112
|
-
// Put back in queue
|
|
2113
|
-
this.offlineQueue.unshift(...batch);
|
|
2114
|
-
this.saveOfflineQueue();
|
|
2115
|
-
break;
|
|
2238
|
+
if (this.offlineQueue.length === 0) {
|
|
2239
|
+
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2116
2240
|
}
|
|
2117
2241
|
}
|
|
2118
|
-
|
|
2119
|
-
|
|
2242
|
+
finally {
|
|
2243
|
+
this.offlineProcessing = false;
|
|
2120
2244
|
}
|
|
2121
2245
|
});
|
|
2122
2246
|
}
|
|
@@ -2138,30 +2262,103 @@ class EventQueue {
|
|
|
2138
2262
|
getNetworkStatus() {
|
|
2139
2263
|
return Object.assign({}, this.networkStatus);
|
|
2140
2264
|
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Build a batch payload from a set of events.
|
|
2267
|
+
*/
|
|
2268
|
+
buildBatch(events) {
|
|
2269
|
+
return {
|
|
2270
|
+
events,
|
|
2271
|
+
batchId: generateUUID(),
|
|
2272
|
+
timestamp: new Date().toISOString()
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2141
2275
|
/**
|
|
2142
2276
|
* Force flush (for page unload)
|
|
2277
|
+
* FIXED (WEB-3): include the offline queue, chunk under sendBeacon's ~64KB cap,
|
|
2278
|
+
* and persist any chunk the browser refuses so it survives to the next page load.
|
|
2279
|
+
* Previously this beaconed the live queue as one blob — ignoring the offline queue
|
|
2280
|
+
* entirely and silently failing (returning false) whenever the payload exceeded 64KB.
|
|
2281
|
+
*
|
|
2282
|
+
* Two follow-up review fixes:
|
|
2283
|
+
* - HIGH-1: exclude any batch currently in-flight in _flush(). That batch is being
|
|
2284
|
+
* sent with a keepalive fetch that already survives unload; re-beaconing it would
|
|
2285
|
+
* double-send. (`inFlight` is the front `inFlight.length` events of this.queue.)
|
|
2286
|
+
* - HIGH-2: handleUnload is wired to visibilitychange+pagehide+beforeunload, so this
|
|
2287
|
+
* runs multiple times per lifecycle. Detach what we beacon SYNCHRONOUSLY (clear the
|
|
2288
|
+
* in-memory queues, persist a refused remainder straight to storage rather than back
|
|
2289
|
+
* into this.offlineQueue) so a repeat call finds nothing to re-send.
|
|
2143
2290
|
*/
|
|
2144
2291
|
forceFlush() {
|
|
2145
2292
|
return __awaiter(this, void 0, void 0, function* () {
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2293
|
+
if (!this.enabled)
|
|
2294
|
+
return;
|
|
2295
|
+
if (!navigator.sendBeacon) {
|
|
2296
|
+
// No beacon API — best-effort keepalive-fetch drain of both queues.
|
|
2297
|
+
yield this.flush();
|
|
2298
|
+
yield this.processOfflineQueue();
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
// Exclude the in-flight batch (its keepalive fetch already carries it).
|
|
2302
|
+
const live = this.queue.slice(this.inFlight.length);
|
|
2303
|
+
const pending = [...live, ...this.offlineQueue];
|
|
2304
|
+
// Detach synchronously: keep only the in-flight front (for _flush to settle) and
|
|
2305
|
+
// empty the offline queue, so a second unload event this lifecycle re-enters with
|
|
2306
|
+
// nothing to re-beacon. The refused remainder (if any) is persisted to STORAGE
|
|
2307
|
+
// below, not back into this.offlineQueue, for exactly this reason.
|
|
2308
|
+
this.queue = this.queue.slice(0, this.inFlight.length);
|
|
2309
|
+
this.offlineQueue = [];
|
|
2310
|
+
if (pending.length === 0)
|
|
2311
|
+
return;
|
|
2312
|
+
const MAX_BEACON_BYTES = 60000; // headroom under the browser's ~64KB cap
|
|
2313
|
+
// Greedily pack events into chunks bounded by BOTH batchSize and byte size.
|
|
2314
|
+
const chunks = [];
|
|
2315
|
+
let current = [];
|
|
2316
|
+
let currentBytes = 2; // approx for the JSON array/object wrapper
|
|
2317
|
+
for (const ev of pending) {
|
|
2318
|
+
// Byte length (not UTF-16 .length) so multibyte product names / emoji can't
|
|
2319
|
+
// under-count and overflow the cap.
|
|
2320
|
+
const evBytes = new Blob([JSON.stringify(ev)]).size + 1;
|
|
2321
|
+
if (current.length > 0 &&
|
|
2322
|
+
(current.length >= this.config.batchSize || currentBytes + evBytes > MAX_BEACON_BYTES)) {
|
|
2323
|
+
chunks.push(current);
|
|
2324
|
+
current = [];
|
|
2325
|
+
currentBytes = 2;
|
|
2326
|
+
}
|
|
2327
|
+
current.push(ev);
|
|
2328
|
+
currentBytes += evBytes;
|
|
2329
|
+
}
|
|
2330
|
+
if (current.length > 0)
|
|
2331
|
+
chunks.push(current);
|
|
2332
|
+
// Send each chunk; the moment the browser refuses to enqueue one, keep it and
|
|
2333
|
+
// everything after it for the next session.
|
|
2334
|
+
let failedFrom = -1;
|
|
2335
|
+
for (let c = 0; c < chunks.length; c++) {
|
|
2336
|
+
const blob = new Blob([JSON.stringify(this.buildBatch(chunks[c]))], {
|
|
2154
2337
|
type: 'application/json'
|
|
2155
2338
|
});
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
this.queue = [];
|
|
2160
|
-
return;
|
|
2339
|
+
if (!navigator.sendBeacon(this.config.endpoint, blob)) {
|
|
2340
|
+
failedFrom = c;
|
|
2341
|
+
break;
|
|
2161
2342
|
}
|
|
2162
2343
|
}
|
|
2163
|
-
|
|
2164
|
-
|
|
2344
|
+
if (failedFrom >= 0) {
|
|
2345
|
+
const remainder = chunks
|
|
2346
|
+
.slice(failedFrom)
|
|
2347
|
+
.reduce((acc, c) => acc.concat(c), []);
|
|
2348
|
+
// Persist straight to storage (NOT this.offlineQueue) so a repeat unload event
|
|
2349
|
+
// won't re-beacon it; the next page load's drain (which uses a normal fetch with
|
|
2350
|
+
// no 64KB cap) delivers it. MED-2: log if the maxOfflineQueueSize cap truncates.
|
|
2351
|
+
const toPersist = remainder.slice(-this.config.maxOfflineQueueSize);
|
|
2352
|
+
if (remainder.length > toPersist.length) {
|
|
2353
|
+
this.log(`Offline cap dropped ${remainder.length - toPersist.length} oldest events at unload`);
|
|
2354
|
+
}
|
|
2355
|
+
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2356
|
+
this.log(`sendBeacon refused ${remainder.length} events; persisted ${toPersist.length} for next load`);
|
|
2357
|
+
}
|
|
2358
|
+
else {
|
|
2359
|
+
this.log('Events sent via sendBeacon');
|
|
2360
|
+
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2361
|
+
}
|
|
2165
2362
|
});
|
|
2166
2363
|
}
|
|
2167
2364
|
/**
|
|
@@ -2174,6 +2371,22 @@ class EventQueue {
|
|
|
2174
2371
|
this.batchTimer = null;
|
|
2175
2372
|
}
|
|
2176
2373
|
}
|
|
2374
|
+
/**
|
|
2375
|
+
* Enable/disable all sending. Used by opt-out / withdrawn analytics consent so that
|
|
2376
|
+
* events persisted BEFORE opt-out aren't drained afterwards (the periodic drain and
|
|
2377
|
+
* the on-load drain both honor this).
|
|
2378
|
+
*/
|
|
2379
|
+
setEnabled(enabled) {
|
|
2380
|
+
this.enabled = enabled;
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Clear the offline queue and its persisted copy (used by opt-out to purge any
|
|
2384
|
+
* PII-bearing events that were parked before the user opted out).
|
|
2385
|
+
*/
|
|
2386
|
+
clearOffline() {
|
|
2387
|
+
this.offlineQueue = [];
|
|
2388
|
+
storage.remove(this.OFFLINE_QUEUE_KEY);
|
|
2389
|
+
}
|
|
2177
2390
|
/**
|
|
2178
2391
|
* Debug logging
|
|
2179
2392
|
*/
|
|
@@ -2400,9 +2613,10 @@ class ContainerManager {
|
|
|
2400
2613
|
throw new Error(`Failed to fetch container scripts: ${response.status}`);
|
|
2401
2614
|
}
|
|
2402
2615
|
const data = yield response.json();
|
|
2403
|
-
// Store scripts and
|
|
2616
|
+
// Store scripts, pixels, and the SDK runtime config envelope.
|
|
2404
2617
|
this.scripts = data.scripts || [];
|
|
2405
2618
|
this.pixels = data.pixels || null;
|
|
2619
|
+
this.remoteConfig = (data.config && typeof data.config === 'object') ? data.config : undefined;
|
|
2406
2620
|
// Initialize pixels if configured. Awaited so advanced-matching hashes
|
|
2407
2621
|
// are resolved before the first dl.track() flushes through trackToPixels
|
|
2408
2622
|
// (otherwise fbq('init') would lag fbq('track') in fast-path tracks).
|
|
@@ -2432,6 +2646,13 @@ class ContainerManager {
|
|
|
2432
2646
|
}
|
|
2433
2647
|
});
|
|
2434
2648
|
}
|
|
2649
|
+
/**
|
|
2650
|
+
* The SDK runtime config delivered by /container-scripts, or undefined if the
|
|
2651
|
+
* response omitted it. The SDK merges this under explicit init() options.
|
|
2652
|
+
*/
|
|
2653
|
+
getRemoteConfig() {
|
|
2654
|
+
return this.remoteConfig;
|
|
2655
|
+
}
|
|
2435
2656
|
/**
|
|
2436
2657
|
* Load scripts by trigger type
|
|
2437
2658
|
*/
|
|
@@ -2862,7 +3083,7 @@ class ContainerManager {
|
|
|
2862
3083
|
* Track event to all initialized pixels
|
|
2863
3084
|
*/
|
|
2864
3085
|
trackToPixels(eventName, properties = {}, eventId) {
|
|
2865
|
-
var _a, _b, _c, _e, _f, _g, _h, _j;
|
|
3086
|
+
var _a, _b, _c, _e, _f, _g, _h, _j, _k, _l;
|
|
2866
3087
|
// Datalyr-internal events ($identify, $group, $alias, $auto_identify,
|
|
2867
3088
|
// $app_download_click, …) are not conversions — never forward them to ad
|
|
2868
3089
|
// pixels. (Previously they fired as noise custom events with the $ stripped.)
|
|
@@ -2928,16 +3149,31 @@ class ContainerManager {
|
|
|
2928
3149
|
// Track to TikTok Pixel
|
|
2929
3150
|
if (((_j = (_h = this.pixels) === null || _h === void 0 ? void 0 : _h.tiktok) === null || _j === void 0 ? void 0 : _j.enabled) && window.ttq) {
|
|
2930
3151
|
try {
|
|
2931
|
-
// Map
|
|
3152
|
+
// Map our event names to TikTok's standard vocabulary. BUG FIX (TikTok-dead):
|
|
3153
|
+
// this map was keyed on Meta-standard names ('Purchase') but looked up with the
|
|
3154
|
+
// sanitized RAW event ('purchase'), so EVERY standard event missed and fired as
|
|
3155
|
+
// a literal custom event (e.g. "purchase" instead of "CompletePayment") — all
|
|
3156
|
+
// TikTok conversions were mis-categorized. Now keyed on the lowercased raw name
|
|
3157
|
+
// and looked up the same way the Meta block does, with a workspace rule map first.
|
|
2932
3158
|
const tiktokEventMap = {
|
|
2933
|
-
'
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
'
|
|
2937
|
-
'
|
|
2938
|
-
|
|
3159
|
+
view_content: 'ViewContent', product_viewed: 'ViewContent', view_item: 'ViewContent',
|
|
3160
|
+
search: 'Search',
|
|
3161
|
+
add_to_wishlist: 'AddToWishlist',
|
|
3162
|
+
add_to_cart: 'AddToCart', product_added: 'AddToCart',
|
|
3163
|
+
initiate_checkout: 'InitiateCheckout', begin_checkout: 'InitiateCheckout', checkout_started: 'InitiateCheckout',
|
|
3164
|
+
add_payment_info: 'AddPaymentInfo',
|
|
3165
|
+
purchase: 'CompletePayment', order_completed: 'CompletePayment', order_paid: 'CompletePayment',
|
|
3166
|
+
place_an_order: 'PlaceAnOrder',
|
|
3167
|
+
contact: 'Contact',
|
|
3168
|
+
download: 'Download',
|
|
3169
|
+
lead: 'SubmitForm', submit_form: 'SubmitForm',
|
|
3170
|
+
complete_registration: 'CompleteRegistration', sign_up: 'CompleteRegistration', signup: 'CompleteRegistration',
|
|
3171
|
+
subscribe: 'Subscribe', subscription_created: 'Subscribe',
|
|
2939
3172
|
};
|
|
2940
|
-
const
|
|
3173
|
+
const tiktokRuleMap = (_l = (_k = this.pixels) === null || _k === void 0 ? void 0 : _k.tiktok) === null || _l === void 0 ? void 0 : _l.event_mappings;
|
|
3174
|
+
const tiktokEvent = (tiktokRuleMap === null || tiktokRuleMap === void 0 ? void 0 : tiktokRuleMap[eventName])
|
|
3175
|
+
|| tiktokEventMap[String(eventName).toLowerCase()]
|
|
3176
|
+
|| sanitizedEventName;
|
|
2941
3177
|
window.ttq.track(tiktokEvent, sanitizedProperties);
|
|
2942
3178
|
}
|
|
2943
3179
|
catch (error) {
|
|
@@ -3086,10 +3322,11 @@ class AutoIdentifyManager {
|
|
|
3086
3322
|
this.formListeners = [];
|
|
3087
3323
|
this.lastIdentifyTime = 0;
|
|
3088
3324
|
this.RATE_LIMIT_MS = 5000; // Don't auto-identify more than once per 5 seconds
|
|
3325
|
+
this.destroyed = false; // set by destroy() (e.g. on opt-out) — stops any in-flight capture from re-persisting email
|
|
3089
3326
|
this.config = {
|
|
3090
3327
|
enabled: config.enabled !== false,
|
|
3091
3328
|
captureFromForms: config.captureFromForms !== false,
|
|
3092
|
-
captureFromAPI: config.captureFromAPI
|
|
3329
|
+
captureFromAPI: config.captureFromAPI === true, // default OFF: same-origin response-scan can mis-identify (e.g. admin views)
|
|
3093
3330
|
captureFromShopify: config.captureFromShopify !== false,
|
|
3094
3331
|
trustedDomains: config.trustedDomains || [],
|
|
3095
3332
|
debug: config.debug || false
|
|
@@ -3099,29 +3336,49 @@ class AutoIdentifyManager {
|
|
|
3099
3336
|
* Initialize auto-identify system
|
|
3100
3337
|
*/
|
|
3101
3338
|
initialize(identifyCallback) {
|
|
3102
|
-
|
|
3103
|
-
this.
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3339
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
3340
|
+
if (!this.config.enabled) {
|
|
3341
|
+
this.log('Auto-identify disabled');
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3344
|
+
this.identifyCallback = identifyCallback;
|
|
3345
|
+
// Check if already identified.
|
|
3346
|
+
// WEB-5: the captured email is PII — store it AES-GCM-encrypted (consistent with
|
|
3347
|
+
// dl_user_traits), not plaintext localStorage.
|
|
3348
|
+
const existingEmail = yield storage.getEncrypted('dl_auto_identified_email');
|
|
3349
|
+
if (existingEmail) {
|
|
3350
|
+
// Migration: a value written by a pre-1.7.1 build is PLAINTEXT. getEncrypted
|
|
3351
|
+
// returns it anyway — decrypt() has a backwards-compat fallback that hands back
|
|
3352
|
+
// the raw string when AES decryption fails — so without this step the PII would
|
|
3353
|
+
// stay in plaintext localStorage forever. Detect a legacy plaintext email by
|
|
3354
|
+
// inspecting the RAW stored bytes (ciphertext is base64, so an '@' means it was
|
|
3355
|
+
// never encrypted) and re-write it encrypted in place.
|
|
3356
|
+
const raw = storage.get('dl_auto_identified_email');
|
|
3357
|
+
if (typeof raw === 'string' && raw.includes('@')) {
|
|
3358
|
+
try {
|
|
3359
|
+
yield storage.setEncrypted('dl_auto_identified_email', existingEmail);
|
|
3360
|
+
this.log('Migrated legacy plaintext auto-identified email to encrypted storage');
|
|
3361
|
+
}
|
|
3362
|
+
catch (error) {
|
|
3363
|
+
this.log('Failed to migrate legacy auto-identified email:', error);
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
this.log('User already auto-identified');
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
// Setup monitoring
|
|
3370
|
+
if (this.config.captureFromForms) {
|
|
3371
|
+
this.setupFormMonitoring();
|
|
3372
|
+
}
|
|
3373
|
+
if (this.config.captureFromAPI) {
|
|
3374
|
+
this.setupFetchInterception();
|
|
3375
|
+
this.setupXHRInterception();
|
|
3376
|
+
}
|
|
3377
|
+
if (this.config.captureFromShopify) {
|
|
3378
|
+
this.setupShopifyMonitoring();
|
|
3379
|
+
}
|
|
3380
|
+
this.log('Auto-identify initialized');
|
|
3381
|
+
});
|
|
3125
3382
|
}
|
|
3126
3383
|
/**
|
|
3127
3384
|
* Setup form monitoring for email capture
|
|
@@ -3277,10 +3534,21 @@ class AutoIdentifyManager {
|
|
|
3277
3534
|
this.log('Shopify detected, setting up monitoring');
|
|
3278
3535
|
// Try to fetch customer data immediately
|
|
3279
3536
|
this.checkShopifyCustomer();
|
|
3280
|
-
// Check periodically (in case customer logs in later)
|
|
3537
|
+
// Check periodically (in case the customer logs in later), but CAP the attempts so a
|
|
3538
|
+
// visitor who never logs in — the vast majority of storefront traffic — doesn't poll
|
|
3539
|
+
// /account.json forever (it used to run every 10s for the whole session). A logged-in
|
|
3540
|
+
// customer surfaces well within ~2 minutes. (WEB-10)
|
|
3541
|
+
let checks = 0;
|
|
3542
|
+
const MAX_CHECKS = 12; // ~2 minutes at 10s
|
|
3281
3543
|
this.shopifyCheckInterval = setInterval(() => {
|
|
3544
|
+
checks++;
|
|
3282
3545
|
this.checkShopifyCustomer();
|
|
3283
|
-
|
|
3546
|
+
if (checks >= MAX_CHECKS && this.shopifyCheckInterval) {
|
|
3547
|
+
clearInterval(this.shopifyCheckInterval);
|
|
3548
|
+
this.shopifyCheckInterval = undefined;
|
|
3549
|
+
this.log('Shopify monitoring stopped after max checks (no login detected)');
|
|
3550
|
+
}
|
|
3551
|
+
}, 10000);
|
|
3284
3552
|
this.log('Shopify monitoring active');
|
|
3285
3553
|
}
|
|
3286
3554
|
/**
|
|
@@ -3437,31 +3705,51 @@ class AutoIdentifyManager {
|
|
|
3437
3705
|
* Trigger identify with rate limiting
|
|
3438
3706
|
*/
|
|
3439
3707
|
triggerIdentify(email, source) {
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3708
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
3709
|
+
if (this.destroyed)
|
|
3710
|
+
return;
|
|
3711
|
+
// Rate limiting
|
|
3712
|
+
const now = Date.now();
|
|
3713
|
+
if (now - this.lastIdentifyTime < this.RATE_LIMIT_MS) {
|
|
3714
|
+
this.log('Rate limited, skipping identify');
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
// Stamp the rate-limit timer synchronously, BEFORE the async storage calls below,
|
|
3718
|
+
// so two near-simultaneous triggers can't both slip past into a double-identify
|
|
3719
|
+
// (the storage read/write is now async — a window the old sync code didn't have).
|
|
3720
|
+
this.lastIdentifyTime = now;
|
|
3721
|
+
// Check if already identified.
|
|
3722
|
+
// WEB-5: encrypted store (the email is PII at rest).
|
|
3723
|
+
const existingEmail = yield storage.getEncrypted('dl_auto_identified_email');
|
|
3724
|
+
if (existingEmail === email) {
|
|
3725
|
+
this.log('Already identified with this email');
|
|
3726
|
+
return;
|
|
3727
|
+
}
|
|
3728
|
+
// If we were destroyed (e.g. user opted out) during the async read above, do NOT
|
|
3729
|
+
// re-persist the email or fire the callback.
|
|
3730
|
+
if (this.destroyed)
|
|
3731
|
+
return;
|
|
3732
|
+
// Store email (encrypted) to prevent duplicate identification. Best-effort: a
|
|
3733
|
+
// storage/encryption failure must not block the identify, since the identify
|
|
3734
|
+
// event carries the email to the backend regardless.
|
|
3735
|
+
try {
|
|
3736
|
+
yield storage.setEncrypted('dl_auto_identified_email', email);
|
|
3737
|
+
}
|
|
3738
|
+
catch (error) {
|
|
3739
|
+
this.log('Failed to persist auto-identified email:', error);
|
|
3740
|
+
}
|
|
3741
|
+
// Trigger callback
|
|
3742
|
+
if (this.identifyCallback) {
|
|
3743
|
+
this.log(`Auto-identifying user: ${email} (source: ${source})`);
|
|
3744
|
+
this.identifyCallback(email, source);
|
|
3745
|
+
}
|
|
3746
|
+
});
|
|
3460
3747
|
}
|
|
3461
3748
|
/**
|
|
3462
3749
|
* Destroy and cleanup
|
|
3463
3750
|
*/
|
|
3464
3751
|
destroy() {
|
|
3752
|
+
this.destroyed = true;
|
|
3465
3753
|
// Restore original fetch
|
|
3466
3754
|
if (this.originalFetch) {
|
|
3467
3755
|
window.fetch = this.originalFetch;
|
|
@@ -3498,18 +3786,66 @@ class AutoIdentifyManager {
|
|
|
3498
3786
|
}
|
|
3499
3787
|
}
|
|
3500
3788
|
|
|
3789
|
+
/** Keys the remote config is allowed to fill on DatalyrConfig. */
|
|
3790
|
+
const REMOTE_KEYS = [
|
|
3791
|
+
'autoIdentify',
|
|
3792
|
+
'autoIdentifyForms',
|
|
3793
|
+
'autoIdentifyAPI',
|
|
3794
|
+
'autoIdentifyShopify',
|
|
3795
|
+
'shopifyCartAttributes',
|
|
3796
|
+
'checkoutChampDomains',
|
|
3797
|
+
'respectGlobalPrivacyControl',
|
|
3798
|
+
'respectDoNotTrack',
|
|
3799
|
+
'privacyMode',
|
|
3800
|
+
];
|
|
3801
|
+
/**
|
|
3802
|
+
* Fold `remote` into `config` IN PLACE. Precedence per key:
|
|
3803
|
+
* explicit init() value > remote (dashboard) > built-in default
|
|
3804
|
+
*
|
|
3805
|
+
* `explicitKeys` = the keys the CALLER passed to init() (before built-in
|
|
3806
|
+
* defaults were merged in). For any remote key NOT in that set, remote
|
|
3807
|
+
* OVERRIDES the built-in default. This is essential because some keys
|
|
3808
|
+
* (respectDoNotTrack, respectGlobalPrivacyControl, privacyMode) get a built-in
|
|
3809
|
+
* default at init() and are therefore never `undefined` — a naive
|
|
3810
|
+
* "fill-if-undefined" would silently ignore the dashboard for them.
|
|
3811
|
+
*
|
|
3812
|
+
* No-op when there's no remote config (older worker / container disabled /
|
|
3813
|
+
* failed fetch) — built-in defaults then stand.
|
|
3814
|
+
*/
|
|
3815
|
+
function applyRemoteConfig(config, remote, explicitKeys) {
|
|
3816
|
+
if (!remote)
|
|
3817
|
+
return;
|
|
3818
|
+
const target = config;
|
|
3819
|
+
for (const key of REMOTE_KEYS) {
|
|
3820
|
+
const remoteVal = remote[key];
|
|
3821
|
+
if (remoteVal === undefined || remoteVal === null)
|
|
3822
|
+
continue;
|
|
3823
|
+
// Explicit init() always wins; otherwise remote overrides the built-in default.
|
|
3824
|
+
if (explicitKeys === null || explicitKeys === void 0 ? void 0 : explicitKeys.has(key))
|
|
3825
|
+
continue;
|
|
3826
|
+
target[key] = remoteVal;
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3501
3830
|
/**
|
|
3502
3831
|
* Datalyr Web SDK
|
|
3503
3832
|
* Modern attribution tracking for web applications
|
|
3504
3833
|
*/
|
|
3505
3834
|
class Datalyr {
|
|
3506
3835
|
constructor() {
|
|
3836
|
+
// Keys the caller passed to init() (before built-in defaults were merged).
|
|
3837
|
+
// Lets the remote-config merge override built-in defaults while always
|
|
3838
|
+
// deferring to an explicit init() value. See applyRemoteConfig.
|
|
3839
|
+
this.explicitConfigKeys = new Set();
|
|
3507
3840
|
this.superProperties = {};
|
|
3508
3841
|
this.userProperties = {};
|
|
3509
3842
|
this.optedOut = false;
|
|
3843
|
+
// Consent preferences (setConsent). null = not set → no consent-based restriction.
|
|
3844
|
+
this.consent = null;
|
|
3510
3845
|
this.initialized = false;
|
|
3511
3846
|
this.errors = [];
|
|
3512
3847
|
this.MAX_ERRORS = 50;
|
|
3848
|
+
this.lastSpaPath = null; // dedups SPA pageviews (replaceState-on-mount double-fire)
|
|
3513
3849
|
// FIXED (ISSUE-01): Async initialization promise to prevent race conditions
|
|
3514
3850
|
this.initializationPromise = null;
|
|
3515
3851
|
// Opt-out check moved to init() after cookies configured (Issue #14)
|
|
@@ -3526,6 +3862,10 @@ class Datalyr {
|
|
|
3526
3862
|
if (!config.workspaceId) {
|
|
3527
3863
|
throw new Error('[Datalyr] workspaceId is required');
|
|
3528
3864
|
}
|
|
3865
|
+
// Snapshot the keys the caller explicitly set, BEFORE built-in defaults are
|
|
3866
|
+
// merged in — so the remote-config merge can override built-in defaults
|
|
3867
|
+
// while still deferring to anything the caller passed explicitly.
|
|
3868
|
+
this.explicitConfigKeys = new Set(Object.keys(config));
|
|
3529
3869
|
// Set default config values
|
|
3530
3870
|
this.config = Object.assign({ endpoint: 'https://ingest.datalyr.com', debug: false, batchSize: 10, flushInterval: 5000, flushAt: 10, criticalEvents: undefined, highPriorityEvents: undefined, sessionTimeout: 60 * 60 * 1000, trackSessions: true, attributionWindow: 90 * 24 * 60 * 60 * 1000, trackedParams: [], respectDoNotTrack: false, respectGlobalPrivacyControl: true, privacyMode: 'standard', cookieDomain: 'auto', cookieExpires: 365, secureCookie: 'auto', sameSite: 'Lax', cookiePrefix: '__dl_', enablePerformanceTracking: true, enableFingerprinting: true, maxRetries: 5, retryDelay: 1000, maxOfflineQueueSize: 100, trackSPA: true, trackPageViews: true, fallbackEndpoints: [], plugins: [] }, config);
|
|
3531
3871
|
// platform:'shopify' implies cart-attribute sync: stamp visitor_id + Meta
|
|
@@ -3547,6 +3887,9 @@ class Datalyr {
|
|
|
3547
3887
|
storage.migrateFromLegacyPrefix();
|
|
3548
3888
|
// Check opt-out AFTER cookies configured (Issue #14)
|
|
3549
3889
|
this.optedOut = this.cookies.get('__dl_opt_out') === 'true';
|
|
3890
|
+
// Load persisted consent so shouldTrack()/container gating honor it from the
|
|
3891
|
+
// first event (setConsent persists it; see optOut for the enforcement on opt-out).
|
|
3892
|
+
this.consent = storage.get('dl_consent', null);
|
|
3550
3893
|
// CC funnel page entry: restore the _dl_* URL bridge BEFORE IdentityManager
|
|
3551
3894
|
// initializes so the storefront's visitor_id is the one used here (instead
|
|
3552
3895
|
// of auto-generating a fresh one on this domain).
|
|
@@ -3568,6 +3911,9 @@ class Datalyr {
|
|
|
3568
3911
|
// Set session ID in identity manager
|
|
3569
3912
|
const sessionId = this.session.getSessionId();
|
|
3570
3913
|
this.identity.setSessionId(sessionId);
|
|
3914
|
+
// Gate the queue by the FULL tracking policy (opt-out + analytics consent + DNT +
|
|
3915
|
+
// GPC) so a returning opted-out / DNT / GPC visitor's persisted events don't drain.
|
|
3916
|
+
this.queue.setEnabled(this.shouldTrack());
|
|
3571
3917
|
// FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
|
|
3572
3918
|
// This allows encryption to initialize before any events are tracked
|
|
3573
3919
|
this.initializeAsync();
|
|
@@ -3602,19 +3948,35 @@ class Datalyr {
|
|
|
3602
3948
|
return this.initializationPromise;
|
|
3603
3949
|
}
|
|
3604
3950
|
this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
|
|
3951
|
+
var _a;
|
|
3605
3952
|
try {
|
|
3606
|
-
// SEC-03
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
//
|
|
3610
|
-
|
|
3611
|
-
|
|
3953
|
+
// SEC-03: encryption is for PII-AT-REST only — it must NOT gate event delivery,
|
|
3954
|
+
// pageviews, or pixels. On a non-secure context (http://) or an old browser,
|
|
3955
|
+
// crypto.subtle is absent and initialize() throws; isolate it so the rest of init
|
|
3956
|
+
// (SPA tracking, container/pixels, initial pageview) still runs. (http:// fix)
|
|
3957
|
+
try {
|
|
3958
|
+
const deviceId = this.identity.getAnonymousId();
|
|
3959
|
+
yield dataEncryption.initialize(this.config.workspaceId, deviceId);
|
|
3960
|
+
this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
|
|
3961
|
+
this.log('Encryption initialized, user properties loaded');
|
|
3962
|
+
}
|
|
3963
|
+
catch (encErr) {
|
|
3964
|
+
console.warn('[Datalyr] Encryption unavailable (non-secure context?); continuing WITHOUT PII-at-rest encryption:', encErr);
|
|
3965
|
+
this.userProperties = storage.get('dl_user_traits', {});
|
|
3966
|
+
}
|
|
3612
3967
|
// Setup SPA tracking if enabled
|
|
3613
3968
|
if (this.config.trackSPA) {
|
|
3614
3969
|
this.setupSPATracking();
|
|
3615
3970
|
}
|
|
3616
|
-
// Initialize container manager if enabled
|
|
3617
|
-
|
|
3971
|
+
// Initialize container manager if enabled.
|
|
3972
|
+
// WEB-4 (privacy gate): the container loads third-party pixels (Meta/Google/
|
|
3973
|
+
// TikTok) and calls fbq('init', …, advancedMatching) with the visitor's
|
|
3974
|
+
// SHA-256 email/id — so it must NOT initialize for opted-out / DNT / GPC
|
|
3975
|
+
// visitors, nor when privacyMode is explicitly 'strict'. (Remote config can't
|
|
3976
|
+
// gate this: the container fetch is what delivers the remote config, so only
|
|
3977
|
+
// local consent signals + an explicit strict init() are knowable here.)
|
|
3978
|
+
const privacyStrict = this.config.privacyMode === 'strict';
|
|
3979
|
+
if (this.config.enableContainer !== false && this.shouldTrack() && !privacyStrict && this.consentAllowsMarketing()) {
|
|
3618
3980
|
this.container = new ContainerManager({
|
|
3619
3981
|
workspaceId: this.config.workspaceId,
|
|
3620
3982
|
endpoint: this.config.endpoint,
|
|
@@ -3647,6 +4009,16 @@ class Datalyr {
|
|
|
3647
4009
|
this.fireCheckoutChampPurchasePixel();
|
|
3648
4010
|
}
|
|
3649
4011
|
}
|
|
4012
|
+
// Fold the remote /container-scripts config UNDER explicit init()
|
|
4013
|
+
// overrides (precedence: defaults <- remote <- explicit). No-op if the
|
|
4014
|
+
// container is disabled or the worker didn't send `config`. This is what
|
|
4015
|
+
// makes the dashboard toggles take effect with no snippet edits.
|
|
4016
|
+
applyRemoteConfig(this.config, (_a = this.container) === null || _a === void 0 ? void 0 : _a.getRemoteConfig(), this.explicitConfigKeys);
|
|
4017
|
+
// Privacy-strict turns auto-identify (email capture) OFF regardless of
|
|
4018
|
+
// the dashboard/remote value — privacy wins over the bridge.
|
|
4019
|
+
if (this.config.privacyMode === 'strict') {
|
|
4020
|
+
this.config.autoIdentify = false;
|
|
4021
|
+
}
|
|
3650
4022
|
// autoIdentify default for the CC bridge:
|
|
3651
4023
|
// - platform === 'checkoutchamp' (CC funnel pages) OR
|
|
3652
4024
|
// - checkoutChampDomains set (Shopify storefronts feeding a CC funnel)
|
|
@@ -3657,8 +4029,10 @@ class Datalyr {
|
|
|
3657
4029
|
if (ccBridgeActive && this.config.autoIdentify === undefined) {
|
|
3658
4030
|
this.config.autoIdentify = true;
|
|
3659
4031
|
}
|
|
3660
|
-
// Initialize auto-identify
|
|
3661
|
-
|
|
4032
|
+
// Initialize auto-identify when enabled (explicit or remote) AND
|
|
4033
|
+
// tracking is allowed. The shouldTrack() gate keeps capture from even
|
|
4034
|
+
// setting up its form/API interceptors for opted-out / DNT / GPC users.
|
|
4035
|
+
if (this.config.autoIdentify === true && this.shouldTrack()) {
|
|
3662
4036
|
this.autoIdentify = new AutoIdentifyManager({
|
|
3663
4037
|
enabled: true,
|
|
3664
4038
|
captureFromForms: this.config.autoIdentifyForms,
|
|
@@ -3684,7 +4058,7 @@ class Datalyr {
|
|
|
3684
4058
|
// Lets server-side order webhooks recover the browser visitor + Meta click
|
|
3685
4059
|
// signals (the postback webhook reads these as note_attributes). Inert unless
|
|
3686
4060
|
// enabled; best-effort and never blocks init.
|
|
3687
|
-
if (this.config.shopifyCartAttributes === true) {
|
|
4061
|
+
if (this.config.shopifyCartAttributes === true && this.shouldTrack()) {
|
|
3688
4062
|
this.syncShopifyCartAttributes().catch((error) => {
|
|
3689
4063
|
this.log('Shopify cart attribute sync failed:', error);
|
|
3690
4064
|
});
|
|
@@ -3692,7 +4066,9 @@ class Datalyr {
|
|
|
3692
4066
|
// Storefront → CC bridge: stamp _dl_* params on outbound links to the
|
|
3693
4067
|
// configured CC domains so visitor_id + click signals cross the domain
|
|
3694
4068
|
// boundary. Inert unless checkoutChampDomains is set.
|
|
3695
|
-
if (Array.isArray(this.config.checkoutChampDomains) && this.config.checkoutChampDomains.length > 0) {
|
|
4069
|
+
if (Array.isArray(this.config.checkoutChampDomains) && this.config.checkoutChampDomains.length > 0 && this.shouldTrack()) {
|
|
4070
|
+
// MED-4: gated on shouldTrack() (like syncShopifyCartAttributes) so an
|
|
4071
|
+
// opted-out / DNT / GPC visitor's id + click-ids aren't stamped on outbound links.
|
|
3696
4072
|
this.syncOutboundLinkParams(this.config.checkoutChampDomains);
|
|
3697
4073
|
}
|
|
3698
4074
|
// Track initial page view if enabled (AFTER encryption ready)
|
|
@@ -3777,51 +4153,56 @@ class Datalyr {
|
|
|
3777
4153
|
* so the mobile SDK can retrieve them deterministically after install.
|
|
3778
4154
|
*/
|
|
3779
4155
|
trackAppDownloadClick(options) {
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
if (!this.shouldTrack())
|
|
3785
|
-
return;
|
|
3786
|
-
// Fire the event with target platform info — attribution data is
|
|
3787
|
-
// automatically merged by createEventPayload via getAttributionData()
|
|
3788
|
-
this.track('$app_download_click', {
|
|
3789
|
-
target_platform: options.targetPlatform,
|
|
3790
|
-
app_store_url: options.appStoreUrl,
|
|
3791
|
-
});
|
|
3792
|
-
// Flush immediately via sendBeacon before page navigates away
|
|
3793
|
-
this.queue.forceFlush();
|
|
3794
|
-
// For Android: append referrer param to Play Store URL with click attribution
|
|
3795
|
-
if (options.targetPlatform === 'android' && options.appStoreUrl.includes('play.google.com')) {
|
|
3796
|
-
const lastTouch = this.attribution.getLastTouch();
|
|
3797
|
-
const referrerParams = new URLSearchParams();
|
|
3798
|
-
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickId)
|
|
3799
|
-
referrerParams.set('dl_click_id', lastTouch.clickId);
|
|
3800
|
-
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickIdType)
|
|
3801
|
-
referrerParams.set('dl_click_id_type', lastTouch.clickIdType);
|
|
3802
|
-
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.source)
|
|
3803
|
-
referrerParams.set('utm_source', lastTouch.source);
|
|
3804
|
-
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.medium)
|
|
3805
|
-
referrerParams.set('utm_medium', lastTouch.medium);
|
|
3806
|
-
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.campaign)
|
|
3807
|
-
referrerParams.set('utm_campaign', lastTouch.campaign);
|
|
3808
|
-
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.content)
|
|
3809
|
-
referrerParams.set('utm_content', lastTouch.content);
|
|
3810
|
-
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.term)
|
|
3811
|
-
referrerParams.set('utm_term', lastTouch.term);
|
|
3812
|
-
try {
|
|
3813
|
-
const url = new URL(options.appStoreUrl);
|
|
3814
|
-
url.searchParams.set('referrer', referrerParams.toString());
|
|
3815
|
-
window.location.href = url.toString();
|
|
4156
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
4157
|
+
if (!this.initialized) {
|
|
4158
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
4159
|
+
return;
|
|
3816
4160
|
}
|
|
3817
|
-
|
|
3818
|
-
|
|
4161
|
+
if (!this.shouldTrack())
|
|
4162
|
+
return;
|
|
4163
|
+
// Fire the event with target platform info — attribution data is
|
|
4164
|
+
// automatically merged by createEventPayload via getAttributionData()
|
|
4165
|
+
this.track('$app_download_click', {
|
|
4166
|
+
target_platform: options.targetPlatform,
|
|
4167
|
+
app_store_url: options.appStoreUrl,
|
|
4168
|
+
});
|
|
4169
|
+
// AWAIT the flush before navigating away (M5). sendBeacon dispatches synchronously,
|
|
4170
|
+
// but on browsers WITHOUT sendBeacon forceFlush falls back to a keepalive fetch —
|
|
4171
|
+
// and the synchronous window.location assignment below would tear the page down
|
|
4172
|
+
// before that fetch dispatched, losing the click event.
|
|
4173
|
+
yield this.queue.forceFlush();
|
|
4174
|
+
// For Android: append referrer param to Play Store URL with click attribution
|
|
4175
|
+
if (options.targetPlatform === 'android' && options.appStoreUrl.includes('play.google.com')) {
|
|
4176
|
+
const lastTouch = this.attribution.getLastTouch();
|
|
4177
|
+
const referrerParams = new URLSearchParams();
|
|
4178
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickId)
|
|
4179
|
+
referrerParams.set('dl_click_id', lastTouch.clickId);
|
|
4180
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.clickIdType)
|
|
4181
|
+
referrerParams.set('dl_click_id_type', lastTouch.clickIdType);
|
|
4182
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.source)
|
|
4183
|
+
referrerParams.set('utm_source', lastTouch.source);
|
|
4184
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.medium)
|
|
4185
|
+
referrerParams.set('utm_medium', lastTouch.medium);
|
|
4186
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.campaign)
|
|
4187
|
+
referrerParams.set('utm_campaign', lastTouch.campaign);
|
|
4188
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.content)
|
|
4189
|
+
referrerParams.set('utm_content', lastTouch.content);
|
|
4190
|
+
if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.term)
|
|
4191
|
+
referrerParams.set('utm_term', lastTouch.term);
|
|
4192
|
+
try {
|
|
4193
|
+
const url = new URL(options.appStoreUrl);
|
|
4194
|
+
url.searchParams.set('referrer', referrerParams.toString());
|
|
4195
|
+
window.location.href = url.toString();
|
|
4196
|
+
}
|
|
4197
|
+
catch (_a) {
|
|
4198
|
+
// If URL parsing fails, redirect without referrer
|
|
4199
|
+
window.location.href = options.appStoreUrl;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
else {
|
|
3819
4203
|
window.location.href = options.appStoreUrl;
|
|
3820
4204
|
}
|
|
3821
|
-
}
|
|
3822
|
-
else {
|
|
3823
|
-
window.location.href = options.appStoreUrl;
|
|
3824
|
-
}
|
|
4205
|
+
});
|
|
3825
4206
|
}
|
|
3826
4207
|
/**
|
|
3827
4208
|
* Identify a user
|
|
@@ -3886,6 +4267,17 @@ class Datalyr {
|
|
|
3886
4267
|
}
|
|
3887
4268
|
if (!this.shouldTrack())
|
|
3888
4269
|
return;
|
|
4270
|
+
// WEB-6: record one customer-journey touchpoint per session so touchpoint_count /
|
|
4271
|
+
// days_since_first_touch become real multi-touch signals (addTouchpoint had no
|
|
4272
|
+
// callers before, so the journey was always empty). Dedup against the PERSISTED
|
|
4273
|
+
// journey (not an in-memory field) so a hard page reload within the same session
|
|
4274
|
+
// doesn't append a duplicate touchpoint on a classic multi-page site.
|
|
4275
|
+
const sid = this.session.getSessionId();
|
|
4276
|
+
const journey = this.attribution.getJourney();
|
|
4277
|
+
const lastTouchpoint = journey.length ? journey[journey.length - 1] : null;
|
|
4278
|
+
if (!lastTouchpoint || lastTouchpoint.sessionId !== sid) {
|
|
4279
|
+
this.attribution.addTouchpoint(sid, this.attribution.captureAttribution());
|
|
4280
|
+
}
|
|
3889
4281
|
const pageData = Object.assign({ title: document.title, url: window.location.href, path: window.location.pathname, search: window.location.search, referrer: document.referrer }, properties);
|
|
3890
4282
|
// Add referrer data
|
|
3891
4283
|
const referrerData = getReferrerData();
|
|
@@ -3934,6 +4326,8 @@ class Datalyr {
|
|
|
3934
4326
|
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3935
4327
|
return;
|
|
3936
4328
|
}
|
|
4329
|
+
if (!this.shouldTrack())
|
|
4330
|
+
return;
|
|
3937
4331
|
this.track('$group', {
|
|
3938
4332
|
group_id: groupId,
|
|
3939
4333
|
traits
|
|
@@ -3947,6 +4341,13 @@ class Datalyr {
|
|
|
3947
4341
|
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3948
4342
|
return;
|
|
3949
4343
|
}
|
|
4344
|
+
if (!userId) {
|
|
4345
|
+
console.warn('[Datalyr] alias() requires a non-empty userId');
|
|
4346
|
+
return;
|
|
4347
|
+
}
|
|
4348
|
+
// Gate before mutating persisted identity (identity.alias writes dl_user_id).
|
|
4349
|
+
if (!this.shouldTrack())
|
|
4350
|
+
return;
|
|
3950
4351
|
const aliasData = this.identity.alias(userId, previousId);
|
|
3951
4352
|
this.track('$alias', aliasData);
|
|
3952
4353
|
}
|
|
@@ -3960,7 +4361,17 @@ class Datalyr {
|
|
|
3960
4361
|
}
|
|
3961
4362
|
this.identity.reset();
|
|
3962
4363
|
this.userProperties = {};
|
|
4364
|
+
// Clear super properties too — they'd otherwise keep attaching the previous user's
|
|
4365
|
+
// values to the next user's events (cross-user contamination on shared devices).
|
|
4366
|
+
this.superProperties = {};
|
|
3963
4367
|
storage.remove('dl_user_traits');
|
|
4368
|
+
// Clear the auto-identify guard so a different user on the same browser is
|
|
4369
|
+
// re-captured (it short-circuits while dl_auto_identified_email is present), and so
|
|
4370
|
+
// the prior user's email isn't left at rest after logout.
|
|
4371
|
+
storage.remove('dl_auto_identified_email');
|
|
4372
|
+
// Clear the journey too, or the next user's touchpoints append to the previous
|
|
4373
|
+
// user's (cross-user contamination of touchpoint_count / days_since_first_touch).
|
|
4374
|
+
storage.remove('dl_journey');
|
|
3964
4375
|
this.session.createNewSession();
|
|
3965
4376
|
this.log('User reset');
|
|
3966
4377
|
}
|
|
@@ -4071,19 +4482,44 @@ class Datalyr {
|
|
|
4071
4482
|
setAttribution(attribution) {
|
|
4072
4483
|
const current = this.attribution.captureAttribution();
|
|
4073
4484
|
const merged = Object.assign(Object.assign({}, current), attribution);
|
|
4485
|
+
// M6: actually make this affect events. Previously it only wrote a session key that
|
|
4486
|
+
// NOTHING in the event path reads, so setAttribution() was a silent no-op. Route it
|
|
4487
|
+
// through the AttributionManager so last_touch_* reflects it (and first_touch_* when
|
|
4488
|
+
// none is set yet — storeFirstTouch is immutable-unless-expired, so it won't clobber
|
|
4489
|
+
// an existing first touch).
|
|
4490
|
+
this.attribution.storeLastTouch(merged);
|
|
4491
|
+
this.attribution.storeFirstTouch(merged);
|
|
4074
4492
|
this.session.storeAttribution(merged);
|
|
4075
4493
|
}
|
|
4076
4494
|
/**
|
|
4077
4495
|
* Opt out of tracking
|
|
4078
4496
|
*/
|
|
4079
4497
|
optOut() {
|
|
4498
|
+
var _a;
|
|
4080
4499
|
if (!this.initialized) {
|
|
4081
4500
|
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
4082
4501
|
return;
|
|
4083
4502
|
}
|
|
4084
4503
|
this.optedOut = true;
|
|
4085
4504
|
this.cookies.set('__dl_opt_out', 'true', this.config.cookieExpires);
|
|
4505
|
+
// Stop the queue from sending OR draining anything persisted before opt-out, and
|
|
4506
|
+
// purge what's already buffered (the periodic/on-load drain has no other gate).
|
|
4507
|
+
this.queue.setEnabled(false);
|
|
4086
4508
|
this.queue.clear();
|
|
4509
|
+
this.queue.clearOffline();
|
|
4510
|
+
// Tear down auto-identify so it stops capturing email into storage post-opt-out.
|
|
4511
|
+
(_a = this.autoIdentify) === null || _a === void 0 ? void 0 : _a.destroy();
|
|
4512
|
+
this.autoIdentify = undefined;
|
|
4513
|
+
// Stop forwarding to (and drop) third-party pixels for this visitor.
|
|
4514
|
+
if (this.container) {
|
|
4515
|
+
this.container.cleanupAllIframes();
|
|
4516
|
+
this.container = undefined;
|
|
4517
|
+
}
|
|
4518
|
+
// Purge PII at rest.
|
|
4519
|
+
this.userProperties = {};
|
|
4520
|
+
storage.remove('dl_user_traits');
|
|
4521
|
+
storage.remove('dl_auto_identified_email');
|
|
4522
|
+
storage.remove('dl_journey');
|
|
4087
4523
|
this.log('User opted out');
|
|
4088
4524
|
}
|
|
4089
4525
|
/**
|
|
@@ -4096,6 +4532,10 @@ class Datalyr {
|
|
|
4096
4532
|
}
|
|
4097
4533
|
this.optedOut = false;
|
|
4098
4534
|
this.cookies.set('__dl_opt_out', 'false', this.config.cookieExpires);
|
|
4535
|
+
// Re-enable only if the full policy now allows (analytics consent + DNT/GPC), not
|
|
4536
|
+
// merely because opt-out was lifted — otherwise a GPC/DNT visitor's persisted
|
|
4537
|
+
// events would start draining again. (Pixels / auto-identify resume on next load.)
|
|
4538
|
+
this.queue.setEnabled(this.shouldTrack());
|
|
4099
4539
|
this.log('User opted in');
|
|
4100
4540
|
}
|
|
4101
4541
|
/**
|
|
@@ -4108,7 +4548,26 @@ class Datalyr {
|
|
|
4108
4548
|
* Set consent preferences
|
|
4109
4549
|
*/
|
|
4110
4550
|
setConsent(consent) {
|
|
4551
|
+
this.consent = consent;
|
|
4111
4552
|
storage.set('dl_consent', consent);
|
|
4553
|
+
// Enforce it live (not just persist it). Gate the queue by the full policy
|
|
4554
|
+
// (analytics consent + opt-out + DNT/GPC), and on withdrawal purge buffered events
|
|
4555
|
+
// too — mirroring optOut — so events captured before withdrawal can't drain if
|
|
4556
|
+
// consent is later re-granted.
|
|
4557
|
+
const allowed = this.shouldTrack();
|
|
4558
|
+
this.queue.setEnabled(allowed);
|
|
4559
|
+
if (!allowed) {
|
|
4560
|
+
this.queue.clear();
|
|
4561
|
+
this.queue.clearOffline();
|
|
4562
|
+
}
|
|
4563
|
+
// Marketing / "do not sell" withdrawal: stop FEEDING the third-party pixels and
|
|
4564
|
+
// prevent them from being initialized on the next page load. NOTE: an
|
|
4565
|
+
// already-injected pixel global (fbq/gtag/ttq) keeps running in the page — we
|
|
4566
|
+
// can't fully unload a third-party script mid-session; full removal is on reload.
|
|
4567
|
+
if (!this.consentAllowsMarketing() && this.container) {
|
|
4568
|
+
this.container.cleanupAllIframes();
|
|
4569
|
+
this.container = undefined;
|
|
4570
|
+
}
|
|
4112
4571
|
this.log('Consent updated:', consent);
|
|
4113
4572
|
}
|
|
4114
4573
|
/**
|
|
@@ -4474,18 +4933,21 @@ class Datalyr {
|
|
|
4474
4933
|
};
|
|
4475
4934
|
const observer = new MutationObserver(scheduleRestamp);
|
|
4476
4935
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
4477
|
-
// Observer + click listener live for the session; pagehide cleans up on
|
|
4478
|
-
//
|
|
4479
|
-
//
|
|
4480
|
-
|
|
4936
|
+
// Observer + click listener live for the session; pagehide cleans up on full-page
|
|
4937
|
+
// unload, and destroy() can tear them down early via this disposer (H2 — otherwise
|
|
4938
|
+
// a destroy()+re-init() leaks the observer + capturing click listener).
|
|
4939
|
+
this.outboundDisposer = () => {
|
|
4481
4940
|
try {
|
|
4482
4941
|
observer.disconnect();
|
|
4483
4942
|
}
|
|
4484
4943
|
catch ( /* idempotent */_a) { /* idempotent */ }
|
|
4485
|
-
if (restampTimer != null)
|
|
4944
|
+
if (restampTimer != null) {
|
|
4486
4945
|
clearTimeout(restampTimer);
|
|
4946
|
+
restampTimer = null;
|
|
4947
|
+
}
|
|
4487
4948
|
document.removeEventListener("click", onClick, true);
|
|
4488
|
-
}
|
|
4949
|
+
};
|
|
4950
|
+
window.addEventListener("pagehide", () => { var _a; return (_a = this.outboundDisposer) === null || _a === void 0 ? void 0 : _a.call(this); }, { once: true });
|
|
4489
4951
|
}
|
|
4490
4952
|
catch (error) {
|
|
4491
4953
|
this.log("MutationObserver setup failed (CC link sync):", error);
|
|
@@ -4496,6 +4958,12 @@ class Datalyr {
|
|
|
4496
4958
|
* Create event payload
|
|
4497
4959
|
*/
|
|
4498
4960
|
createEventPayload(eventName, properties, eventIdArg) {
|
|
4961
|
+
// NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
|
|
4962
|
+
// The session id changes on identify() (rotateSessionId) and on session timeout, but
|
|
4963
|
+
// identity only synced it at init/start — so the top-level payload.session_id (from
|
|
4964
|
+
// identity) disagreed with event_data.session_id (from session.getMetrics()). Sync
|
|
4965
|
+
// here, before reading either, so every event carries a single consistent session id.
|
|
4966
|
+
this.identity.setSessionId(this.session.getSessionId());
|
|
4499
4967
|
// Sanitize and merge properties
|
|
4500
4968
|
const sanitizedProperties = sanitizeEventData(properties);
|
|
4501
4969
|
const eventData = deepMerge({}, this.superProperties, sanitizedProperties);
|
|
@@ -4552,7 +5020,7 @@ class Datalyr {
|
|
|
4552
5020
|
resolution_method: 'browser_sdk',
|
|
4553
5021
|
resolution_confidence: 1.0,
|
|
4554
5022
|
// SDK metadata (keep in sync with package.json version)
|
|
4555
|
-
sdk_version: '1.
|
|
5023
|
+
sdk_version: '1.7.1',
|
|
4556
5024
|
sdk_name: 'datalyr-web-sdk'
|
|
4557
5025
|
};
|
|
4558
5026
|
return payload;
|
|
@@ -4565,6 +5033,10 @@ class Datalyr {
|
|
|
4565
5033
|
if (this.optedOut) {
|
|
4566
5034
|
return false;
|
|
4567
5035
|
}
|
|
5036
|
+
// Explicit analytics-consent withdrawal (setConsent) blocks first-party tracking.
|
|
5037
|
+
if (this.consent && this.consent.analytics === false) {
|
|
5038
|
+
return false;
|
|
5039
|
+
}
|
|
4568
5040
|
// Check Do Not Track
|
|
4569
5041
|
if (this.config.respectDoNotTrack && isDoNotTrackEnabled()) {
|
|
4570
5042
|
return false;
|
|
@@ -4575,6 +5047,14 @@ class Datalyr {
|
|
|
4575
5047
|
}
|
|
4576
5048
|
return true;
|
|
4577
5049
|
}
|
|
5050
|
+
/**
|
|
5051
|
+
* Whether marketing/third-party pixels are allowed. Withdrawing `marketing` or `sale`
|
|
5052
|
+
* (CCPA "do not sell") consent via setConsent() blocks loading the Meta/Google/TikTok
|
|
5053
|
+
* pixels (which share data). No consent set = allowed (default).
|
|
5054
|
+
*/
|
|
5055
|
+
consentAllowsMarketing() {
|
|
5056
|
+
return !this.consent || (this.consent.marketing !== false && this.consent.sale !== false);
|
|
5057
|
+
}
|
|
4578
5058
|
/**
|
|
4579
5059
|
* Setup SPA tracking
|
|
4580
5060
|
* Fixed Issue #15: Store original methods for cleanup
|
|
@@ -4585,56 +5065,55 @@ class Datalyr {
|
|
|
4585
5065
|
this.originalPushState = history.pushState;
|
|
4586
5066
|
this.originalReplaceState = history.replaceState;
|
|
4587
5067
|
const self = this;
|
|
5068
|
+
// Seed with the current URL so a router's replaceState-on-mount (same URL) doesn't
|
|
5069
|
+
// fire a duplicate pageview on top of the initial page() call. (M2/M3)
|
|
5070
|
+
this.lastSpaPath = window.location.pathname + window.location.search + window.location.hash;
|
|
4588
5071
|
// Override pushState
|
|
4589
5072
|
history.pushState = function (...args) {
|
|
4590
5073
|
self.originalPushState.apply(history, args);
|
|
4591
|
-
setTimeout(() =>
|
|
4592
|
-
// Clear attribution cache to capture fresh URL params
|
|
4593
|
-
self.attribution.clearCache();
|
|
4594
|
-
self.page();
|
|
4595
|
-
}, 0);
|
|
5074
|
+
setTimeout(() => self.handleSpaNavigation(), 0);
|
|
4596
5075
|
};
|
|
4597
5076
|
// Override replaceState
|
|
4598
5077
|
history.replaceState = function (...args) {
|
|
4599
5078
|
self.originalReplaceState.apply(history, args);
|
|
4600
|
-
setTimeout(() =>
|
|
4601
|
-
// Clear attribution cache to capture fresh URL params
|
|
4602
|
-
self.attribution.clearCache();
|
|
4603
|
-
self.page();
|
|
4604
|
-
}, 0);
|
|
5079
|
+
setTimeout(() => self.handleSpaNavigation(), 0);
|
|
4605
5080
|
};
|
|
4606
5081
|
// Listen for popstate
|
|
4607
|
-
this.popstateHandler = () => {
|
|
4608
|
-
setTimeout(() => {
|
|
4609
|
-
// Clear attribution cache to capture fresh URL params
|
|
4610
|
-
self.attribution.clearCache();
|
|
4611
|
-
self.page();
|
|
4612
|
-
}, 0);
|
|
4613
|
-
};
|
|
5082
|
+
this.popstateHandler = () => { setTimeout(() => self.handleSpaNavigation(), 0); };
|
|
4614
5083
|
window.addEventListener('popstate', this.popstateHandler);
|
|
4615
5084
|
// Listen for hashchange
|
|
4616
|
-
this.hashchangeHandler = () => {
|
|
4617
|
-
// Clear attribution cache to capture fresh URL params
|
|
4618
|
-
self.attribution.clearCache();
|
|
4619
|
-
self.page();
|
|
4620
|
-
};
|
|
5085
|
+
this.hashchangeHandler = () => { self.handleSpaNavigation(); };
|
|
4621
5086
|
window.addEventListener('hashchange', this.hashchangeHandler);
|
|
4622
5087
|
}
|
|
5088
|
+
/**
|
|
5089
|
+
* Handle an SPA virtual navigation. Dedups consecutive same-URL fires (M2/M3): many
|
|
5090
|
+
* routers call history.replaceState on mount/hydration, which previously fired a
|
|
5091
|
+
* SECOND `pageview` on top of the initial `page()`. Only fires when the full URL
|
|
5092
|
+
* (path+search+hash) actually changed.
|
|
5093
|
+
*/
|
|
5094
|
+
handleSpaNavigation() {
|
|
5095
|
+
const path = window.location.pathname + window.location.search + window.location.hash;
|
|
5096
|
+
if (path === this.lastSpaPath)
|
|
5097
|
+
return;
|
|
5098
|
+
this.lastSpaPath = path;
|
|
5099
|
+
this.attribution.clearCache();
|
|
5100
|
+
this.page();
|
|
5101
|
+
}
|
|
4623
5102
|
/**
|
|
4624
5103
|
* Setup page unload handler
|
|
4625
5104
|
*/
|
|
4626
5105
|
setupUnloadHandler() {
|
|
4627
|
-
//
|
|
4628
|
-
|
|
4629
|
-
|
|
5106
|
+
// Store refs so destroy() can remove these — otherwise a destroy()+re-init() cycle
|
|
5107
|
+
// leaks listeners that keep firing forceFlush() on the OLD (destroyed) queue. (H2)
|
|
5108
|
+
this.unloadHandler = () => { this.queue.forceFlush(); };
|
|
5109
|
+
this.visibilityHandler = () => {
|
|
5110
|
+
if (document.visibilityState === 'hidden')
|
|
5111
|
+
this.queue.forceFlush();
|
|
4630
5112
|
};
|
|
4631
|
-
|
|
4632
|
-
window.addEventListener('
|
|
4633
|
-
window.addEventListener('
|
|
4634
|
-
|
|
4635
|
-
handleUnload();
|
|
4636
|
-
}
|
|
4637
|
-
});
|
|
5113
|
+
// Use multiple events for maximum compatibility.
|
|
5114
|
+
window.addEventListener('beforeunload', this.unloadHandler);
|
|
5115
|
+
window.addEventListener('pagehide', this.unloadHandler);
|
|
5116
|
+
window.addEventListener('visibilitychange', this.visibilityHandler);
|
|
4638
5117
|
}
|
|
4639
5118
|
/**
|
|
4640
5119
|
* Get performance metrics
|
|
@@ -4739,6 +5218,22 @@ class Datalyr {
|
|
|
4739
5218
|
if (this.hashchangeHandler) {
|
|
4740
5219
|
window.removeEventListener('hashchange', this.hashchangeHandler);
|
|
4741
5220
|
}
|
|
5221
|
+
// H2: also remove the unload/visibility listeners and tear down the CC outbound-link
|
|
5222
|
+
// observer/listener — these were leaking across destroy()+re-init(), keeping the OLD
|
|
5223
|
+
// queue alive and firing on every navigation.
|
|
5224
|
+
if (this.unloadHandler) {
|
|
5225
|
+
window.removeEventListener('beforeunload', this.unloadHandler);
|
|
5226
|
+
window.removeEventListener('pagehide', this.unloadHandler);
|
|
5227
|
+
this.unloadHandler = undefined;
|
|
5228
|
+
}
|
|
5229
|
+
if (this.visibilityHandler) {
|
|
5230
|
+
window.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
5231
|
+
this.visibilityHandler = undefined;
|
|
5232
|
+
}
|
|
5233
|
+
if (this.outboundDisposer) {
|
|
5234
|
+
this.outboundDisposer();
|
|
5235
|
+
this.outboundDisposer = undefined;
|
|
5236
|
+
}
|
|
4742
5237
|
// Clean up queue
|
|
4743
5238
|
if (this.queue) {
|
|
4744
5239
|
this.queue.destroy();
|
|
@@ -4757,6 +5252,13 @@ class Datalyr {
|
|
|
4757
5252
|
}
|
|
4758
5253
|
// SEC-03 Fix: Clean up encryption keys
|
|
4759
5254
|
dataEncryption.destroy();
|
|
5255
|
+
// H3: reset init state so a subsequent init() fully re-runs. Without nulling the
|
|
5256
|
+
// initializationPromise, initializeAsync() early-returns the stale (resolved)
|
|
5257
|
+
// promise and the SDK comes back half-initialized (no container/pixels/SPA/pageview).
|
|
5258
|
+
this.initializationPromise = null;
|
|
5259
|
+
this.container = undefined;
|
|
5260
|
+
this.autoIdentify = undefined;
|
|
5261
|
+
this.lastSpaPath = null;
|
|
4760
5262
|
// Clear any remaining data
|
|
4761
5263
|
this.superProperties = {};
|
|
4762
5264
|
this.userProperties = {};
|