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