@datalyr/web 1.7.4 → 1.7.6
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/dist/attribution.d.ts +4 -0
- package/dist/attribution.d.ts.map +1 -1
- package/dist/datalyr.cjs.js +579 -256
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +579 -256
- 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 +579 -256
- package/dist/datalyr.js.map +1 -1
- package/dist/datalyr.min.js +1 -1
- package/dist/datalyr.min.js.map +1 -1
- package/dist/identity.d.ts +6 -4
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +48 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/queue.d.ts +15 -4
- package/dist/queue.d.ts.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +13 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.test.d.ts +2 -1
- package/dist/utils.test.d.ts.map +1 -1
- package/package.json +2 -1
package/dist/datalyr.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @datalyr/web v1.7.
|
|
2
|
+
* @datalyr/web v1.7.6
|
|
3
3
|
* Datalyr Web SDK - Modern attribution tracking for web applications
|
|
4
4
|
* (c) 2026 Datalyr Inc.
|
|
5
5
|
* Released under the MIT License
|
|
@@ -794,6 +794,125 @@ var Datalyr = (function (exports) {
|
|
|
794
794
|
}
|
|
795
795
|
return data;
|
|
796
796
|
}
|
|
797
|
+
// 9.A.4: query-param NAMES whose VALUES are secrets/PII — password-reset tokens,
|
|
798
|
+
// OAuth codes, magic-link sessions, emails/phones prefilled into URLs. Events ship
|
|
799
|
+
// `url` / `referrer` / `landingPage` with the FULL query string (sanitizeEventData
|
|
800
|
+
// only strips property KEYS, not URL param values), so /reset?token=…&email=… leaked
|
|
801
|
+
// verbatim into events and onward to ad platforms. Matched case-insensitively.
|
|
802
|
+
const REDACTED_URL_PARAMS = new Set([
|
|
803
|
+
'token', 'access_token', 'refresh_token', 'id_token', 'auth', 'authorization',
|
|
804
|
+
'password', 'pass', 'pwd', 'secret', 'api_key', 'apikey', 'key',
|
|
805
|
+
'session', 'session_id', 'sid', 'email', 'e', 'phone', 'tel',
|
|
806
|
+
'signature', 'sig', 'otp', 'reset', 'hash'
|
|
807
|
+
]);
|
|
808
|
+
// BATCH-2(e): `code` is CONDITIONAL, not in the always-redact set above. An OAuth
|
|
809
|
+
// authorization code (account-takeover-grade if it leaks to ad platforms) ALWAYS travels
|
|
810
|
+
// with an OAuth-flow marker in the same query/fragment — the response carries `code`+`state`
|
|
811
|
+
// (+`scope`/`session_state`/`iss`), the authorize request carries `client_id`/`redirect_uri`/
|
|
812
|
+
// `response_type`/`code_challenge`. A coupon/referral/product code (`?code=SUMMER20`) — a real
|
|
813
|
+
// attribution signal for the DTC/Shopify ICP — never does. So we redact `code` ONLY when a
|
|
814
|
+
// marker co-occurs in the same k=v string, preserving marketing codes. (A bare `code=` with no
|
|
815
|
+
// marker is syntactically indistinguishable from a coupon code and is kept; state-less OAuth
|
|
816
|
+
// flows are rare and CSRF-unsafe.) Markers are deliberately OAuth-specific to avoid redacting
|
|
817
|
+
// legitimate codes — `prompt`/`authuser` are excluded as too generic.
|
|
818
|
+
const OAUTH_CODE_CONTEXT_MARKERS = new Set([
|
|
819
|
+
'state', 'client_id', 'redirect_uri', 'response_type', 'grant_type',
|
|
820
|
+
'code_challenge', 'session_state', 'scope', 'iss',
|
|
821
|
+
// Token family: the presence of any bearer/OAuth token means a bare `code` in the same
|
|
822
|
+
// string is almost certainly an auth code, not a coupon — a marketing URL never carries
|
|
823
|
+
// these. (These values are independently redacted too; this only governs `code`.)
|
|
824
|
+
'access_token', 'id_token', 'refresh_token', 'token'
|
|
825
|
+
]);
|
|
826
|
+
const REDACTED_URL_VALUE = '__redacted__';
|
|
827
|
+
// Redact denylisted keys in one `k=v&k=v` string (query OR fragment). Returns the
|
|
828
|
+
// re-encoded string and whether anything was rewritten (so the caller can preserve the
|
|
829
|
+
// exact original bytes when nothing matched).
|
|
830
|
+
function redactKvPairs(s) {
|
|
831
|
+
const params = new URLSearchParams(s);
|
|
832
|
+
let mutated = false;
|
|
833
|
+
const keysLower = Array.from(new Set(params.keys())).map(k => k.toLowerCase());
|
|
834
|
+
// `code` co-occurrence check (BATCH-2e) — evaluated per-string, since an OAuth flow puts
|
|
835
|
+
// `code` and its marker in the SAME query (or the SAME fragment).
|
|
836
|
+
const hasOAuthMarker = keysLower.some(k => OAUTH_CODE_CONTEXT_MARKERS.has(k));
|
|
837
|
+
// Snapshot the keys first — set() collapses duplicate keys mid-iteration.
|
|
838
|
+
for (const key of Array.from(new Set(params.keys()))) {
|
|
839
|
+
const lower = key.toLowerCase();
|
|
840
|
+
const sensitive = lower === 'code'
|
|
841
|
+
? hasOAuthMarker
|
|
842
|
+
: REDACTED_URL_PARAMS.has(lower);
|
|
843
|
+
if (sensitive) {
|
|
844
|
+
params.set(key, REDACTED_URL_VALUE);
|
|
845
|
+
mutated = true;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return { out: params.toString(), mutated };
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Redact secret/PII param VALUES in a URL (9.A.4). The param NAME is kept
|
|
852
|
+
* (value → `__redacted__`) so funnel steps that match on the param's presence still
|
|
853
|
+
* work; every other param — click IDs (fbclid/gclid/…) and utm_* — survives untouched.
|
|
854
|
+
* Both the query string AND a k=v fragment are rewritten: TR-04 — OAuth-implicit /
|
|
855
|
+
* Supabase magic links carry the session in the FRAGMENT (`/welcome#access_token=eyJ…`),
|
|
856
|
+
* with no query string at all; the old code returned early on a missing `?` and reattached
|
|
857
|
+
* the fragment verbatim, leaking the full JWT into `url` / `landingPage` / `dl_first_touch`
|
|
858
|
+
* (90d) and onward to ad platforms. Non-k=v fragments (`#section`, `#/spa-route`) keep their
|
|
859
|
+
* shape. Returns the input unchanged when nothing sensitive matched or on any parse
|
|
860
|
+
* failure — redaction must never break tracking.
|
|
861
|
+
*/
|
|
862
|
+
function redactUrl(url) {
|
|
863
|
+
if (!url || typeof url !== 'string')
|
|
864
|
+
return url;
|
|
865
|
+
const hashStart = url.indexOf('#');
|
|
866
|
+
const qIdx = url.indexOf('?');
|
|
867
|
+
// A query string exists only when '?' appears BEFORE the fragment — a '?' after '#' is
|
|
868
|
+
// part of the fragment per the URL grammar (e.g. a SPA hash-route `#/reset?token=…`).
|
|
869
|
+
const queryStart = (qIdx !== -1 && (hashStart === -1 || qIdx < hashStart)) ? qIdx : -1;
|
|
870
|
+
if (queryStart === -1 && hashStart === -1)
|
|
871
|
+
return url; // nothing to redact
|
|
872
|
+
try {
|
|
873
|
+
const base = url.slice(0, queryStart !== -1 ? queryStart : (hashStart !== -1 ? hashStart : url.length));
|
|
874
|
+
const query = queryStart === -1 ? '' : url.slice(queryStart + 1, hashStart === -1 ? url.length : hashStart);
|
|
875
|
+
const rawFragment = hashStart === -1 ? '' : url.slice(hashStart + 1);
|
|
876
|
+
let mutated = false;
|
|
877
|
+
let queryOut = query;
|
|
878
|
+
if (query) {
|
|
879
|
+
const r = redactKvPairs(query);
|
|
880
|
+
if (r.mutated) {
|
|
881
|
+
queryOut = r.out;
|
|
882
|
+
mutated = true;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// TR-04: redact the fragment when it carries k=v pairs. Two shapes: a pure implicit-flow
|
|
886
|
+
// fragment (`#access_token=…&refresh_token=…`) and a SPA hash-route with its own query
|
|
887
|
+
// (`#/reset?access_token=…`). Leave non-k=v fragments (`#section`, `#/route`) untouched.
|
|
888
|
+
let fragmentOut = rawFragment;
|
|
889
|
+
if (rawFragment) {
|
|
890
|
+
const fragQ = rawFragment.indexOf('?');
|
|
891
|
+
if (fragQ !== -1) {
|
|
892
|
+
const r = redactKvPairs(rawFragment.slice(fragQ + 1));
|
|
893
|
+
if (r.mutated) {
|
|
894
|
+
fragmentOut = rawFragment.slice(0, fragQ + 1) + r.out;
|
|
895
|
+
mutated = true;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
else if (/[\w-]+=/.test(rawFragment)) {
|
|
899
|
+
const r = redactKvPairs(rawFragment);
|
|
900
|
+
if (r.mutated) {
|
|
901
|
+
fragmentOut = r.out;
|
|
902
|
+
mutated = true;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (!mutated)
|
|
907
|
+
return url; // nothing sensitive matched → ship the exact original bytes
|
|
908
|
+
return base
|
|
909
|
+
+ (queryStart === -1 ? '' : '?' + queryOut)
|
|
910
|
+
+ (hashStart === -1 ? '' : '#' + fragmentOut);
|
|
911
|
+
}
|
|
912
|
+
catch (_a) {
|
|
913
|
+
return url; // malformed URL → ship as-is rather than drop/break the event
|
|
914
|
+
}
|
|
915
|
+
}
|
|
797
916
|
/**
|
|
798
917
|
* Deep merge objects
|
|
799
918
|
*/
|
|
@@ -924,16 +1043,18 @@ var Datalyr = (function (exports) {
|
|
|
924
1043
|
return {};
|
|
925
1044
|
try {
|
|
926
1045
|
const url = new URL(referrer);
|
|
1046
|
+
// 9.A.4: the referrer can be our own /reset?token=… or an OAuth callback —
|
|
1047
|
+
// redact secret/PII param values before they're stamped onto the event.
|
|
927
1048
|
return {
|
|
928
|
-
referrer,
|
|
1049
|
+
referrer: redactUrl(referrer),
|
|
929
1050
|
referrer_host: url.hostname,
|
|
930
1051
|
referrer_path: url.pathname,
|
|
931
|
-
referrer_search: url.search,
|
|
1052
|
+
referrer_search: redactUrl(url.search),
|
|
932
1053
|
referrer_source: detectReferrerSource(url.hostname)
|
|
933
1054
|
};
|
|
934
1055
|
}
|
|
935
1056
|
catch (_a) {
|
|
936
|
-
return { referrer };
|
|
1057
|
+
return { referrer: redactUrl(referrer) };
|
|
937
1058
|
}
|
|
938
1059
|
}
|
|
939
1060
|
/**
|
|
@@ -994,17 +1115,13 @@ var Datalyr = (function (exports) {
|
|
|
994
1115
|
this.setRootDomainCookie('__dl_visitor_id', anonymousId);
|
|
995
1116
|
return anonymousId;
|
|
996
1117
|
}
|
|
997
|
-
// 2.
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
// it from the address bar so a re-shared URL can't merge the next visitor. (FSR-50)
|
|
1118
|
+
// 2. Raw URL visitor IDs are never identity authority. A syntactically
|
|
1119
|
+
// valid ID is still replayable/attacker-chosen and can merge two people.
|
|
1120
|
+
// Strip the legacy parameter and generate a fresh local identity below.
|
|
1001
1121
|
try {
|
|
1002
1122
|
const urlParams = new URLSearchParams(window.location.search);
|
|
1003
|
-
|
|
1004
|
-
if (urlVisitorId && this.isValidAnonymousId(urlVisitorId)) {
|
|
1123
|
+
if (urlParams.has('_dl_vid')) {
|
|
1005
1124
|
this.stripUrlParam('_dl_vid');
|
|
1006
|
-
this.persistAnonymousId(urlVisitorId);
|
|
1007
|
-
return urlVisitorId;
|
|
1008
1125
|
}
|
|
1009
1126
|
}
|
|
1010
1127
|
catch (e) {
|
|
@@ -1028,12 +1145,17 @@ var Datalyr = (function (exports) {
|
|
|
1028
1145
|
storage.set('dl_anonymous_id', id);
|
|
1029
1146
|
}
|
|
1030
1147
|
/**
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1148
|
+
* TR-15 / FSR-107: tracking became allowed mid-session (optIn / consent grant). Start
|
|
1149
|
+
* persisting AND flush the current in-memory anon id to the cookie + localStorage now.
|
|
1150
|
+
* Without this, a visitor declined at init keeps a memory-only id, so that session's
|
|
1151
|
+
* events land under a visitor_id that vanishes on the next page load (attribution
|
|
1152
|
+
* fragmentation). Idempotent — a no-op once already persisting.
|
|
1034
1153
|
*/
|
|
1035
|
-
|
|
1036
|
-
|
|
1154
|
+
enablePersistence() {
|
|
1155
|
+
if (this.persistNewId)
|
|
1156
|
+
return;
|
|
1157
|
+
this.persistNewId = true;
|
|
1158
|
+
this.persistAnonymousId(this.anonymousId);
|
|
1037
1159
|
}
|
|
1038
1160
|
/**
|
|
1039
1161
|
* Remove a query param from the current URL via history.replaceState (best-effort).
|
|
@@ -1524,6 +1646,10 @@ var Datalyr = (function (exports) {
|
|
|
1524
1646
|
this.CLICK_ID_ALIASES = {
|
|
1525
1647
|
ScCid: 'sclid',
|
|
1526
1648
|
sccid: 'sclid',
|
|
1649
|
+
// Impact's real landing param is `irclickid`; capture it and normalize to the canonical
|
|
1650
|
+
// `irclid` that ingest / the MV / the source map (`irclid→impact`) all key on. Without
|
|
1651
|
+
// this, real Impact affiliate clicks were dark on web (only bare `irclid` was captured).
|
|
1652
|
+
irclickid: 'irclid',
|
|
1527
1653
|
};
|
|
1528
1654
|
// Default tracked params matching dl.js
|
|
1529
1655
|
this.DEFAULT_TRACKED_PARAMS = [
|
|
@@ -1534,9 +1660,18 @@ var Datalyr = (function (exports) {
|
|
|
1534
1660
|
'medium', // Generic medium (non-UTM)
|
|
1535
1661
|
'gad_source' // Google Ads source parameter
|
|
1536
1662
|
];
|
|
1663
|
+
// TR-03: ad-cookie fields (Meta/Google Ads/TikTok/Snap) that are MARKETING-scoped. When
|
|
1664
|
+
// marketing consent is declined these are neither synthesized/written nor shipped. Google
|
|
1665
|
+
// Analytics cookies (_ga/_gid) and utm_* stay — they're analytics-scoped.
|
|
1666
|
+
this.MARKETING_COOKIES = ['_fbp', '_fbc', '_gcl_aw', '_gcl_dc', '_gcl_gb', '_gcl_ha', '_gac', '_ttp', '_ttc', '_scid'];
|
|
1537
1667
|
this.attributionWindow = options.attributionWindow || 90 * 24 * 60 * 60 * 1000; // 90 days (increased from 30 for B2B sales cycles)
|
|
1538
1668
|
// Merge default tracked params with user-provided ones
|
|
1539
1669
|
this.trackedParams = [...this.DEFAULT_TRACKED_PARAMS, ...(options.trackedParams || [])];
|
|
1670
|
+
this.marketingAllowedFn = options.marketingAllowed;
|
|
1671
|
+
}
|
|
1672
|
+
// TR-03: default (no predicate / no signal) = allowed → byte-identical to prior behavior.
|
|
1673
|
+
isMarketingAllowed() {
|
|
1674
|
+
return this.marketingAllowedFn ? this.marketingAllowedFn() !== false : true;
|
|
1540
1675
|
}
|
|
1541
1676
|
/**
|
|
1542
1677
|
* Clear query params cache (called on page navigation)
|
|
@@ -1607,13 +1742,15 @@ var Datalyr = (function (exports) {
|
|
|
1607
1742
|
attribution[param] = value;
|
|
1608
1743
|
}
|
|
1609
1744
|
}
|
|
1610
|
-
// Capture referrer
|
|
1745
|
+
// Capture referrer. 9.A.4: redact secret/PII query-param values (reset tokens,
|
|
1746
|
+
// OAuth codes, emails) — this attribution object is stamped onto every event AND
|
|
1747
|
+
// persisted into dl_first_touch/dl_last_touch, so it must be clean at capture.
|
|
1611
1748
|
if (document.referrer) {
|
|
1612
|
-
attribution.referrer = document.referrer;
|
|
1749
|
+
attribution.referrer = redactUrl(document.referrer);
|
|
1613
1750
|
attribution.referrerHost = this.extractHostname(document.referrer);
|
|
1614
1751
|
}
|
|
1615
|
-
// Capture landing page
|
|
1616
|
-
attribution.landingPage = window.location.href;
|
|
1752
|
+
// Capture landing page (redacted — see referrer note above)
|
|
1753
|
+
attribution.landingPage = redactUrl(window.location.href);
|
|
1617
1754
|
attribution.landingPath = window.location.pathname;
|
|
1618
1755
|
// Determine source if not explicitly set
|
|
1619
1756
|
if (!attribution.source) {
|
|
@@ -1709,6 +1846,10 @@ var Datalyr = (function (exports) {
|
|
|
1709
1846
|
captureAdCookies() {
|
|
1710
1847
|
var _a, _b;
|
|
1711
1848
|
const adCookies = {};
|
|
1849
|
+
// TR-03: when marketing consent is declined, do NOT synthesize/write the Meta/Google ad
|
|
1850
|
+
// cookies below (writing _fbp/_fbc is active marketing use). Existing cookies are still
|
|
1851
|
+
// read here but stripped from the event payload in getAttributionData().
|
|
1852
|
+
const marketingAllowed = this.isMarketingAllowed();
|
|
1712
1853
|
// Facebook/Meta cookies
|
|
1713
1854
|
adCookies._fbp = cookies.get('_fbp');
|
|
1714
1855
|
adCookies._fbc = cookies.get('_fbc');
|
|
@@ -1727,8 +1868,8 @@ var Datalyr = (function (exports) {
|
|
|
1727
1868
|
// Snapchat first-party cookie (Snap Pixel sets _scid). Flows to the server as
|
|
1728
1869
|
// cookies._scid and is sent on the Snap CAPI event as sc_cookie1 (raw).
|
|
1729
1870
|
adCookies._scid = cookies.get('_scid');
|
|
1730
|
-
// Generate _fbp if missing (Facebook browser ID)
|
|
1731
|
-
if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
|
|
1871
|
+
// Generate _fbp if missing (Facebook browser ID). TR-03: only when marketing is allowed.
|
|
1872
|
+
if (marketingAllowed && !adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
|
|
1732
1873
|
const timestamp = Date.now();
|
|
1733
1874
|
// Meta's _fbp format is fb.1.<creationTimeMs>.<randomNumber> where the last
|
|
1734
1875
|
// segment MUST be a decimal integer. The old base36 string was non-conformant —
|
|
@@ -1752,7 +1893,8 @@ var Datalyr = (function (exports) {
|
|
|
1752
1893
|
// Shopify cart / server-side rebuild forward it as a time, so its format must not
|
|
1753
1894
|
// change.
|
|
1754
1895
|
const fbclid = this.getCurrentFbclid();
|
|
1755
|
-
|
|
1896
|
+
// TR-03: skip _fbc synthesis/refresh (cookie write) when marketing is declined.
|
|
1897
|
+
if (marketingAllowed && fbclid) {
|
|
1756
1898
|
const existingFbc = adCookies._fbc; // captured above (cookies.get('_fbc'))
|
|
1757
1899
|
const embeddedFbclid = this.extractFbclidFromFbc(existingFbc);
|
|
1758
1900
|
if (!existingFbc) {
|
|
@@ -1782,7 +1924,7 @@ var Datalyr = (function (exports) {
|
|
|
1782
1924
|
// (gclid has no synthesized-and-going-stale artifact like _fbc, and Google does not
|
|
1783
1925
|
// validate a creationTime window the way Meta does, so this stays once-only.)
|
|
1784
1926
|
const gclid = this.hasClickId('gclid') ? ((_b = (_a = this.queryParamsCache) === null || _a === void 0 ? void 0 : _a.gclid) !== null && _b !== void 0 ? _b : null) : null;
|
|
1785
|
-
if (gclid && !cookies.get('_dl_gclid_at')) {
|
|
1927
|
+
if (marketingAllowed && gclid && !cookies.get('_dl_gclid_at')) {
|
|
1786
1928
|
cookies.set('_dl_gclid_at', String(Date.now()), 90);
|
|
1787
1929
|
}
|
|
1788
1930
|
// Filter out null values for cleaner data
|
|
@@ -1860,7 +2002,7 @@ var Datalyr = (function (exports) {
|
|
|
1860
2002
|
if (hasRealAttribution) {
|
|
1861
2003
|
this.storeLastTouch(current);
|
|
1862
2004
|
}
|
|
1863
|
-
|
|
2005
|
+
const result = Object.assign(Object.assign(Object.assign({}, current), adCookies), {
|
|
1864
2006
|
// First touch (with snake_case aliases)
|
|
1865
2007
|
first_touch_source: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.source, first_touch_medium: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.medium, first_touch_campaign: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.campaign, first_touch_timestamp: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp, firstTouchSource: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.source, firstTouchMedium: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.medium, firstTouchCampaign: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.campaign,
|
|
1866
2008
|
// Last touch (with snake_case aliases)
|
|
@@ -1871,6 +2013,21 @@ var Datalyr = (function (exports) {
|
|
|
1871
2013
|
: 0, daysSinceFirstTouch: (firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp)
|
|
1872
2014
|
? Math.floor((Date.now() - firstTouch.timestamp) / 86400000)
|
|
1873
2015
|
: 0 });
|
|
2016
|
+
// TR-03: strip MARKETING-scoped signals (click IDs + ad cookies) from the event payload
|
|
2017
|
+
// when marketing consent is declined — analytics-scoped fields (utm_*, source/medium/
|
|
2018
|
+
// campaign, first/last touch, _ga/_gid) stay. Live predicate → a later grant restores
|
|
2019
|
+
// them on the next event. Synthesis of _fbp/_fbc was already skipped in captureAdCookies.
|
|
2020
|
+
if (!this.isMarketingAllowed()) {
|
|
2021
|
+
const marketingKeys = [
|
|
2022
|
+
...this.CLICK_IDS,
|
|
2023
|
+
...Object.values(this.CLICK_ID_ALIASES),
|
|
2024
|
+
'clickId', 'clickIdType',
|
|
2025
|
+
...this.MARKETING_COOKIES,
|
|
2026
|
+
];
|
|
2027
|
+
for (const k of marketingKeys)
|
|
2028
|
+
delete result[k];
|
|
2029
|
+
}
|
|
2030
|
+
return result;
|
|
1874
2031
|
}
|
|
1875
2032
|
/**
|
|
1876
2033
|
* Determine source from attribution data
|
|
@@ -1881,13 +2038,19 @@ var Datalyr = (function (exports) {
|
|
|
1881
2038
|
const clickIdSources = {
|
|
1882
2039
|
fbclid: 'facebook',
|
|
1883
2040
|
gclid: 'google',
|
|
2041
|
+
gbraid: 'google', // 9.A.6: Google Ads (iOS) — was falling through to 'paid'
|
|
2042
|
+
wbraid: 'google', // 9.A.6: Google Ads (web)
|
|
1884
2043
|
ttclid: 'tiktok',
|
|
1885
2044
|
msclkid: 'bing',
|
|
1886
2045
|
twclid: 'twitter',
|
|
1887
2046
|
li_fat_id: 'linkedin',
|
|
1888
2047
|
sclid: 'snapchat',
|
|
1889
2048
|
dclid: 'doubleclick',
|
|
1890
|
-
epik: 'pinterest'
|
|
2049
|
+
epik: 'pinterest',
|
|
2050
|
+
rdt_cid: 'reddit', // 9.A.6
|
|
2051
|
+
obclid: 'outbrain', // 9.A.6
|
|
2052
|
+
irclid: 'impact', // 9.A.6: Impact Radius
|
|
2053
|
+
ko_click_id: 'klaviyo' // 9.A.6
|
|
1891
2054
|
};
|
|
1892
2055
|
return clickIdSources[attribution.clickIdType] || 'paid';
|
|
1893
2056
|
}
|
|
@@ -2301,16 +2464,22 @@ var Datalyr = (function (exports) {
|
|
|
2301
2464
|
// so only request it when the body is safely under the cap; oversized batches go via
|
|
2302
2465
|
// a normal fetch (no cap) and succeed.
|
|
2303
2466
|
const useKeepalive = new Blob([body]).size <= 60000;
|
|
2467
|
+
// First-party endpoint (the customer's own tracking host, never *.datalyr.com — set
|
|
2468
|
+
// by the script-origin auto-derive): send the cookie cross-subdomain so the edge can
|
|
2469
|
+
// read/refresh the server-set, ITP-durable visitor_id. Legacy datalyr.com endpoints
|
|
2470
|
+
// stay credential-less — unchanged behavior for every existing install.
|
|
2471
|
+
let firstPartyEndpoint = false;
|
|
2304
2472
|
try {
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2473
|
+
firstPartyEndpoint = !/(^|\.)datalyr\.com$/i.test(new URL(currentEndpoint).hostname);
|
|
2474
|
+
}
|
|
2475
|
+
catch (_a) {
|
|
2476
|
+
/* malformed endpoint — leave false */
|
|
2477
|
+
}
|
|
2478
|
+
try {
|
|
2479
|
+
const response = yield fetch(currentEndpoint, Object.assign({ method: 'POST', headers: {
|
|
2308
2480
|
'Content-Type': 'application/json',
|
|
2309
2481
|
'X-Batch-Size': events.length.toString()
|
|
2310
|
-
},
|
|
2311
|
-
body,
|
|
2312
|
-
keepalive: useKeepalive
|
|
2313
|
-
});
|
|
2482
|
+
}, body, keepalive: useKeepalive }, (firstPartyEndpoint ? { credentials: 'include' } : {})));
|
|
2314
2483
|
if (!response.ok) {
|
|
2315
2484
|
// Handle rate limiting
|
|
2316
2485
|
if (response.status === 429) {
|
|
@@ -2507,6 +2676,16 @@ var Datalyr = (function (exports) {
|
|
|
2507
2676
|
// two of them could splice the same offlineQueue concurrently and double-send.
|
|
2508
2677
|
if (!this.enabled)
|
|
2509
2678
|
return;
|
|
2679
|
+
// TR-15: re-check consent at DRAIN time. A returning DECLINED visitor whose consent
|
|
2680
|
+
// signal loads asynchronously (Shopify customerPrivacy) fires no visitorConsentCollected
|
|
2681
|
+
// event, so `enabled` stayed at its fail-open init value. If consent now says no, latch
|
|
2682
|
+
// off and PURGE the persisted backlog instead of draining it (a later grant re-enables
|
|
2683
|
+
// via setEnabled). Mirrors the withdrawal purge in optOut()/setConsent().
|
|
2684
|
+
if (this.consentCheck && this.consentCheck() === false) {
|
|
2685
|
+
this.enabled = false;
|
|
2686
|
+
this.clearOffline();
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2510
2689
|
if (Date.now() < this.rateLimitedUntil)
|
|
2511
2690
|
return; // FIXED (429): honor Retry-After window
|
|
2512
2691
|
if (this.offlineProcessing)
|
|
@@ -2598,10 +2777,13 @@ var Datalyr = (function (exports) {
|
|
|
2598
2777
|
* - Non-terminal (tab switch): deliver the LIVE queue via a response-checked keepalive
|
|
2599
2778
|
* fetch (flush) and response-check-drain the offline backlog (processOfflineQueue) —
|
|
2600
2779
|
* neither erases the backlog on failure. The destructive beacon path is not used.
|
|
2601
|
-
* - Terminal (unload):
|
|
2602
|
-
* and NEVER storage.remove() on a mere beacon enqueue.
|
|
2603
|
-
*
|
|
2604
|
-
*
|
|
2780
|
+
* - Terminal (unload): PERSIST everything first (live + the already-persisted offline
|
|
2781
|
+
* backlog) and NEVER storage.remove() on a mere beacon enqueue. Beacon ONLY the LIVE
|
|
2782
|
+
* queue best-effort — NOT the offline backlog (TR-13): the backlog is already durable and
|
|
2783
|
+
* the next-load drain delivers it exactly once, so beaconing it here too risked a
|
|
2784
|
+
* delivered-now-AND-re-drained-next-load double-send that, when >6h apart, outlives
|
|
2785
|
+
* ingest's dedup window → a duplicate purchase. The next page load's response-checked
|
|
2786
|
+
* drain (normal fetch, no 64KB cap) is the source of truth and clears the copy.
|
|
2605
2787
|
* - Both paths honor rateLimitedUntil (don't beacon/flush into the backoff window).
|
|
2606
2788
|
*
|
|
2607
2789
|
* Still excludes the in-flight _flush batch (its keepalive fetch already carries it,
|
|
@@ -2643,6 +2825,14 @@ var Datalyr = (function (exports) {
|
|
|
2643
2825
|
}
|
|
2644
2826
|
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2645
2827
|
}
|
|
2828
|
+
// TR-13: beacon ONLY the LIVE queue (never-persisted events from THIS session). The
|
|
2829
|
+
// offline backlog is already durable and the next-load drain delivers it once, so
|
|
2830
|
+
// beaconing it here would double-send it — and a stale backlog event (e.g. a purchase
|
|
2831
|
+
// parked during an outage) re-drained on a later day lands >6h from the beacon, past
|
|
2832
|
+
// ingest's dedup window. The live queue's beacon + its own next-load drain both land THIS
|
|
2833
|
+
// session (<6h), so dedup absorbs that pair.
|
|
2834
|
+
if (live.length === 0)
|
|
2835
|
+
return; // only the persisted backlog remained → it drains next load
|
|
2646
2836
|
// Within the 429 window, don't beacon into it — the persisted copy drains next load.
|
|
2647
2837
|
if (Date.now() < this.rateLimitedUntil)
|
|
2648
2838
|
return;
|
|
@@ -2653,7 +2843,7 @@ var Datalyr = (function (exports) {
|
|
|
2653
2843
|
const chunks = [];
|
|
2654
2844
|
let current = [];
|
|
2655
2845
|
let currentBytes = 2; // approx for the JSON array/object wrapper
|
|
2656
|
-
for (const ev of
|
|
2846
|
+
for (const ev of live) {
|
|
2657
2847
|
// Byte length (not UTF-16 .length) so multibyte product names / emoji can't
|
|
2658
2848
|
// under-count and overflow the cap.
|
|
2659
2849
|
const evBytes = new Blob([JSON.stringify(ev)]).size + 1;
|
|
@@ -2679,7 +2869,7 @@ var Datalyr = (function (exports) {
|
|
|
2679
2869
|
break;
|
|
2680
2870
|
beaconed += chunk.length;
|
|
2681
2871
|
}
|
|
2682
|
-
this.log(`forceFlush(terminal): beaconed ${beaconed}/${
|
|
2872
|
+
this.log(`forceFlush(terminal): beaconed ${beaconed}/${live.length} live events; backlog (${pending.length - live.length}) persisted for next-load drain`);
|
|
2683
2873
|
});
|
|
2684
2874
|
}
|
|
2685
2875
|
/**
|
|
@@ -2700,6 +2890,15 @@ var Datalyr = (function (exports) {
|
|
|
2700
2890
|
setEnabled(enabled) {
|
|
2701
2891
|
this.enabled = enabled;
|
|
2702
2892
|
}
|
|
2893
|
+
/**
|
|
2894
|
+
* TR-15: register a live consent predicate the offline drain re-evaluates at drain time.
|
|
2895
|
+
* `enabled` is latched at init to a fail-open value (Shopify's customerPrivacy loads
|
|
2896
|
+
* async), and a returning DECLINED visitor fires no visitorConsentCollected event — so
|
|
2897
|
+
* without this, their persisted backlog would drain before the decline is known.
|
|
2898
|
+
*/
|
|
2899
|
+
setConsentCheck(fn) {
|
|
2900
|
+
this.consentCheck = fn;
|
|
2901
|
+
}
|
|
2703
2902
|
/**
|
|
2704
2903
|
* Clear the offline queue and its persisted copy (used by opt-out to purge any
|
|
2705
2904
|
* PII-bearing events that were parked before the user opted out).
|
|
@@ -4292,7 +4491,11 @@ var Datalyr = (function (exports) {
|
|
|
4292
4491
|
this.session = new SessionManager(this.config.sessionTimeout);
|
|
4293
4492
|
this.attribution = new AttributionManager({
|
|
4294
4493
|
attributionWindow: this.config.attributionWindow,
|
|
4295
|
-
trackedParams: this.config.trackedParams
|
|
4494
|
+
trackedParams: this.config.trackedParams,
|
|
4495
|
+
// TR-03: gate ad-signal synthesis + shipping on LIVE marketing consent. Returns true by
|
|
4496
|
+
// default (no consent signal) so behavior is unchanged for the common case; false only on
|
|
4497
|
+
// an explicit decline (Shopify marketing:false / setConsent marketing|sale=false).
|
|
4498
|
+
marketingAllowed: () => this.consentAllowsMarketing()
|
|
4296
4499
|
});
|
|
4297
4500
|
this.queue = new EventQueue(this.config);
|
|
4298
4501
|
this.fingerprint = new FingerprintCollector({
|
|
@@ -4305,11 +4508,18 @@ var Datalyr = (function (exports) {
|
|
|
4305
4508
|
// Gate the queue by the FULL tracking policy (opt-out + analytics consent + DNT +
|
|
4306
4509
|
// GPC) so a returning opted-out / DNT / GPC visitor's persisted events don't drain.
|
|
4307
4510
|
this.queue.setEnabled(this.shouldTrack());
|
|
4511
|
+
// TR-15: also give the offline drain a LIVE consent gate. setEnabled above is latched to
|
|
4512
|
+
// a fail-open value while Shopify's customerPrivacy loads async; a returning declined
|
|
4513
|
+
// visitor fires no visitorConsentCollected event, so the drain must re-check per run.
|
|
4514
|
+
this.queue.setConsentCheck(() => this.shouldTrack());
|
|
4308
4515
|
// FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
|
|
4309
4516
|
// This allows encryption to initialize before any events are tracked
|
|
4310
4517
|
this.initializeAsync();
|
|
4311
4518
|
// Setup page unload handler
|
|
4312
4519
|
this.setupUnloadHandler();
|
|
4520
|
+
// Shopify Customer Privacy (9.A.1): react to the native consent banner's
|
|
4521
|
+
// decision mid-session (grant AND revoke), not just at the next page load.
|
|
4522
|
+
this.setupShopifyConsentListener();
|
|
4313
4523
|
// Initialize plugins
|
|
4314
4524
|
if (this.config.plugins) {
|
|
4315
4525
|
for (const plugin of this.config.plugins) {
|
|
@@ -4341,6 +4551,27 @@ var Datalyr = (function (exports) {
|
|
|
4341
4551
|
this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
|
|
4342
4552
|
var _a, _b;
|
|
4343
4553
|
try {
|
|
4554
|
+
// TR-22: capture the LANDING attribution NOW, before any await. The first read used to
|
|
4555
|
+
// sit inside this.page() AFTER `await container.init()` (up to a ~3s budget), so a
|
|
4556
|
+
// router / consent tool that strips fbclid via history.replaceState within that window
|
|
4557
|
+
// — or a fast bounce — lost the click id entirely (nothing persisted to first/last
|
|
4558
|
+
// touch until the first event). getAttributionData() warms queryParamsCache (freezing
|
|
4559
|
+
// the landing params) AND persists first/last touch immediately. Wrapped so a failure
|
|
4560
|
+
// never blocks init.
|
|
4561
|
+
// CONSENT (Track-3 review P1): gate the eager capture behind shouldTrack(). This call
|
|
4562
|
+
// synthesizes _fbp/_fbc and persists first/last touch — which must NOT happen at page
|
|
4563
|
+
// load for an opted-out / DNT / GPC visitor (shouldTrack()=false), or it undoes the
|
|
4564
|
+
// id-less-at-init privacy invariant. For a TRACKED visitor with marketing declined,
|
|
4565
|
+
// getAttributionData already strips the click-ids / marketing cookies (TR-03), so TR-22's
|
|
4566
|
+
// early-capture benefit is preserved for consenting/default visitors.
|
|
4567
|
+
if (this.shouldTrack()) {
|
|
4568
|
+
try {
|
|
4569
|
+
this.attribution.getAttributionData();
|
|
4570
|
+
}
|
|
4571
|
+
catch (e) {
|
|
4572
|
+
this.log('Eager attribution capture failed:', e);
|
|
4573
|
+
}
|
|
4574
|
+
}
|
|
4344
4575
|
// SEC-03: encryption is for PII-AT-REST only — it must NOT gate event delivery,
|
|
4345
4576
|
// pageviews, or pixels. On a non-secure context (http://) or an old browser,
|
|
4346
4577
|
// crypto.subtle is absent and initialize() throws; isolate it so the rest of init
|
|
@@ -4423,7 +4654,10 @@ var Datalyr = (function (exports) {
|
|
|
4423
4654
|
// Initialize auto-identify when enabled (explicit or remote) AND
|
|
4424
4655
|
// tracking is allowed. The shouldTrack() gate keeps capture from even
|
|
4425
4656
|
// setting up its form/API interceptors for opted-out / DNT / GPC users.
|
|
4426
|
-
|
|
4657
|
+
// 9.A.1: the captured email feeds Meta advanced matching / CAPI, so a
|
|
4658
|
+
// declined Shopify marketing consent also blocks setup (incl. the
|
|
4659
|
+
// /account.json polling); null = no Shopify signal → unchanged.
|
|
4660
|
+
if (this.config.autoIdentify === true && this.shouldTrack() && this.shopifyMarketingConsent() !== false) {
|
|
4427
4661
|
this.autoIdentify = new AutoIdentifyManager({
|
|
4428
4662
|
enabled: true,
|
|
4429
4663
|
captureFromForms: this.config.autoIdentifyForms,
|
|
@@ -4448,8 +4682,10 @@ var Datalyr = (function (exports) {
|
|
|
4448
4682
|
// Stamp attribution signals into the Shopify cart (OPT-IN, default off).
|
|
4449
4683
|
// Lets server-side order webhooks recover the browser visitor + Meta click
|
|
4450
4684
|
// signals (the postback webhook reads these as note_attributes). Inert unless
|
|
4451
|
-
// enabled; best-effort and never blocks init.
|
|
4452
|
-
|
|
4685
|
+
// enabled; best-effort and never blocks init. 9.A.1: the stamped visitor_id +
|
|
4686
|
+
// fbc/fbp/fbclid are marketing signals, so a declined Shopify marketing
|
|
4687
|
+
// consent blocks stamping too (null = no signal → unchanged).
|
|
4688
|
+
if (this.config.shopifyCartAttributes === true && this.shouldTrack() && this.shopifyMarketingConsent() !== false) {
|
|
4453
4689
|
this.syncShopifyCartAttributes().catch((error) => {
|
|
4454
4690
|
this.log('Shopify cart attribute sync failed:', error);
|
|
4455
4691
|
});
|
|
@@ -4619,6 +4855,13 @@ var Datalyr = (function (exports) {
|
|
|
4619
4855
|
return;
|
|
4620
4856
|
}
|
|
4621
4857
|
try {
|
|
4858
|
+
// A different authenticated user on the same device is a privacy
|
|
4859
|
+
// boundary. Rotate the anonymous/session identities and clear the prior
|
|
4860
|
+
// user's traits/attribution before creating the new link, even when the
|
|
4861
|
+
// integrator forgot to call reset() on logout.
|
|
4862
|
+
const currentUserId = this.identity.getUserId();
|
|
4863
|
+
if (currentUserId && currentUserId !== userId)
|
|
4864
|
+
this.reset();
|
|
4622
4865
|
// FSR-53: do NOT rotate the session id on identify(). 'Session fixation' is an
|
|
4623
4866
|
// auth-credential concept; a client-generated analytics session_id is not one.
|
|
4624
4867
|
// Rotating split every identified visit (and every auto-identify form submit) into
|
|
@@ -4681,7 +4924,9 @@ var Datalyr = (function (exports) {
|
|
|
4681
4924
|
if (!lastTouchpoint || lastTouchpoint.sessionId !== sid) {
|
|
4682
4925
|
this.attribution.addTouchpoint(sid, this.attribution.captureAttribution());
|
|
4683
4926
|
}
|
|
4684
|
-
|
|
4927
|
+
// 9.A.4: url/search/referrer are redacted (secret/PII query-param values →
|
|
4928
|
+
// __redacted__) before they ever leave the browser — see redactUrl in utils.
|
|
4929
|
+
const pageData = Object.assign({ title: document.title, url: redactUrl(window.location.href), path: window.location.pathname, search: redactUrl(window.location.search), referrer: redactUrl(document.referrer) }, properties);
|
|
4685
4930
|
// Add referrer data
|
|
4686
4931
|
const referrerData = getReferrerData();
|
|
4687
4932
|
Object.assign(pageData, referrerData);
|
|
@@ -4751,6 +4996,13 @@ var Datalyr = (function (exports) {
|
|
|
4751
4996
|
// Gate before mutating persisted identity (identity.alias writes dl_user_id).
|
|
4752
4997
|
if (!this.shouldTrack())
|
|
4753
4998
|
return;
|
|
4999
|
+
if (previousId && previousId !== this.identity.getAnonymousId()) {
|
|
5000
|
+
console.warn('[Datalyr] alias() only accepts the current anonymous ID as previousId');
|
|
5001
|
+
return;
|
|
5002
|
+
}
|
|
5003
|
+
const currentUserId = this.identity.getUserId();
|
|
5004
|
+
if (currentUserId && currentUserId !== userId)
|
|
5005
|
+
this.reset();
|
|
4754
5006
|
const aliasData = this.identity.alias(userId, previousId);
|
|
4755
5007
|
this.track('$alias', aliasData);
|
|
4756
5008
|
}
|
|
@@ -4906,6 +5158,27 @@ var Datalyr = (function (exports) {
|
|
|
4906
5158
|
/**
|
|
4907
5159
|
* Opt out of tracking
|
|
4908
5160
|
*/
|
|
5161
|
+
/**
|
|
5162
|
+
* X-1 (consent): stop the Stripe Payment Link + CheckoutChamp outbound-link decorators from
|
|
5163
|
+
* stamping `client_reference_id`/`prefilled_email` onto <a> hrefs once consent/marketing is
|
|
5164
|
+
* withdrawn. Their MutationObservers otherwise keep rewriting links after a CMP or Shopify
|
|
5165
|
+
* decline — and the server still reads the stamped `client_reference_id` — so attribution
|
|
5166
|
+
* continues post-withdrawal. Idempotent; decorators re-init on the next load if consent returns
|
|
5167
|
+
* (same convention as pixels/auto-identify).
|
|
5168
|
+
*/
|
|
5169
|
+
disposeMarketingLinkDecorators() {
|
|
5170
|
+
var _a, _b;
|
|
5171
|
+
try {
|
|
5172
|
+
(_a = this.stripeLinksDisposer) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
5173
|
+
}
|
|
5174
|
+
catch ( /* best-effort */_c) { /* best-effort */ }
|
|
5175
|
+
this.stripeLinksDisposer = undefined;
|
|
5176
|
+
try {
|
|
5177
|
+
(_b = this.outboundDisposer) === null || _b === void 0 ? void 0 : _b.call(this);
|
|
5178
|
+
}
|
|
5179
|
+
catch ( /* best-effort */_d) { /* best-effort */ }
|
|
5180
|
+
this.outboundDisposer = undefined;
|
|
5181
|
+
}
|
|
4909
5182
|
optOut() {
|
|
4910
5183
|
var _a;
|
|
4911
5184
|
if (!this.initialized) {
|
|
@@ -4927,6 +5200,8 @@ var Datalyr = (function (exports) {
|
|
|
4927
5200
|
this.container.cleanupAllIframes();
|
|
4928
5201
|
this.container = undefined;
|
|
4929
5202
|
}
|
|
5203
|
+
// X-1: stop stamping Stripe/CC outbound links with the visitor id / email.
|
|
5204
|
+
this.disposeMarketingLinkDecorators();
|
|
4930
5205
|
// Purge PII at rest.
|
|
4931
5206
|
this.userProperties = {};
|
|
4932
5207
|
storage.remove('dl_user_traits');
|
|
@@ -4948,6 +5223,9 @@ var Datalyr = (function (exports) {
|
|
|
4948
5223
|
// merely because opt-out was lifted — otherwise a GPC/DNT visitor's persisted
|
|
4949
5224
|
// events would start draining again. (Pixels / auto-identify resume on next load.)
|
|
4950
5225
|
this.queue.setEnabled(this.shouldTrack());
|
|
5226
|
+
// TR-15 (P3): opt-in → persist the in-memory anon id now (see onShopifyConsentChanged).
|
|
5227
|
+
if (this.shouldTrack())
|
|
5228
|
+
this.identity.enablePersistence();
|
|
4951
5229
|
this.log('User opted in');
|
|
4952
5230
|
}
|
|
4953
5231
|
/**
|
|
@@ -4960,6 +5238,7 @@ var Datalyr = (function (exports) {
|
|
|
4960
5238
|
* Set consent preferences
|
|
4961
5239
|
*/
|
|
4962
5240
|
setConsent(consent) {
|
|
5241
|
+
var _a;
|
|
4963
5242
|
// Always record + persist consent FIRST, even before init(). FSR-51: a consent-
|
|
4964
5243
|
// management platform commonly calls setConsent() before init() so tracking is gated
|
|
4965
5244
|
// from the very first event. The old code then dereferenced this.queue/this.config
|
|
@@ -4980,6 +5259,22 @@ var Datalyr = (function (exports) {
|
|
|
4980
5259
|
if (!allowed) {
|
|
4981
5260
|
this.queue.clear();
|
|
4982
5261
|
this.queue.clearOffline();
|
|
5262
|
+
// TR-14: mirror optOut() on analytics withdrawal. Previously setConsent tore down the
|
|
5263
|
+
// queue + container but left autoIdentify alive — its form/fetch interceptors and
|
|
5264
|
+
// /account.json polling kept running and triggerIdentify persisted the captured email
|
|
5265
|
+
// to storage AFTER withdrawal (PII newly written at rest → GDPR/consent-audit failure
|
|
5266
|
+
// on non-Shopify CMP installs). Destroy it and purge PII at rest. (Auto-identify
|
|
5267
|
+
// resumes on the next page load if consent is re-granted, matching optOut/optIn.)
|
|
5268
|
+
(_a = this.autoIdentify) === null || _a === void 0 ? void 0 : _a.destroy();
|
|
5269
|
+
this.autoIdentify = undefined;
|
|
5270
|
+
this.userProperties = {};
|
|
5271
|
+
storage.remove('dl_user_traits');
|
|
5272
|
+
storage.remove('dl_auto_identified_email');
|
|
5273
|
+
storage.remove('dl_journey');
|
|
5274
|
+
}
|
|
5275
|
+
else {
|
|
5276
|
+
// TR-15 (P3): grant → persist the in-memory anon id now (see onShopifyConsentChanged).
|
|
5277
|
+
this.identity.enablePersistence();
|
|
4983
5278
|
}
|
|
4984
5279
|
// Marketing / "do not sell" withdrawal: stop FEEDING the third-party pixels and
|
|
4985
5280
|
// prevent them from being initialized on the next page load. NOTE: an
|
|
@@ -4989,6 +5284,12 @@ var Datalyr = (function (exports) {
|
|
|
4989
5284
|
this.container.cleanupAllIframes();
|
|
4990
5285
|
this.container = undefined;
|
|
4991
5286
|
}
|
|
5287
|
+
// X-1: on marketing/sale withdrawal, stop the Stripe/CC decorators from stamping the
|
|
5288
|
+
// visitor id / email onto outbound links (the observers otherwise keep rewriting hrefs
|
|
5289
|
+
// after withdrawal, and the server still reads the stamped client_reference_id).
|
|
5290
|
+
if (!this.consentAllowsMarketing()) {
|
|
5291
|
+
this.disposeMarketingLinkDecorators();
|
|
5292
|
+
}
|
|
4992
5293
|
this.log('Consent updated:', consent);
|
|
4993
5294
|
}
|
|
4994
5295
|
/**
|
|
@@ -5073,91 +5374,27 @@ var Datalyr = (function (exports) {
|
|
|
5073
5374
|
}
|
|
5074
5375
|
});
|
|
5075
5376
|
}
|
|
5076
|
-
/**
|
|
5077
|
-
* Restore _dl_* URL bridge params on a Checkout Champ funnel page.
|
|
5078
|
-
* Runs BEFORE IdentityManager so the storefront's visitor_id wins over a
|
|
5079
|
-
* freshly auto-generated one. Also restores _fbc / _fbp cookies and the
|
|
5080
|
-
* fbclid click time so server-side rebuilds carry the real click moment.
|
|
5081
|
-
*
|
|
5082
|
-
* Strategy: stamp matching cookies (without clobbering pre-existing values),
|
|
5083
|
-
* then rewrite the URL so `_dl_fbclid` becomes `fbclid` — that way the rest
|
|
5084
|
-
* of the SDK's attribution layer (which reads `params.fbclid`) works
|
|
5085
|
-
* unchanged, and any merchant analytics also see the canonical click ID.
|
|
5086
|
-
*/
|
|
5377
|
+
/** Strip the retired unsigned Checkout Champ bridge parameters. */
|
|
5087
5378
|
restoreFromURL() {
|
|
5088
5379
|
var _a;
|
|
5089
5380
|
if (typeof window === "undefined" || typeof document === "undefined")
|
|
5090
5381
|
return;
|
|
5091
5382
|
try {
|
|
5092
5383
|
const params = new URLSearchParams(window.location.search);
|
|
5093
|
-
|
|
5094
|
-
const vid = get("_dl_vid");
|
|
5095
|
-
const fbc = get("_dl_fbc");
|
|
5096
|
-
const fbp = get("_dl_fbp");
|
|
5097
|
-
const fbclid = get("_dl_fbclid");
|
|
5098
|
-
const fbclidAt = get("_dl_fbclid_at");
|
|
5099
|
-
const gclid = get("_dl_gclid");
|
|
5100
|
-
const gclidAt = get("_dl_gclid_at");
|
|
5101
|
-
// visitor_id: bridge wins. The whole point of `_dl_vid` is to unify the
|
|
5102
|
-
// storefront's session with the CC funnel session. A pre-existing local
|
|
5103
|
-
// cookie from a prior direct CC visit would silently sink the integration
|
|
5104
|
-
// (CC events stay on the local id; storefront events use the bridged id;
|
|
5105
|
-
// the two never link). Overwrite — orphaned local events are fine.
|
|
5106
|
-
if (vid)
|
|
5107
|
-
this.cookies.set("__dl_visitor_id", vid, 365);
|
|
5108
|
-
// Meta cookies + click-time cookies: existing wins. _fbc / _fbp may have
|
|
5109
|
-
// been written by Meta Pixel on the CC funnel page itself (more recent
|
|
5110
|
-
// than the bridged value); _dl_fbclid_at should record first-touch click
|
|
5111
|
-
// time per device, not get reset by a bridge from a new campaign.
|
|
5112
|
-
const setIfMissing = (name, value) => {
|
|
5113
|
-
if (!value)
|
|
5114
|
-
return;
|
|
5115
|
-
if (this.cookies.get(name))
|
|
5116
|
-
return;
|
|
5117
|
-
this.cookies.set(name, value, 365);
|
|
5118
|
-
};
|
|
5119
|
-
setIfMissing("_fbc", fbc);
|
|
5120
|
-
setIfMissing("_fbp", fbp);
|
|
5121
|
-
setIfMissing("_dl_fbclid_at", fbclidAt);
|
|
5122
|
-
setIfMissing("_dl_gclid_at", gclidAt);
|
|
5123
|
-
// Rewrite URL: _dl_fbclid → fbclid (etc.) so captureAttribution() picks
|
|
5124
|
-
// them up via its existing `params.fbclid` path. Strip the _dl_* params
|
|
5125
|
-
// either way so they don't leak into downstream analytics URLs.
|
|
5126
|
-
let rewrote = false;
|
|
5127
|
-
const mappings = [
|
|
5128
|
-
["fbclid", fbclid],
|
|
5129
|
-
["gclid", gclid]
|
|
5130
|
-
];
|
|
5131
|
-
for (const [canonical, value] of mappings) {
|
|
5132
|
-
if (value && !params.get(canonical)) {
|
|
5133
|
-
params.set(canonical, value);
|
|
5134
|
-
rewrote = true;
|
|
5135
|
-
}
|
|
5136
|
-
}
|
|
5384
|
+
let changed = false;
|
|
5137
5385
|
for (const k of ["_dl_vid", "_dl_fbc", "_dl_fbp", "_dl_fbclid", "_dl_fbclid_at", "_dl_gclid", "_dl_gclid_at"]) {
|
|
5138
5386
|
if (params.has(k)) {
|
|
5139
5387
|
params.delete(k);
|
|
5140
|
-
|
|
5388
|
+
changed = true;
|
|
5141
5389
|
}
|
|
5142
5390
|
}
|
|
5143
|
-
if (
|
|
5391
|
+
if (changed && typeof ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState) === "function") {
|
|
5144
5392
|
const newSearch = params.toString();
|
|
5145
|
-
const newUrl = window.location.pathname +
|
|
5146
|
-
(newSearch ? "?" + newSearch : "") +
|
|
5147
|
-
window.location.hash;
|
|
5393
|
+
const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
|
|
5148
5394
|
window.history.replaceState(window.history.state, "", newUrl);
|
|
5149
5395
|
}
|
|
5150
|
-
this.log("Checkout Champ bridge restored:", {
|
|
5151
|
-
had_vid: !!vid,
|
|
5152
|
-
had_fbc: !!fbc,
|
|
5153
|
-
had_fbp: !!fbp,
|
|
5154
|
-
had_fbclid: !!fbclid,
|
|
5155
|
-
had_gclid: !!gclid
|
|
5156
|
-
});
|
|
5157
5396
|
}
|
|
5158
5397
|
catch (error) {
|
|
5159
|
-
// Don't let bridge restoration block init — fall through to normal SDK
|
|
5160
|
-
// behavior (a fresh visitor_id, no restored click signals).
|
|
5161
5398
|
this.log("restoreFromURL failed:", error);
|
|
5162
5399
|
}
|
|
5163
5400
|
}
|
|
@@ -5258,138 +5495,9 @@ var Datalyr = (function (exports) {
|
|
|
5258
5495
|
this.log("fireCheckoutChampPurchasePixel failed:", error);
|
|
5259
5496
|
}
|
|
5260
5497
|
}
|
|
5261
|
-
/**
|
|
5262
|
-
* Storefront → Checkout Champ link stamping. Finds every `<a href>` whose host
|
|
5263
|
-
* matches the configured CC domain list and appends `?_dl_vid=…&_dl_fbc=…&
|
|
5264
|
-
* _dl_fbp=…&_dl_fbclid=…&_dl_fbclid_at=…&_dl_gclid=…` so the user's
|
|
5265
|
-
* visitor_id + Meta click signals cross the domain. MutationObserver watches
|
|
5266
|
-
* for dynamically-injected links. On click, force-flush the event queue so
|
|
5267
|
-
* any pending track() events land before the browser navigates away.
|
|
5268
|
-
*/
|
|
5498
|
+
/** Checkout Champ identity bridging remains disabled pending signed tokens. */
|
|
5269
5499
|
syncOutboundLinkParams(domains) {
|
|
5270
|
-
|
|
5271
|
-
return;
|
|
5272
|
-
const lowerDomains = domains.map((d) => d.toLowerCase());
|
|
5273
|
-
const matchesCcDomain = (href) => {
|
|
5274
|
-
try {
|
|
5275
|
-
const host = new URL(href, window.location.href).hostname.toLowerCase();
|
|
5276
|
-
return lowerDomains.some((d) => host === d || host.endsWith("." + d));
|
|
5277
|
-
}
|
|
5278
|
-
catch (_a) {
|
|
5279
|
-
return false;
|
|
5280
|
-
}
|
|
5281
|
-
};
|
|
5282
|
-
const buildBridgeParams = () => {
|
|
5283
|
-
var _a, _b;
|
|
5284
|
-
const attribution = this.attribution.getAttributionData();
|
|
5285
|
-
const out = {};
|
|
5286
|
-
const vid = this.identity.getAnonymousId();
|
|
5287
|
-
const fbc = this.cookies.get("_fbc") || attribution._fbc;
|
|
5288
|
-
const fbp = this.cookies.get("_fbp") || attribution._fbp;
|
|
5289
|
-
// FSR-104: read the named click-id fields directly (captureAttribution stores every
|
|
5290
|
-
// present click id under its own key) so a landing URL carrying BOTH fbclid and
|
|
5291
|
-
// gclid bridges both — the old `clickIdType === 'fbclid' ? clickId : null` dropped
|
|
5292
|
-
// gclid whenever fbclid was the primary, silently losing Google attribution on the
|
|
5293
|
-
// cross-domain CC path. Fall back to the primary clickId for older stored shapes.
|
|
5294
|
-
const fbclid = (_a = attribution.fbclid) !== null && _a !== void 0 ? _a : (attribution.clickIdType === "fbclid" ? attribution.clickId : null);
|
|
5295
|
-
const fbclidAt = this.cookies.get("_dl_fbclid_at");
|
|
5296
|
-
const gclid = (_b = attribution.gclid) !== null && _b !== void 0 ? _b : (attribution.clickIdType === "gclid" ? attribution.clickId : null);
|
|
5297
|
-
const gclidAt = this.cookies.get("_dl_gclid_at");
|
|
5298
|
-
if (vid)
|
|
5299
|
-
out._dl_vid = vid;
|
|
5300
|
-
if (fbc)
|
|
5301
|
-
out._dl_fbc = String(fbc);
|
|
5302
|
-
if (fbp)
|
|
5303
|
-
out._dl_fbp = String(fbp);
|
|
5304
|
-
if (fbclid)
|
|
5305
|
-
out._dl_fbclid = String(fbclid);
|
|
5306
|
-
if (fbclidAt)
|
|
5307
|
-
out._dl_fbclid_at = String(fbclidAt);
|
|
5308
|
-
if (gclid)
|
|
5309
|
-
out._dl_gclid = String(gclid);
|
|
5310
|
-
if (gclidAt)
|
|
5311
|
-
out._dl_gclid_at = String(gclidAt);
|
|
5312
|
-
return out;
|
|
5313
|
-
};
|
|
5314
|
-
const stampLink = (anchor) => {
|
|
5315
|
-
if (!anchor.href || !matchesCcDomain(anchor.href))
|
|
5316
|
-
return;
|
|
5317
|
-
try {
|
|
5318
|
-
const u = new URL(anchor.href, window.location.href);
|
|
5319
|
-
const bridge = buildBridgeParams();
|
|
5320
|
-
let mutated = false;
|
|
5321
|
-
for (const [k, v] of Object.entries(bridge)) {
|
|
5322
|
-
if (!u.searchParams.get(k)) {
|
|
5323
|
-
u.searchParams.set(k, v);
|
|
5324
|
-
mutated = true;
|
|
5325
|
-
}
|
|
5326
|
-
}
|
|
5327
|
-
if (mutated)
|
|
5328
|
-
anchor.href = u.toString();
|
|
5329
|
-
}
|
|
5330
|
-
catch (_a) {
|
|
5331
|
-
// Ignore malformed URLs — don't break the page.
|
|
5332
|
-
}
|
|
5333
|
-
};
|
|
5334
|
-
const stampAll = () => {
|
|
5335
|
-
document.querySelectorAll("a[href]").forEach((el) => stampLink(el));
|
|
5336
|
-
};
|
|
5337
|
-
const onClick = (e) => {
|
|
5338
|
-
var _a, _b;
|
|
5339
|
-
const target = (_a = e.target) === null || _a === void 0 ? void 0 : _a.closest("a[href]");
|
|
5340
|
-
if (!target)
|
|
5341
|
-
return;
|
|
5342
|
-
const anchor = target;
|
|
5343
|
-
if (!matchesCcDomain(anchor.href))
|
|
5344
|
-
return;
|
|
5345
|
-
stampLink(anchor); // re-stamp in case attribution changed since DOMReady
|
|
5346
|
-
try {
|
|
5347
|
-
(_b = this.queue) === null || _b === void 0 ? void 0 : _b.flush();
|
|
5348
|
-
}
|
|
5349
|
-
catch (_c) {
|
|
5350
|
-
// Best-effort — never block navigation.
|
|
5351
|
-
}
|
|
5352
|
-
};
|
|
5353
|
-
stampAll();
|
|
5354
|
-
document.addEventListener("click", onClick, true);
|
|
5355
|
-
try {
|
|
5356
|
-
// Debounce re-stamping. A busy SPA (infinite scroll, animations,
|
|
5357
|
-
// React/Vue updates) can fire thousands of mutations per second; a naive
|
|
5358
|
-
// re-stamp on every notification would burn CPU pointlessly when
|
|
5359
|
-
// outbound link sets only change occasionally. 150ms is small enough to
|
|
5360
|
-
// catch links before the user can click them, large enough to coalesce
|
|
5361
|
-
// bursts.
|
|
5362
|
-
let restampTimer = null;
|
|
5363
|
-
const scheduleRestamp = () => {
|
|
5364
|
-
if (restampTimer != null)
|
|
5365
|
-
return;
|
|
5366
|
-
restampTimer = setTimeout(() => {
|
|
5367
|
-
restampTimer = null;
|
|
5368
|
-
stampAll();
|
|
5369
|
-
}, 150);
|
|
5370
|
-
};
|
|
5371
|
-
const observer = new MutationObserver(scheduleRestamp);
|
|
5372
|
-
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
5373
|
-
// Observer + click listener live for the session; pagehide cleans up on full-page
|
|
5374
|
-
// unload, and destroy() can tear them down early via this disposer (H2 — otherwise
|
|
5375
|
-
// a destroy()+re-init() leaks the observer + capturing click listener).
|
|
5376
|
-
this.outboundDisposer = () => {
|
|
5377
|
-
try {
|
|
5378
|
-
observer.disconnect();
|
|
5379
|
-
}
|
|
5380
|
-
catch ( /* idempotent */_a) { /* idempotent */ }
|
|
5381
|
-
if (restampTimer != null) {
|
|
5382
|
-
clearTimeout(restampTimer);
|
|
5383
|
-
restampTimer = null;
|
|
5384
|
-
}
|
|
5385
|
-
document.removeEventListener("click", onClick, true);
|
|
5386
|
-
};
|
|
5387
|
-
window.addEventListener("pagehide", () => { var _a; return (_a = this.outboundDisposer) === null || _a === void 0 ? void 0 : _a.call(this); }, { once: true });
|
|
5388
|
-
}
|
|
5389
|
-
catch (error) {
|
|
5390
|
-
this.log("MutationObserver setup failed (CC link sync):", error);
|
|
5391
|
-
}
|
|
5392
|
-
this.log("Checkout Champ outbound-link sync active for:", lowerDomains);
|
|
5500
|
+
this.log("Checkout Champ outbound identity bridge disabled (signed token required)", domains);
|
|
5393
5501
|
}
|
|
5394
5502
|
/**
|
|
5395
5503
|
* Stripe Payment Link auto-decoration (D1). Structurally mirrors
|
|
@@ -5548,6 +5656,7 @@ var Datalyr = (function (exports) {
|
|
|
5548
5656
|
* Create event payload
|
|
5549
5657
|
*/
|
|
5550
5658
|
createEventPayload(eventName, properties, eventIdArg) {
|
|
5659
|
+
var _a;
|
|
5551
5660
|
// NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
|
|
5552
5661
|
// The session id changes on session timeout (and previously on identify(), removed in
|
|
5553
5662
|
// FSR-53), but identity only synced it at init/start — so the top-level
|
|
@@ -5584,17 +5693,30 @@ var Datalyr = (function (exports) {
|
|
|
5584
5693
|
device_fingerprint: fingerprintData // Use snake_case only (matches backend)
|
|
5585
5694
|
});
|
|
5586
5695
|
}
|
|
5587
|
-
// Add browser context (caller wins on collisions)
|
|
5696
|
+
// Add browser context (caller wins on collisions). 9.A.4: url/referrer are
|
|
5697
|
+
// redacted — secret/PII query-param values must not ship on events.
|
|
5588
5698
|
assignMissing(eventData, {
|
|
5589
|
-
url: window.location.href,
|
|
5699
|
+
url: redactUrl(window.location.href),
|
|
5590
5700
|
path: window.location.pathname,
|
|
5591
|
-
referrer: document.referrer,
|
|
5701
|
+
referrer: redactUrl(document.referrer),
|
|
5592
5702
|
title: document.title,
|
|
5593
5703
|
screen_width: screen.width,
|
|
5594
5704
|
screen_height: screen.height,
|
|
5595
5705
|
viewport_width: window.innerWidth,
|
|
5596
5706
|
viewport_height: window.innerHeight
|
|
5597
5707
|
});
|
|
5708
|
+
// Consent travels with the event so downstream profile enrichment and ad
|
|
5709
|
+
// postbacks enforce the decision that applied at occurrence time. The SDK
|
|
5710
|
+
// snapshot is authoritative over caller properties.
|
|
5711
|
+
const consentSnapshot = Object.assign({}, ((_a = this.consent) !== null && _a !== void 0 ? _a : {}));
|
|
5712
|
+
const shopifyAnalytics = this.shopifyAnalyticsConsent();
|
|
5713
|
+
const shopifyMarketing = this.shopifyMarketingConsent();
|
|
5714
|
+
if (typeof shopifyAnalytics === 'boolean')
|
|
5715
|
+
consentSnapshot.analytics = shopifyAnalytics;
|
|
5716
|
+
if (typeof shopifyMarketing === 'boolean')
|
|
5717
|
+
consentSnapshot.marketing = shopifyMarketing;
|
|
5718
|
+
if (Object.keys(consentSnapshot).length > 0)
|
|
5719
|
+
eventData.consent = consentSnapshot;
|
|
5598
5720
|
// Create payload using snake_case only (matches backend API and production script)
|
|
5599
5721
|
const identityFields = this.identity.getIdentityFields();
|
|
5600
5722
|
// Use the caller-provided event_id (shared with the Meta Pixel co-fire for
|
|
@@ -5623,11 +5745,17 @@ var Datalyr = (function (exports) {
|
|
|
5623
5745
|
// Resolution metadata
|
|
5624
5746
|
resolution_method: 'browser_sdk',
|
|
5625
5747
|
resolution_confidence: 1.0,
|
|
5626
|
-
// SDK metadata.
|
|
5627
|
-
// (
|
|
5628
|
-
//
|
|
5629
|
-
|
|
5630
|
-
|
|
5748
|
+
// SDK metadata. The placeholder is replaced with package.json "version" at build
|
|
5749
|
+
// time (rollup.config.js injectSdkVersion — 9.A.3), so the literal can never
|
|
5750
|
+
// drift from the package again (it sat at 1.7.4 across the 1.7.5 release). The
|
|
5751
|
+
// build guard (scripts/check-bundle.js, run by build:check) still verifies the
|
|
5752
|
+
// deployable bundles carry the package.json version. (FSR-103)
|
|
5753
|
+
sdk_version: '1.7.6',
|
|
5754
|
+
sdk_name: 'datalyr-web-sdk',
|
|
5755
|
+
// A3-25: versioned-envelope stamp. Every SDK emits schema_version so the ingest/contract
|
|
5756
|
+
// layer can key on ONE canonical envelope version (snake_case fields, event_name/event_data
|
|
5757
|
+
// keys, canonical click-ids, clamped client timestamp — all now converged across SDKs).
|
|
5758
|
+
schema_version: 1
|
|
5631
5759
|
};
|
|
5632
5760
|
return payload;
|
|
5633
5761
|
}
|
|
@@ -5643,6 +5771,14 @@ var Datalyr = (function (exports) {
|
|
|
5643
5771
|
if (this.consent && this.consent.analytics === false) {
|
|
5644
5772
|
return false;
|
|
5645
5773
|
}
|
|
5774
|
+
// Shopify Customer Privacy (9.A.1 — LEGAL): on a Shopify storefront, a shopper who
|
|
5775
|
+
// declined the native consent banner must not be tracked, even when the merchant
|
|
5776
|
+
// never wired setConsent(). null = API absent (non-Plus store not using it, or
|
|
5777
|
+
// script loaded off-Shopify) → fall through. A detected Shopify storefront
|
|
5778
|
+
// fails closed while the asynchronous Customer Privacy API is unresolved.
|
|
5779
|
+
if (this.shopifyAnalyticsConsent() === false) {
|
|
5780
|
+
return false;
|
|
5781
|
+
}
|
|
5646
5782
|
// Check Do Not Track
|
|
5647
5783
|
if (this.config.respectDoNotTrack && isDoNotTrackEnabled()) {
|
|
5648
5784
|
return false;
|
|
@@ -5659,8 +5795,185 @@ var Datalyr = (function (exports) {
|
|
|
5659
5795
|
* pixels (which share data). No consent set = allowed (default).
|
|
5660
5796
|
*/
|
|
5661
5797
|
consentAllowsMarketing() {
|
|
5798
|
+
// Shopify Customer Privacy (9.A.1): a declined native banner blocks marketing use
|
|
5799
|
+
// (pixel loads, click-id/email forwarding) even with no setConsent() call.
|
|
5800
|
+
// null = no signal → defer to the setConsent-based policy below, unchanged.
|
|
5801
|
+
if (this.shopifyMarketingConsent() === false) {
|
|
5802
|
+
return false;
|
|
5803
|
+
}
|
|
5662
5804
|
return !this.consent || (this.consent.marketing !== false && this.consent.sale !== false);
|
|
5663
5805
|
}
|
|
5806
|
+
/**
|
|
5807
|
+
* Shopify Customer Privacy API handle (9.A.1), or null when it doesn't apply.
|
|
5808
|
+
*
|
|
5809
|
+
* X-2 (LEGAL): gated on RUNTIME detection of `window.Shopify.customerPrivacy`, NOT on
|
|
5810
|
+
* `config.platform === 'shopify'`. A Shopify merchant who installs via a plain <script>
|
|
5811
|
+
* snippet or a headless storefront (no `platform:'shopify'`) still exposes customerPrivacy;
|
|
5812
|
+
* gating on config.platform meant a shopper who DECLINED the native banner (with no
|
|
5813
|
+
* setConsent() wired) was fully tracked — nullifying the consent feature for a whole install
|
|
5814
|
+
* class, and unfixable server-side (platform is install-time only). Every access is wrapped —
|
|
5815
|
+
* a Shopify API shape change (or a non-Shopify site) must NEVER break tracking (→ null = fail open).
|
|
5816
|
+
*/
|
|
5817
|
+
getShopifyCustomerPrivacy() {
|
|
5818
|
+
var _a, _b;
|
|
5819
|
+
if (typeof window === 'undefined')
|
|
5820
|
+
return null;
|
|
5821
|
+
try {
|
|
5822
|
+
return (_b = (_a = window.Shopify) === null || _a === void 0 ? void 0 : _a.customerPrivacy) !== null && _b !== void 0 ? _b : null;
|
|
5823
|
+
}
|
|
5824
|
+
catch (_c) {
|
|
5825
|
+
return null;
|
|
5826
|
+
}
|
|
5827
|
+
}
|
|
5828
|
+
isShopifyStorefront() {
|
|
5829
|
+
var _a;
|
|
5830
|
+
if (((_a = this.config) === null || _a === void 0 ? void 0 : _a.platform) === 'shopify')
|
|
5831
|
+
return true;
|
|
5832
|
+
if (typeof window === 'undefined')
|
|
5833
|
+
return false;
|
|
5834
|
+
try {
|
|
5835
|
+
return Boolean(window.Shopify) ||
|
|
5836
|
+
window.location.hostname.toLowerCase().endsWith('.myshopify.com');
|
|
5837
|
+
}
|
|
5838
|
+
catch (_b) {
|
|
5839
|
+
return false;
|
|
5840
|
+
}
|
|
5841
|
+
}
|
|
5842
|
+
/**
|
|
5843
|
+
* Shopify's analytics-consent signal: true/false when the Customer Privacy API is
|
|
5844
|
+
* present and answers, null when there's no signal (API absent / unexpected shape)
|
|
5845
|
+
* — null means "no restriction from Shopify", i.e. today's behavior.
|
|
5846
|
+
*/
|
|
5847
|
+
shopifyAnalyticsConsent() {
|
|
5848
|
+
try {
|
|
5849
|
+
const cp = this.getShopifyCustomerPrivacy();
|
|
5850
|
+
if (!cp)
|
|
5851
|
+
return this.isShopifyStorefront() ? false : null;
|
|
5852
|
+
if (typeof cp.analyticsProcessingAllowed === 'function') {
|
|
5853
|
+
const allowed = cp.analyticsProcessingAllowed();
|
|
5854
|
+
return typeof allowed === 'boolean' ? allowed : null;
|
|
5855
|
+
}
|
|
5856
|
+
// Older API surface: the aggregate "can this visitor be tracked" signal.
|
|
5857
|
+
if (typeof cp.userCanBeTracked === 'function') {
|
|
5858
|
+
const allowed = cp.userCanBeTracked();
|
|
5859
|
+
return typeof allowed === 'boolean' ? allowed : null;
|
|
5860
|
+
}
|
|
5861
|
+
return this.isShopifyStorefront() ? false : null;
|
|
5862
|
+
}
|
|
5863
|
+
catch (_a) {
|
|
5864
|
+
return this.isShopifyStorefront() ? false : null;
|
|
5865
|
+
}
|
|
5866
|
+
}
|
|
5867
|
+
/**
|
|
5868
|
+
* Shopify's marketing-consent signal (gates pixels, click-id/email → CAPI, cart
|
|
5869
|
+
* attribute stamping, auto-identify email capture). Same null semantics as
|
|
5870
|
+
* shopifyAnalyticsConsent().
|
|
5871
|
+
*/
|
|
5872
|
+
shopifyMarketingConsent() {
|
|
5873
|
+
try {
|
|
5874
|
+
const cp = this.getShopifyCustomerPrivacy();
|
|
5875
|
+
if (!cp || typeof cp.marketingAllowed !== 'function') {
|
|
5876
|
+
return this.isShopifyStorefront() ? false : null;
|
|
5877
|
+
}
|
|
5878
|
+
const allowed = cp.marketingAllowed();
|
|
5879
|
+
return typeof allowed === 'boolean'
|
|
5880
|
+
? allowed
|
|
5881
|
+
: (this.isShopifyStorefront() ? false : null);
|
|
5882
|
+
}
|
|
5883
|
+
catch (_a) {
|
|
5884
|
+
return this.isShopifyStorefront() ? false : null;
|
|
5885
|
+
}
|
|
5886
|
+
}
|
|
5887
|
+
/**
|
|
5888
|
+
* Listen for Shopify's `visitorConsentCollected` document event (9.A.1) so a
|
|
5889
|
+
* consent decision made mid-session takes effect without a reload. Revocation is
|
|
5890
|
+
* enforced immediately, mirroring setConsent(): queue gated + purged, pixels torn
|
|
5891
|
+
* down, auto-identify (email capture + /account.json polling) destroyed. On grant
|
|
5892
|
+
* the queue re-enables (track() re-checks shouldTrack() per event anyway) and cart
|
|
5893
|
+
* attribute stamping runs; pixels / auto-identify resume on the next page load —
|
|
5894
|
+
* the same convention as optIn().
|
|
5895
|
+
*/
|
|
5896
|
+
setupShopifyConsentListener() {
|
|
5897
|
+
// X-2: NOT gated on config.platform — a plain-snippet/headless Shopify install must also honor
|
|
5898
|
+
// a mid-session consent decision. The `visitorConsentCollected` event only fires on Shopify
|
|
5899
|
+
// storefronts, and the handler + loadFeatures are fully guarded, so this is a safe no-op
|
|
5900
|
+
// everywhere else.
|
|
5901
|
+
if (typeof document === 'undefined')
|
|
5902
|
+
return;
|
|
5903
|
+
try {
|
|
5904
|
+
this.shopifyConsentHandler = () => {
|
|
5905
|
+
try {
|
|
5906
|
+
this.onShopifyConsentChanged();
|
|
5907
|
+
}
|
|
5908
|
+
catch (error) {
|
|
5909
|
+
this.log('Shopify consent change handling failed:', error);
|
|
5910
|
+
}
|
|
5911
|
+
};
|
|
5912
|
+
document.addEventListener('visitorConsentCollected', this.shopifyConsentHandler);
|
|
5913
|
+
// TR-15: customerPrivacy loads ASYNCHRONOUSLY. Until it's present shopifyAnalyticsConsent()
|
|
5914
|
+
// returns null (fail-open → events send in the pre-load window). Force the feature to load
|
|
5915
|
+
// and re-run the full consent evaluation in the callback so a declined visitor is gated
|
|
5916
|
+
// (queue disabled + purged) as soon as the API answers, without waiting for a reload or a
|
|
5917
|
+
// banner interaction. Guarded — a missing/changed loadFeatures must never break tracking.
|
|
5918
|
+
const shopify = window.Shopify;
|
|
5919
|
+
if (shopify && typeof shopify.loadFeatures === 'function') {
|
|
5920
|
+
shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }], (error) => {
|
|
5921
|
+
if (error) {
|
|
5922
|
+
this.log('Shopify loadFeatures(consent-tracking-api) failed:', error);
|
|
5923
|
+
return;
|
|
5924
|
+
}
|
|
5925
|
+
try {
|
|
5926
|
+
this.onShopifyConsentChanged();
|
|
5927
|
+
}
|
|
5928
|
+
catch (e) {
|
|
5929
|
+
this.log('Shopify post-load consent eval failed:', e);
|
|
5930
|
+
}
|
|
5931
|
+
});
|
|
5932
|
+
}
|
|
5933
|
+
}
|
|
5934
|
+
catch (error) {
|
|
5935
|
+
this.log('Shopify consent listener setup failed:', error);
|
|
5936
|
+
}
|
|
5937
|
+
}
|
|
5938
|
+
onShopifyConsentChanged() {
|
|
5939
|
+
const allowed = this.shouldTrack();
|
|
5940
|
+
this.queue.setEnabled(allowed);
|
|
5941
|
+
// TR-15 (P3): a mid-session grant must persist the in-memory anon id NOW — otherwise a
|
|
5942
|
+
// visitor declined at init keeps a memory-only id and this session's events land under a
|
|
5943
|
+
// visitor_id that vanishes on the next page load. Idempotent.
|
|
5944
|
+
if (allowed)
|
|
5945
|
+
this.identity.enablePersistence();
|
|
5946
|
+
if (!allowed) {
|
|
5947
|
+
// Mirror setConsent() withdrawal: purge buffered events so events captured
|
|
5948
|
+
// before the decline can't drain if consent is later re-granted.
|
|
5949
|
+
this.queue.clear();
|
|
5950
|
+
this.queue.clearOffline();
|
|
5951
|
+
}
|
|
5952
|
+
const marketingBlocked = this.shopifyMarketingConsent() === false;
|
|
5953
|
+
// Stop email capture (form interceptors + /account.json polling) immediately.
|
|
5954
|
+
if ((!allowed || marketingBlocked) && this.autoIdentify) {
|
|
5955
|
+
this.autoIdentify.destroy();
|
|
5956
|
+
this.autoIdentify = undefined;
|
|
5957
|
+
}
|
|
5958
|
+
// Same caveat as setConsent(): an already-injected pixel global (fbq/gtag/ttq)
|
|
5959
|
+
// keeps running in the page; full removal is on reload.
|
|
5960
|
+
if (!this.consentAllowsMarketing() && this.container) {
|
|
5961
|
+
this.container.cleanupAllIframes();
|
|
5962
|
+
this.container = undefined;
|
|
5963
|
+
}
|
|
5964
|
+
// X-1: marketing withdrawn (Shopify decline) → stop stamping Stripe/CC outbound links.
|
|
5965
|
+
if (!this.consentAllowsMarketing()) {
|
|
5966
|
+
this.disposeMarketingLinkDecorators();
|
|
5967
|
+
}
|
|
5968
|
+
// Grant direction: cart-attribute stamping is cheap and idempotent (merges via
|
|
5969
|
+
// /cart/update.js), so run it now instead of waiting for the next page load.
|
|
5970
|
+
if (allowed && !marketingBlocked && this.config.shopifyCartAttributes === true) {
|
|
5971
|
+
this.syncShopifyCartAttributes().catch((error) => {
|
|
5972
|
+
this.log('Shopify cart attribute sync failed:', error);
|
|
5973
|
+
});
|
|
5974
|
+
}
|
|
5975
|
+
this.log('Shopify consent collected — analytics allowed:', allowed, '— marketing blocked:', marketingBlocked);
|
|
5976
|
+
}
|
|
5664
5977
|
/**
|
|
5665
5978
|
* Setup SPA tracking
|
|
5666
5979
|
* Fixed Issue #15: Store original methods for cleanup
|
|
@@ -5761,7 +6074,7 @@ var Datalyr = (function (exports) {
|
|
|
5761
6074
|
stack: error.stack,
|
|
5762
6075
|
context,
|
|
5763
6076
|
timestamp: new Date().toISOString(),
|
|
5764
|
-
url: window.location.href
|
|
6077
|
+
url: redactUrl(window.location.href) // 9.A.4: no secret query values in error logs
|
|
5765
6078
|
};
|
|
5766
6079
|
this.errors.push(errorInfo);
|
|
5767
6080
|
// Keep only recent errors
|
|
@@ -5843,6 +6156,10 @@ var Datalyr = (function (exports) {
|
|
|
5843
6156
|
window.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
5844
6157
|
this.visibilityHandler = undefined;
|
|
5845
6158
|
}
|
|
6159
|
+
if (this.shopifyConsentHandler) {
|
|
6160
|
+
document.removeEventListener('visitorConsentCollected', this.shopifyConsentHandler);
|
|
6161
|
+
this.shopifyConsentHandler = undefined;
|
|
6162
|
+
}
|
|
5846
6163
|
if (this.outboundDisposer) {
|
|
5847
6164
|
this.outboundDisposer();
|
|
5848
6165
|
this.outboundDisposer = undefined;
|
|
@@ -5884,8 +6201,14 @@ var Datalyr = (function (exports) {
|
|
|
5884
6201
|
this.log('SDK destroyed');
|
|
5885
6202
|
}
|
|
5886
6203
|
}
|
|
5887
|
-
// Create singleton instance
|
|
5888
|
-
|
|
6204
|
+
// Create singleton instance. TR-23: reuse an existing window.datalyr (a prior tag's instance —
|
|
6205
|
+
// Shopify App Embed + a manual snippet coexisting, or a double-included bundle) instead of
|
|
6206
|
+
// constructing a SECOND one. Two instances each patch history.pushState and fire their own
|
|
6207
|
+
// pageviews (distinct event_ids → no server-side dedup). A plain truthy check (NOT instanceof):
|
|
6208
|
+
// two <script> tags run two separate IIFEs whose Datalyr classes are structurally identical but
|
|
6209
|
+
// distinct objects, so instanceof would fail across them. With one shared instance, the second
|
|
6210
|
+
// tag's bootstrap init() hits the instance-level `initialized` guard and no-ops.
|
|
6211
|
+
const datalyr = (typeof window !== 'undefined' && window.datalyr) || new Datalyr();
|
|
5889
6212
|
// Expose global API
|
|
5890
6213
|
if (typeof window !== 'undefined') {
|
|
5891
6214
|
window.datalyr = datalyr;
|