@datalyr/web 1.7.5 → 1.7.8
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 +542 -476
- package/dist/attribution-lyr.test.d.ts +2 -0
- package/dist/attribution-lyr.test.d.ts.map +1 -0
- package/dist/attribution-oppref.test.d.ts +2 -0
- package/dist/attribution-oppref.test.d.ts.map +1 -0
- package/dist/attribution.d.ts +4 -0
- package/dist/attribution.d.ts.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/datalyr.cjs.js +1303 -281
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +1303 -282
- 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 +1303 -281
- package/dist/datalyr.js.map +1 -1
- package/dist/datalyr.min.js +1 -1
- package/dist/datalyr.min.js.map +1 -1
- package/dist/identify-dedupe.test.d.ts +2 -0
- package/dist/identify-dedupe.test.d.ts.map +1 -0
- package/dist/identity-pii.test.d.ts +2 -0
- package/dist/identity-pii.test.d.ts.map +1 -0
- package/dist/identity.d.ts +52 -4
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +116 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.test.d.ts +3 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/queue-403.test.d.ts +2 -0
- package/dist/queue-403.test.d.ts.map +1 -0
- package/dist/queue.d.ts +32 -4
- package/dist/queue.d.ts.map +1 -1
- package/dist/storage-migration.test.d.ts +2 -0
- package/dist/storage-migration.test.d.ts.map +1 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/stripe-session.d.ts +75 -0
- package/dist/stripe-session.d.ts.map +1 -0
- package/dist/stripe-session.test.d.ts +2 -0
- package/dist/stripe-session.test.d.ts.map +1 -0
- package/dist/types.d.ts +2 -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.cjs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @datalyr/web v1.7.
|
|
2
|
+
* @datalyr/web v1.7.8
|
|
3
3
|
* Datalyr Web SDK - Modern attribution tracking for web applications
|
|
4
4
|
* (c) 2026 Datalyr Inc.
|
|
5
5
|
* Released under the MIT License
|
|
@@ -419,8 +419,23 @@ class SafeStorage {
|
|
|
419
419
|
// Get value from legacy key
|
|
420
420
|
const value = this.storage.getItem(legacyKey);
|
|
421
421
|
if (value) {
|
|
422
|
-
//
|
|
423
|
-
|
|
422
|
+
// WEB-29: strip ONLY the leading '__'.
|
|
423
|
+
//
|
|
424
|
+
// This used to strip '__dl_', producing 'dl_anonymous_id' — but that
|
|
425
|
+
// is the LOGICAL key callers pass to get(), not the key it reads.
|
|
426
|
+
// get() prepends this.prefix ('dl_'), so the value actually lives at
|
|
427
|
+
// 'dl_dl_anonymous_id'. The migration therefore wrote to an address
|
|
428
|
+
// nothing reads AND then deleted the legacy key below, destroying
|
|
429
|
+
// the visitor's id instead of migrating it: a silent visitor reset
|
|
430
|
+
// and attribution loss for anyone still on the old prefix.
|
|
431
|
+
//
|
|
432
|
+
// legacy real key : __dl_dl_anonymous_id
|
|
433
|
+
// current real key: dl_dl_anonymous_id ← strip '__'
|
|
434
|
+
// logical key : dl_anonymous_id ← what get() is called with
|
|
435
|
+
//
|
|
436
|
+
// NOTE: the double 'dl_dl_' looks like a typo and is not. Renaming
|
|
437
|
+
// the prefix would orphan every stored id across the install base.
|
|
438
|
+
const actualKey = legacyKey.slice('__'.length); // '__dl_dl_anonymous_id' -> 'dl_dl_anonymous_id'
|
|
424
439
|
// Only migrate if new key doesn't already exist (don't overwrite newer data)
|
|
425
440
|
if (!this.storage.getItem(actualKey)) {
|
|
426
441
|
this.storage.setItem(actualKey, value);
|
|
@@ -795,6 +810,125 @@ function sanitizeEventData(data, maxDepth = 5, currentDepth = 0) {
|
|
|
795
810
|
}
|
|
796
811
|
return data;
|
|
797
812
|
}
|
|
813
|
+
// 9.A.4: query-param NAMES whose VALUES are secrets/PII — password-reset tokens,
|
|
814
|
+
// OAuth codes, magic-link sessions, emails/phones prefilled into URLs. Events ship
|
|
815
|
+
// `url` / `referrer` / `landingPage` with the FULL query string (sanitizeEventData
|
|
816
|
+
// only strips property KEYS, not URL param values), so /reset?token=…&email=… leaked
|
|
817
|
+
// verbatim into events and onward to ad platforms. Matched case-insensitively.
|
|
818
|
+
const REDACTED_URL_PARAMS = new Set([
|
|
819
|
+
'token', 'access_token', 'refresh_token', 'id_token', 'auth', 'authorization',
|
|
820
|
+
'password', 'pass', 'pwd', 'secret', 'api_key', 'apikey', 'key',
|
|
821
|
+
'session', 'session_id', 'sid', 'email', 'e', 'phone', 'tel',
|
|
822
|
+
'signature', 'sig', 'otp', 'reset', 'hash'
|
|
823
|
+
]);
|
|
824
|
+
// BATCH-2(e): `code` is CONDITIONAL, not in the always-redact set above. An OAuth
|
|
825
|
+
// authorization code (account-takeover-grade if it leaks to ad platforms) ALWAYS travels
|
|
826
|
+
// with an OAuth-flow marker in the same query/fragment — the response carries `code`+`state`
|
|
827
|
+
// (+`scope`/`session_state`/`iss`), the authorize request carries `client_id`/`redirect_uri`/
|
|
828
|
+
// `response_type`/`code_challenge`. A coupon/referral/product code (`?code=SUMMER20`) — a real
|
|
829
|
+
// attribution signal for the DTC/Shopify ICP — never does. So we redact `code` ONLY when a
|
|
830
|
+
// marker co-occurs in the same k=v string, preserving marketing codes. (A bare `code=` with no
|
|
831
|
+
// marker is syntactically indistinguishable from a coupon code and is kept; state-less OAuth
|
|
832
|
+
// flows are rare and CSRF-unsafe.) Markers are deliberately OAuth-specific to avoid redacting
|
|
833
|
+
// legitimate codes — `prompt`/`authuser` are excluded as too generic.
|
|
834
|
+
const OAUTH_CODE_CONTEXT_MARKERS = new Set([
|
|
835
|
+
'state', 'client_id', 'redirect_uri', 'response_type', 'grant_type',
|
|
836
|
+
'code_challenge', 'session_state', 'scope', 'iss',
|
|
837
|
+
// Token family: the presence of any bearer/OAuth token means a bare `code` in the same
|
|
838
|
+
// string is almost certainly an auth code, not a coupon — a marketing URL never carries
|
|
839
|
+
// these. (These values are independently redacted too; this only governs `code`.)
|
|
840
|
+
'access_token', 'id_token', 'refresh_token', 'token'
|
|
841
|
+
]);
|
|
842
|
+
const REDACTED_URL_VALUE = '__redacted__';
|
|
843
|
+
// Redact denylisted keys in one `k=v&k=v` string (query OR fragment). Returns the
|
|
844
|
+
// re-encoded string and whether anything was rewritten (so the caller can preserve the
|
|
845
|
+
// exact original bytes when nothing matched).
|
|
846
|
+
function redactKvPairs(s) {
|
|
847
|
+
const params = new URLSearchParams(s);
|
|
848
|
+
let mutated = false;
|
|
849
|
+
const keysLower = Array.from(new Set(params.keys())).map(k => k.toLowerCase());
|
|
850
|
+
// `code` co-occurrence check (BATCH-2e) — evaluated per-string, since an OAuth flow puts
|
|
851
|
+
// `code` and its marker in the SAME query (or the SAME fragment).
|
|
852
|
+
const hasOAuthMarker = keysLower.some(k => OAUTH_CODE_CONTEXT_MARKERS.has(k));
|
|
853
|
+
// Snapshot the keys first — set() collapses duplicate keys mid-iteration.
|
|
854
|
+
for (const key of Array.from(new Set(params.keys()))) {
|
|
855
|
+
const lower = key.toLowerCase();
|
|
856
|
+
const sensitive = lower === 'code'
|
|
857
|
+
? hasOAuthMarker
|
|
858
|
+
: REDACTED_URL_PARAMS.has(lower);
|
|
859
|
+
if (sensitive) {
|
|
860
|
+
params.set(key, REDACTED_URL_VALUE);
|
|
861
|
+
mutated = true;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return { out: params.toString(), mutated };
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Redact secret/PII param VALUES in a URL (9.A.4). The param NAME is kept
|
|
868
|
+
* (value → `__redacted__`) so funnel steps that match on the param's presence still
|
|
869
|
+
* work; every other param — click IDs (fbclid/gclid/…) and utm_* — survives untouched.
|
|
870
|
+
* Both the query string AND a k=v fragment are rewritten: TR-04 — OAuth-implicit /
|
|
871
|
+
* Supabase magic links carry the session in the FRAGMENT (`/welcome#access_token=eyJ…`),
|
|
872
|
+
* with no query string at all; the old code returned early on a missing `?` and reattached
|
|
873
|
+
* the fragment verbatim, leaking the full JWT into `url` / `landingPage` / `dl_first_touch`
|
|
874
|
+
* (90d) and onward to ad platforms. Non-k=v fragments (`#section`, `#/spa-route`) keep their
|
|
875
|
+
* shape. Returns the input unchanged when nothing sensitive matched or on any parse
|
|
876
|
+
* failure — redaction must never break tracking.
|
|
877
|
+
*/
|
|
878
|
+
function redactUrl(url) {
|
|
879
|
+
if (!url || typeof url !== 'string')
|
|
880
|
+
return url;
|
|
881
|
+
const hashStart = url.indexOf('#');
|
|
882
|
+
const qIdx = url.indexOf('?');
|
|
883
|
+
// A query string exists only when '?' appears BEFORE the fragment — a '?' after '#' is
|
|
884
|
+
// part of the fragment per the URL grammar (e.g. a SPA hash-route `#/reset?token=…`).
|
|
885
|
+
const queryStart = (qIdx !== -1 && (hashStart === -1 || qIdx < hashStart)) ? qIdx : -1;
|
|
886
|
+
if (queryStart === -1 && hashStart === -1)
|
|
887
|
+
return url; // nothing to redact
|
|
888
|
+
try {
|
|
889
|
+
const base = url.slice(0, queryStart !== -1 ? queryStart : (hashStart !== -1 ? hashStart : url.length));
|
|
890
|
+
const query = queryStart === -1 ? '' : url.slice(queryStart + 1, hashStart === -1 ? url.length : hashStart);
|
|
891
|
+
const rawFragment = hashStart === -1 ? '' : url.slice(hashStart + 1);
|
|
892
|
+
let mutated = false;
|
|
893
|
+
let queryOut = query;
|
|
894
|
+
if (query) {
|
|
895
|
+
const r = redactKvPairs(query);
|
|
896
|
+
if (r.mutated) {
|
|
897
|
+
queryOut = r.out;
|
|
898
|
+
mutated = true;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// TR-04: redact the fragment when it carries k=v pairs. Two shapes: a pure implicit-flow
|
|
902
|
+
// fragment (`#access_token=…&refresh_token=…`) and a SPA hash-route with its own query
|
|
903
|
+
// (`#/reset?access_token=…`). Leave non-k=v fragments (`#section`, `#/route`) untouched.
|
|
904
|
+
let fragmentOut = rawFragment;
|
|
905
|
+
if (rawFragment) {
|
|
906
|
+
const fragQ = rawFragment.indexOf('?');
|
|
907
|
+
if (fragQ !== -1) {
|
|
908
|
+
const r = redactKvPairs(rawFragment.slice(fragQ + 1));
|
|
909
|
+
if (r.mutated) {
|
|
910
|
+
fragmentOut = rawFragment.slice(0, fragQ + 1) + r.out;
|
|
911
|
+
mutated = true;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
else if (/[\w-]+=/.test(rawFragment)) {
|
|
915
|
+
const r = redactKvPairs(rawFragment);
|
|
916
|
+
if (r.mutated) {
|
|
917
|
+
fragmentOut = r.out;
|
|
918
|
+
mutated = true;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (!mutated)
|
|
923
|
+
return url; // nothing sensitive matched → ship the exact original bytes
|
|
924
|
+
return base
|
|
925
|
+
+ (queryStart === -1 ? '' : '?' + queryOut)
|
|
926
|
+
+ (hashStart === -1 ? '' : '#' + fragmentOut);
|
|
927
|
+
}
|
|
928
|
+
catch (_a) {
|
|
929
|
+
return url; // malformed URL → ship as-is rather than drop/break the event
|
|
930
|
+
}
|
|
931
|
+
}
|
|
798
932
|
/**
|
|
799
933
|
* Deep merge objects
|
|
800
934
|
*/
|
|
@@ -925,16 +1059,18 @@ function getReferrerData() {
|
|
|
925
1059
|
return {};
|
|
926
1060
|
try {
|
|
927
1061
|
const url = new URL(referrer);
|
|
1062
|
+
// 9.A.4: the referrer can be our own /reset?token=… or an OAuth callback —
|
|
1063
|
+
// redact secret/PII param values before they're stamped onto the event.
|
|
928
1064
|
return {
|
|
929
|
-
referrer,
|
|
1065
|
+
referrer: redactUrl(referrer),
|
|
930
1066
|
referrer_host: url.hostname,
|
|
931
1067
|
referrer_path: url.pathname,
|
|
932
|
-
referrer_search: url.search,
|
|
1068
|
+
referrer_search: redactUrl(url.search),
|
|
933
1069
|
referrer_source: detectReferrerSource(url.hostname)
|
|
934
1070
|
};
|
|
935
1071
|
}
|
|
936
1072
|
catch (_a) {
|
|
937
|
-
return { referrer };
|
|
1073
|
+
return { referrer: redactUrl(referrer) };
|
|
938
1074
|
}
|
|
939
1075
|
}
|
|
940
1076
|
/**
|
|
@@ -972,6 +1108,9 @@ class IdentityManager {
|
|
|
972
1108
|
constructor(options = {}) {
|
|
973
1109
|
this.userId = null;
|
|
974
1110
|
this.sessionId = null;
|
|
1111
|
+
/** WEB-26: bumps whenever the persisted identity changes, so an in-flight
|
|
1112
|
+
* encrypted write can tell it has been superseded. */
|
|
1113
|
+
this.piiGeneration = 0;
|
|
975
1114
|
this.persistNewId = options.persistNewId !== false;
|
|
976
1115
|
this.anonymousId = this.getOrCreateAnonymousId();
|
|
977
1116
|
this.userId = this.getStoredUserId();
|
|
@@ -995,17 +1134,13 @@ class IdentityManager {
|
|
|
995
1134
|
this.setRootDomainCookie('__dl_visitor_id', anonymousId);
|
|
996
1135
|
return anonymousId;
|
|
997
1136
|
}
|
|
998
|
-
// 2.
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
// it from the address bar so a re-shared URL can't merge the next visitor. (FSR-50)
|
|
1137
|
+
// 2. Raw URL visitor IDs are never identity authority. A syntactically
|
|
1138
|
+
// valid ID is still replayable/attacker-chosen and can merge two people.
|
|
1139
|
+
// Strip the legacy parameter and generate a fresh local identity below.
|
|
1002
1140
|
try {
|
|
1003
1141
|
const urlParams = new URLSearchParams(window.location.search);
|
|
1004
|
-
|
|
1005
|
-
if (urlVisitorId && this.isValidAnonymousId(urlVisitorId)) {
|
|
1142
|
+
if (urlParams.has('_dl_vid')) {
|
|
1006
1143
|
this.stripUrlParam('_dl_vid');
|
|
1007
|
-
this.persistAnonymousId(urlVisitorId);
|
|
1008
|
-
return urlVisitorId;
|
|
1009
1144
|
}
|
|
1010
1145
|
}
|
|
1011
1146
|
catch (e) {
|
|
@@ -1029,12 +1164,17 @@ class IdentityManager {
|
|
|
1029
1164
|
storage.set('dl_anonymous_id', id);
|
|
1030
1165
|
}
|
|
1031
1166
|
/**
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1167
|
+
* TR-15 / FSR-107: tracking became allowed mid-session (optIn / consent grant). Start
|
|
1168
|
+
* persisting AND flush the current in-memory anon id to the cookie + localStorage now.
|
|
1169
|
+
* Without this, a visitor declined at init keeps a memory-only id, so that session's
|
|
1170
|
+
* events land under a visitor_id that vanishes on the next page load (attribution
|
|
1171
|
+
* fragmentation). Idempotent — a no-op once already persisting.
|
|
1035
1172
|
*/
|
|
1036
|
-
|
|
1037
|
-
|
|
1173
|
+
enablePersistence() {
|
|
1174
|
+
if (this.persistNewId)
|
|
1175
|
+
return;
|
|
1176
|
+
this.persistNewId = true;
|
|
1177
|
+
this.persistAnonymousId(this.anonymousId);
|
|
1038
1178
|
}
|
|
1039
1179
|
/**
|
|
1040
1180
|
* Remove a query param from the current URL via history.replaceState (best-effort).
|
|
@@ -1097,6 +1237,126 @@ class IdentityManager {
|
|
|
1097
1237
|
// corrupted by JSON.parse — both would fragment identity from the second page on.
|
|
1098
1238
|
return storage.getString('dl_user_id');
|
|
1099
1239
|
}
|
|
1240
|
+
/**
|
|
1241
|
+
* WEB-22 — is this user id personally identifying, and therefore not
|
|
1242
|
+
* something we may write to localStorage in the clear?
|
|
1243
|
+
*
|
|
1244
|
+
* The auto-identify path calls `identify(email, { email })`, so the raw
|
|
1245
|
+
* address became the `user_id` and was written unencrypted to `dl_user_id`.
|
|
1246
|
+
* Measured 2026-07-25: on the four workspaces using auto-identify, **every**
|
|
1247
|
+
* event carrying a `user_id` had an email in that column (20,502 / 20,502 on
|
|
1248
|
+
* `f6260736` over 7 days) — while the surrounding code went to real lengths to
|
|
1249
|
+
* encrypt the *same* address in `dl_auto_identified_email`.
|
|
1250
|
+
*
|
|
1251
|
+
* Deliberately narrow: an email is the case that actually occurs and the one
|
|
1252
|
+
* the SDK itself creates. Opaque application ids — the overwhelming majority —
|
|
1253
|
+
* keep the existing fast, synchronous, plaintext path with no behaviour change.
|
|
1254
|
+
*/
|
|
1255
|
+
static looksLikePII(userId) {
|
|
1256
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userId);
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Persist the user id, encrypting it when it is PII.
|
|
1260
|
+
*
|
|
1261
|
+
* Three-way, and the ordering matters:
|
|
1262
|
+
* - not PII → plaintext `dl_user_id`, exactly as before (sync, always works)
|
|
1263
|
+
* - PII + crypto → encrypted `dl_user_id_pii`, and the plaintext key is REMOVED
|
|
1264
|
+
* - PII, no crypto → memory only, and both keys removed
|
|
1265
|
+
*
|
|
1266
|
+
* That last branch is the deliberate trade. `crypto.subtle` is absent on
|
|
1267
|
+
* http:// and old browsers, and index.ts is explicit that encryption is for
|
|
1268
|
+
* PII-at-rest only and "must NOT gate event delivery". So we never block an
|
|
1269
|
+
* event — `this.userId` is set in memory first and every event still carries
|
|
1270
|
+
* it — we only decline to *persist* an address we cannot protect. The cost is
|
|
1271
|
+
* that a returning visitor on an insecure origin is re-identified by the next
|
|
1272
|
+
* `identify()` call rather than restored from storage; the alternative is
|
|
1273
|
+
* writing their email to disk in the clear, which is what this fixes.
|
|
1274
|
+
*/
|
|
1275
|
+
persistUserId(userId) {
|
|
1276
|
+
if (!IdentityManager.looksLikePII(userId)) {
|
|
1277
|
+
storage.set('dl_user_id', userId);
|
|
1278
|
+
// WEB-26: a previous PII user's encrypted id must not linger once an
|
|
1279
|
+
// opaque id takes over. Reachable when identify('opaque') runs before
|
|
1280
|
+
// hydrateEncryptedUserId() has populated this.userId, so index.ts sees no
|
|
1281
|
+
// current user and skips its account-switch reset().
|
|
1282
|
+
this.piiGeneration += 1;
|
|
1283
|
+
storage.remove('dl_user_id_pii');
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
// Never leave a plaintext copy behind — including one written by a previous
|
|
1287
|
+
// SDK version before this fix existed.
|
|
1288
|
+
storage.remove('dl_user_id');
|
|
1289
|
+
// WEB-26: the encrypted write is async, and reset()/optOut()/consent
|
|
1290
|
+
// withdrawal are not. Without a generation guard this sequence resurrects a
|
|
1291
|
+
// logged-out user's email: identify(email) → write pending →
|
|
1292
|
+
// remove('dl_user_id_pii') deletes NOTHING because the write has not landed
|
|
1293
|
+
// → the write lands → next page load hydrates the previous user's address
|
|
1294
|
+
// and every event ships user_id = their email. Stamp the write and discard
|
|
1295
|
+
// it if the identity moved on while it was in flight.
|
|
1296
|
+
const generation = ++this.piiGeneration;
|
|
1297
|
+
storage.setEncrypted('dl_user_id_pii', userId)
|
|
1298
|
+
.then(() => {
|
|
1299
|
+
if (generation !== this.piiGeneration)
|
|
1300
|
+
storage.remove('dl_user_id_pii');
|
|
1301
|
+
})
|
|
1302
|
+
.catch(() => {
|
|
1303
|
+
// No crypto.subtle (http://, legacy browser) or a storage failure. Drop
|
|
1304
|
+
// the at-rest copy rather than falling back to plaintext PII — but only
|
|
1305
|
+
// if this write is still the current one, or we would delete a newer
|
|
1306
|
+
// user's successfully-written value.
|
|
1307
|
+
if (generation === this.piiGeneration)
|
|
1308
|
+
storage.remove('dl_user_id_pii');
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Restore a PII user id that was persisted encrypted. Async by necessity, so
|
|
1313
|
+
* it cannot run in the constructor; index.ts calls it during init, right after
|
|
1314
|
+
* `dataEncryption.initialize()`.
|
|
1315
|
+
*
|
|
1316
|
+
* Never overwrites an id already established this page load — an explicit
|
|
1317
|
+
* `identify()` that has already run is fresher than anything on disk.
|
|
1318
|
+
*/
|
|
1319
|
+
hydrateEncryptedUserId() {
|
|
1320
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
1321
|
+
// WEB-26 (a) — migrate a plaintext address written by <= 1.7.7.
|
|
1322
|
+
//
|
|
1323
|
+
// persistUserId() only removes `dl_user_id` when identify() runs again, and
|
|
1324
|
+
// for the exact population this fix targets it never does:
|
|
1325
|
+
// AutoIdentifyManager.initialize() returns early while
|
|
1326
|
+
// `dl_auto_identified_email` exists (auto-identify.ts:88), so the callback
|
|
1327
|
+
// never fires. Without this the raw email would sit in localStorage forever
|
|
1328
|
+
// on every already-auto-identified browser.
|
|
1329
|
+
const legacy = storage.getString('dl_user_id');
|
|
1330
|
+
if (legacy && IdentityManager.looksLikePII(legacy)) {
|
|
1331
|
+
if (!this.userId)
|
|
1332
|
+
this.userId = legacy;
|
|
1333
|
+
this.persistUserId(legacy);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
// WEB-26 (b) — recover a write that was dropped because encryption was not
|
|
1337
|
+
// keyed yet. index.ts sets `initialized = true` before initializeAsync()
|
|
1338
|
+
// finishes, so the documented `init(); identify(email)` pattern can reach
|
|
1339
|
+
// persistUserId() before dataEncryption has a key; that write rejects and
|
|
1340
|
+
// nothing is persisted. By here the key exists, so retry once.
|
|
1341
|
+
if (this.userId && IdentityManager.looksLikePII(this.userId)) {
|
|
1342
|
+
const alreadyStored = yield storage.getEncrypted('dl_user_id_pii', null);
|
|
1343
|
+
if (!alreadyStored)
|
|
1344
|
+
this.persistUserId(this.userId);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
if (this.userId)
|
|
1348
|
+
return;
|
|
1349
|
+
try {
|
|
1350
|
+
const stored = yield storage.getEncrypted('dl_user_id_pii', null);
|
|
1351
|
+
if (typeof stored === 'string' && stored && !this.userId) {
|
|
1352
|
+
this.userId = stored;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
catch (_a) {
|
|
1356
|
+
// Undecryptable (rotated key, corrupt blob) — stay anonymous.
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1100
1360
|
/**
|
|
1101
1361
|
* Get the anonymous ID
|
|
1102
1362
|
*/
|
|
@@ -1145,8 +1405,9 @@ class IdentityManager {
|
|
|
1145
1405
|
}
|
|
1146
1406
|
const previousUserId = this.userId;
|
|
1147
1407
|
this.userId = userId;
|
|
1148
|
-
// Persist for future sessions
|
|
1149
|
-
|
|
1408
|
+
// Persist for future sessions. WEB-22: encrypted when the id is PII (the
|
|
1409
|
+
// auto-identify path makes it the raw email); plaintext otherwise.
|
|
1410
|
+
this.persistUserId(userId);
|
|
1150
1411
|
// Return identity link data (will be sent as $identify event)
|
|
1151
1412
|
return {
|
|
1152
1413
|
anonymous_id: this.anonymousId,
|
|
@@ -1169,7 +1430,7 @@ class IdentityManager {
|
|
|
1169
1430
|
// Update current user ID if aliasing to current anonymous ID
|
|
1170
1431
|
if (!previousId || previousId === this.anonymousId) {
|
|
1171
1432
|
this.userId = userId;
|
|
1172
|
-
|
|
1433
|
+
this.persistUserId(userId);
|
|
1173
1434
|
}
|
|
1174
1435
|
return aliasData;
|
|
1175
1436
|
}
|
|
@@ -1179,7 +1440,12 @@ class IdentityManager {
|
|
|
1179
1440
|
*/
|
|
1180
1441
|
reset() {
|
|
1181
1442
|
this.userId = null;
|
|
1443
|
+
// WEB-26: invalidate any encrypted write still in flight.
|
|
1444
|
+
this.piiGeneration += 1;
|
|
1182
1445
|
storage.remove('dl_user_id');
|
|
1446
|
+
// WEB-22: the encrypted PII copy must go too, or a logout would leave the
|
|
1447
|
+
// previous user's email at rest.
|
|
1448
|
+
storage.remove('dl_user_id_pii');
|
|
1183
1449
|
storage.remove('dl_user_traits');
|
|
1184
1450
|
// Generate new anonymous ID for privacy
|
|
1185
1451
|
this.anonymousId = `anon_${generateUUID()}`;
|
|
@@ -1506,6 +1772,12 @@ class AttributionManager {
|
|
|
1506
1772
|
'gbraid', // Google Ads (iOS)
|
|
1507
1773
|
'wbraid', // Google Ads (web)
|
|
1508
1774
|
'ttclid', // TikTok
|
|
1775
|
+
// WEB-28: OpenAI Ads. Captured by iOS and React Native since launch but
|
|
1776
|
+
// never by the web SDK, so every OpenAI Ads *web* conversion delivered with
|
|
1777
|
+
// attribution_type: none unless the customer passed the value by hand.
|
|
1778
|
+
// Placed after the Google block to match the mobile SDKs' ordering — the
|
|
1779
|
+
// first click id present wins, and this must not outrank fbclid/gclid.
|
|
1780
|
+
'oppref', // OpenAI Ads
|
|
1509
1781
|
'msclkid', // Microsoft/Bing
|
|
1510
1782
|
'twclid', // Twitter/X
|
|
1511
1783
|
'li_fat_id', // LinkedIn
|
|
@@ -1525,6 +1797,10 @@ class AttributionManager {
|
|
|
1525
1797
|
this.CLICK_ID_ALIASES = {
|
|
1526
1798
|
ScCid: 'sclid',
|
|
1527
1799
|
sccid: 'sclid',
|
|
1800
|
+
// Impact's real landing param is `irclickid`; capture it and normalize to the canonical
|
|
1801
|
+
// `irclid` that ingest / the MV / the source map (`irclid→impact`) all key on. Without
|
|
1802
|
+
// this, real Impact affiliate clicks were dark on web (only bare `irclid` was captured).
|
|
1803
|
+
irclickid: 'irclid',
|
|
1528
1804
|
};
|
|
1529
1805
|
// Default tracked params matching dl.js
|
|
1530
1806
|
this.DEFAULT_TRACKED_PARAMS = [
|
|
@@ -1535,9 +1811,18 @@ class AttributionManager {
|
|
|
1535
1811
|
'medium', // Generic medium (non-UTM)
|
|
1536
1812
|
'gad_source' // Google Ads source parameter
|
|
1537
1813
|
];
|
|
1814
|
+
// TR-03: ad-cookie fields (Meta/Google Ads/TikTok/Snap) that are MARKETING-scoped. When
|
|
1815
|
+
// marketing consent is declined these are neither synthesized/written nor shipped. Google
|
|
1816
|
+
// Analytics cookies (_ga/_gid) and utm_* stay — they're analytics-scoped.
|
|
1817
|
+
this.MARKETING_COOKIES = ['_fbp', '_fbc', '_gcl_aw', '_gcl_dc', '_gcl_gb', '_gcl_ha', '_gac', '_ttp', '_ttc', '_scid'];
|
|
1538
1818
|
this.attributionWindow = options.attributionWindow || 90 * 24 * 60 * 60 * 1000; // 90 days (increased from 30 for B2B sales cycles)
|
|
1539
1819
|
// Merge default tracked params with user-provided ones
|
|
1540
1820
|
this.trackedParams = [...this.DEFAULT_TRACKED_PARAMS, ...(options.trackedParams || [])];
|
|
1821
|
+
this.marketingAllowedFn = options.marketingAllowed;
|
|
1822
|
+
}
|
|
1823
|
+
// TR-03: default (no predicate / no signal) = allowed → byte-identical to prior behavior.
|
|
1824
|
+
isMarketingAllowed() {
|
|
1825
|
+
return this.marketingAllowedFn ? this.marketingAllowedFn() !== false : true;
|
|
1541
1826
|
}
|
|
1542
1827
|
/**
|
|
1543
1828
|
* Clear query params cache (called on page navigation)
|
|
@@ -1608,13 +1893,15 @@ class AttributionManager {
|
|
|
1608
1893
|
attribution[param] = value;
|
|
1609
1894
|
}
|
|
1610
1895
|
}
|
|
1611
|
-
// Capture referrer
|
|
1896
|
+
// Capture referrer. 9.A.4: redact secret/PII query-param values (reset tokens,
|
|
1897
|
+
// OAuth codes, emails) — this attribution object is stamped onto every event AND
|
|
1898
|
+
// persisted into dl_first_touch/dl_last_touch, so it must be clean at capture.
|
|
1612
1899
|
if (document.referrer) {
|
|
1613
|
-
attribution.referrer = document.referrer;
|
|
1900
|
+
attribution.referrer = redactUrl(document.referrer);
|
|
1614
1901
|
attribution.referrerHost = this.extractHostname(document.referrer);
|
|
1615
1902
|
}
|
|
1616
|
-
// Capture landing page
|
|
1617
|
-
attribution.landingPage = window.location.href;
|
|
1903
|
+
// Capture landing page (redacted — see referrer note above)
|
|
1904
|
+
attribution.landingPage = redactUrl(window.location.href);
|
|
1618
1905
|
attribution.landingPath = window.location.pathname;
|
|
1619
1906
|
// Determine source if not explicitly set
|
|
1620
1907
|
if (!attribution.source) {
|
|
@@ -1710,6 +1997,10 @@ class AttributionManager {
|
|
|
1710
1997
|
captureAdCookies() {
|
|
1711
1998
|
var _a, _b;
|
|
1712
1999
|
const adCookies = {};
|
|
2000
|
+
// TR-03: when marketing consent is declined, do NOT synthesize/write the Meta/Google ad
|
|
2001
|
+
// cookies below (writing _fbp/_fbc is active marketing use). Existing cookies are still
|
|
2002
|
+
// read here but stripped from the event payload in getAttributionData().
|
|
2003
|
+
const marketingAllowed = this.isMarketingAllowed();
|
|
1713
2004
|
// Facebook/Meta cookies
|
|
1714
2005
|
adCookies._fbp = cookies.get('_fbp');
|
|
1715
2006
|
adCookies._fbc = cookies.get('_fbc');
|
|
@@ -1728,8 +2019,8 @@ class AttributionManager {
|
|
|
1728
2019
|
// Snapchat first-party cookie (Snap Pixel sets _scid). Flows to the server as
|
|
1729
2020
|
// cookies._scid and is sent on the Snap CAPI event as sc_cookie1 (raw).
|
|
1730
2021
|
adCookies._scid = cookies.get('_scid');
|
|
1731
|
-
// Generate _fbp if missing (Facebook browser ID)
|
|
1732
|
-
if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
|
|
2022
|
+
// Generate _fbp if missing (Facebook browser ID). TR-03: only when marketing is allowed.
|
|
2023
|
+
if (marketingAllowed && !adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
|
|
1733
2024
|
const timestamp = Date.now();
|
|
1734
2025
|
// Meta's _fbp format is fb.1.<creationTimeMs>.<randomNumber> where the last
|
|
1735
2026
|
// segment MUST be a decimal integer. The old base36 string was non-conformant —
|
|
@@ -1753,7 +2044,8 @@ class AttributionManager {
|
|
|
1753
2044
|
// Shopify cart / server-side rebuild forward it as a time, so its format must not
|
|
1754
2045
|
// change.
|
|
1755
2046
|
const fbclid = this.getCurrentFbclid();
|
|
1756
|
-
|
|
2047
|
+
// TR-03: skip _fbc synthesis/refresh (cookie write) when marketing is declined.
|
|
2048
|
+
if (marketingAllowed && fbclid) {
|
|
1757
2049
|
const existingFbc = adCookies._fbc; // captured above (cookies.get('_fbc'))
|
|
1758
2050
|
const embeddedFbclid = this.extractFbclidFromFbc(existingFbc);
|
|
1759
2051
|
if (!existingFbc) {
|
|
@@ -1783,7 +2075,7 @@ class AttributionManager {
|
|
|
1783
2075
|
// (gclid has no synthesized-and-going-stale artifact like _fbc, and Google does not
|
|
1784
2076
|
// validate a creationTime window the way Meta does, so this stays once-only.)
|
|
1785
2077
|
const gclid = this.hasClickId('gclid') ? ((_b = (_a = this.queryParamsCache) === null || _a === void 0 ? void 0 : _a.gclid) !== null && _b !== void 0 ? _b : null) : null;
|
|
1786
|
-
if (gclid && !cookies.get('_dl_gclid_at')) {
|
|
2078
|
+
if (marketingAllowed && gclid && !cookies.get('_dl_gclid_at')) {
|
|
1787
2079
|
cookies.set('_dl_gclid_at', String(Date.now()), 90);
|
|
1788
2080
|
}
|
|
1789
2081
|
// Filter out null values for cleaner data
|
|
@@ -1834,8 +2126,21 @@ class AttributionManager {
|
|
|
1834
2126
|
// The old `hasCurrentAttribution` (truthy because source is always at least 'direct')
|
|
1835
2127
|
// made the fallback below dead code AND caused storeLastTouch to overwrite a real
|
|
1836
2128
|
// paid last-touch with 'direct'/'none' on the next internal navigation. (WEB NEW-1)
|
|
2129
|
+
// `lyr` counts as a real signal. It is the Datalyr tracking-link tag — the one
|
|
2130
|
+
// attribution parameter the product itself tells customers to put on a URL — and
|
|
2131
|
+
// omitting it meant a bare `?lyr=X` landing (no UTMs, no click id) scored FALSE:
|
|
2132
|
+
// the fallback below then replaced `current` wholesale with the stored touch,
|
|
2133
|
+
// discarding the tag that had just been parsed, and neither store* call ran, so it
|
|
2134
|
+
// never persisted either. Measured 2026-07-25 on the one workspace whose links are
|
|
2135
|
+
// bare: 96 landing URLs carried `lyr=`, 93 events kept it — 3 lost (3.1%), all of
|
|
2136
|
+
// them returning visitors (a first-time visitor has no stored touch to be replaced
|
|
2137
|
+
// by, which is the only reason the number is 3 and not 96). The edge worker's own
|
|
2138
|
+
// `buildDestination` emits exactly this URL shape when a link defines no UTMs.
|
|
2139
|
+
// iOS (AttributionManager.swift) and React Native persist `lyr` unconditionally;
|
|
2140
|
+
// this brings web into line.
|
|
1837
2141
|
const hasRealAttribution = !!(current.clickId ||
|
|
1838
2142
|
current.campaign ||
|
|
2143
|
+
current.lyr ||
|
|
1839
2144
|
(current.source && current.source !== 'direct'));
|
|
1840
2145
|
// Direct / internal navigation: fall back to persisted attribution so the event
|
|
1841
2146
|
// isn't mis-attributed to 'direct'. FSR-48: prefer the LAST touch (the most recent
|
|
@@ -1861,7 +2166,7 @@ class AttributionManager {
|
|
|
1861
2166
|
if (hasRealAttribution) {
|
|
1862
2167
|
this.storeLastTouch(current);
|
|
1863
2168
|
}
|
|
1864
|
-
|
|
2169
|
+
const result = Object.assign(Object.assign(Object.assign({}, current), adCookies), {
|
|
1865
2170
|
// First touch (with snake_case aliases)
|
|
1866
2171
|
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,
|
|
1867
2172
|
// Last touch (with snake_case aliases)
|
|
@@ -1872,6 +2177,21 @@ class AttributionManager {
|
|
|
1872
2177
|
: 0, daysSinceFirstTouch: (firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp)
|
|
1873
2178
|
? Math.floor((Date.now() - firstTouch.timestamp) / 86400000)
|
|
1874
2179
|
: 0 });
|
|
2180
|
+
// TR-03: strip MARKETING-scoped signals (click IDs + ad cookies) from the event payload
|
|
2181
|
+
// when marketing consent is declined — analytics-scoped fields (utm_*, source/medium/
|
|
2182
|
+
// campaign, first/last touch, _ga/_gid) stay. Live predicate → a later grant restores
|
|
2183
|
+
// them on the next event. Synthesis of _fbp/_fbc was already skipped in captureAdCookies.
|
|
2184
|
+
if (!this.isMarketingAllowed()) {
|
|
2185
|
+
const marketingKeys = [
|
|
2186
|
+
...this.CLICK_IDS,
|
|
2187
|
+
...Object.values(this.CLICK_ID_ALIASES),
|
|
2188
|
+
'clickId', 'clickIdType',
|
|
2189
|
+
...this.MARKETING_COOKIES,
|
|
2190
|
+
];
|
|
2191
|
+
for (const k of marketingKeys)
|
|
2192
|
+
delete result[k];
|
|
2193
|
+
}
|
|
2194
|
+
return result;
|
|
1875
2195
|
}
|
|
1876
2196
|
/**
|
|
1877
2197
|
* Determine source from attribution data
|
|
@@ -1882,13 +2202,19 @@ class AttributionManager {
|
|
|
1882
2202
|
const clickIdSources = {
|
|
1883
2203
|
fbclid: 'facebook',
|
|
1884
2204
|
gclid: 'google',
|
|
2205
|
+
gbraid: 'google', // 9.A.6: Google Ads (iOS) — was falling through to 'paid'
|
|
2206
|
+
wbraid: 'google', // 9.A.6: Google Ads (web)
|
|
1885
2207
|
ttclid: 'tiktok',
|
|
1886
2208
|
msclkid: 'bing',
|
|
1887
2209
|
twclid: 'twitter',
|
|
1888
2210
|
li_fat_id: 'linkedin',
|
|
1889
2211
|
sclid: 'snapchat',
|
|
1890
2212
|
dclid: 'doubleclick',
|
|
1891
|
-
epik: 'pinterest'
|
|
2213
|
+
epik: 'pinterest',
|
|
2214
|
+
rdt_cid: 'reddit', // 9.A.6
|
|
2215
|
+
obclid: 'outbrain', // 9.A.6
|
|
2216
|
+
irclid: 'impact', // 9.A.6: Impact Radius
|
|
2217
|
+
ko_click_id: 'klaviyo' // 9.A.6
|
|
1892
2218
|
};
|
|
1893
2219
|
return clickIdSources[attribution.clickIdType] || 'paid';
|
|
1894
2220
|
}
|
|
@@ -2014,15 +2340,20 @@ class AttributionManager {
|
|
|
2014
2340
|
const DEFAULT_CRITICAL_EVENTS = ['purchase', 'signup', 'subscribe', 'lead', 'conversion'];
|
|
2015
2341
|
// Default high priority events that use faster batching
|
|
2016
2342
|
const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_item', 'search'];
|
|
2017
|
-
//
|
|
2018
|
-
//
|
|
2019
|
-
//
|
|
2343
|
+
// WEB-23: how long to stop sending after a 403 (origin not allowed). Long enough not to
|
|
2344
|
+
// hammer a rejecting origin, short enough that a server-side config fix is picked up
|
|
2345
|
+
// within the same visit. A 403 carries no Retry-After, so this is fixed.
|
|
2346
|
+
const ORIGIN_REJECTED_BACKOFF_MS = 5 * 60 * 1000;
|
|
2347
|
+
// Backpressure, not failure — routed to the offline queue WITHOUT retrying (which would
|
|
2348
|
+
// storm an already-overloaded server) and gated behind rateLimitedUntil. Raised for a 429
|
|
2349
|
+
// and, since WEB-23, for a 403: both mean "not now", not "never". See sendBatch.
|
|
2020
2350
|
class RateLimitError extends Error {
|
|
2021
2351
|
}
|
|
2022
|
-
// A permanent (non-retryable) failure — a 4xx other than 408/429
|
|
2023
|
-
//
|
|
2024
|
-
// queue forever and hammers ingest. Tagged so the send paths DROP the batch
|
|
2025
|
-
// unshifting it back to the head. (FSR-55
|
|
2352
|
+
// A permanent (non-retryable) failure — a 4xx other than 403/408/429, i.e. an invalid
|
|
2353
|
+
// event shape (400) or bad auth (401). Retrying or parking it just head-of-line-blocks
|
|
2354
|
+
// the offline queue forever and hammers ingest. Tagged so the send paths DROP the batch
|
|
2355
|
+
// instead of unshifting it back to the head. (FSR-55, narrowed by WEB-23 — a 403 is
|
|
2356
|
+
// server *configuration* state and does change, so it is no longer in this class.)
|
|
2026
2357
|
class PermanentError extends Error {
|
|
2027
2358
|
constructor(status, message) {
|
|
2028
2359
|
super(message || `HTTP ${status}`);
|
|
@@ -2045,6 +2376,11 @@ class EventQueue {
|
|
|
2045
2376
|
this.inFlight = []; // FIXED (WEB-3): batch currently in _flight's keepalive fetch — forceFlush must NOT re-beacon it
|
|
2046
2377
|
this.enabled = true; // FIXED (consent): when false (opt-out / withdrawn analytics consent) all enqueue/flush/drain is a no-op
|
|
2047
2378
|
this.rateLimitedUntil = 0; // FIXED (429): skip flush/drain until the server's Retry-After window passes
|
|
2379
|
+
// WEB-23: permanent drops used to be invisible — a debug-gated log line and
|
|
2380
|
+
// nothing else, so 11 days of 403s looked identical to a healthy queue. Counted
|
|
2381
|
+
// here and exposed via getStats() so a drop is at least observable.
|
|
2382
|
+
this.droppedEventCount = 0;
|
|
2383
|
+
this.lastDropStatus = null;
|
|
2048
2384
|
// Coerce numeric config to sane values. The old `config.X || default` both turned a
|
|
2049
2385
|
// legitimate 0 into the default AND let invalid values through — a NEGATIVE batchSize
|
|
2050
2386
|
// made processOfflineQueue's `splice(0, -1)` loop forever and FREEZE the host tab.
|
|
@@ -2270,7 +2606,12 @@ class EventQueue {
|
|
|
2270
2606
|
// FSR-55: don't park a permanently-rejected batch — it would never succeed and
|
|
2271
2607
|
// would block the offline drain. Drop it.
|
|
2272
2608
|
if (error instanceof PermanentError) {
|
|
2273
|
-
this.
|
|
2609
|
+
this.droppedEventCount += events.length;
|
|
2610
|
+
this.lastDropStatus = error.status;
|
|
2611
|
+
// console.warn, not this.log: a silent drop is the failure mode that let the
|
|
2612
|
+
// 2026-07-13 outage run for 11 days. This must be visible without debug mode.
|
|
2613
|
+
console.warn(`[Datalyr Queue] Dropped ${events.length} events — permanent error ${error.status}. ` +
|
|
2614
|
+
`${this.droppedEventCount} dropped this session.`);
|
|
2274
2615
|
}
|
|
2275
2616
|
else {
|
|
2276
2617
|
this.moveToOfflineQueue(events);
|
|
@@ -2332,9 +2673,24 @@ class EventQueue {
|
|
|
2332
2673
|
this.log(`Rate limited; backing off ${retryAfter}s`);
|
|
2333
2674
|
throw new RateLimitError('Rate limited (429)');
|
|
2334
2675
|
}
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
//
|
|
2676
|
+
// WEB-23: a 403 is NOT permanent — it is server *configuration* state, and it
|
|
2677
|
+
// changes. On 2026-07-13 an allowed_origins regression made ingest reject three
|
|
2678
|
+
// workspaces with 403; the config was fixed 11 days later, but every event sent
|
|
2679
|
+
// in between had already been dropped on the floor by the FSR-55 rule below.
|
|
2680
|
+
// Eleven days of data, unrecoverable, with only a debug log to show for it.
|
|
2681
|
+
//
|
|
2682
|
+
// Treated like 429 instead: park the events offline behind a backoff window, so
|
|
2683
|
+
// we neither hammer a rejecting origin (FSR-55's real concern) nor discard data
|
|
2684
|
+
// that would have been accepted an hour later. The window is fixed because a 403
|
|
2685
|
+
// carries no Retry-After.
|
|
2686
|
+
if (response.status === 403) {
|
|
2687
|
+
this.rateLimitedUntil = Date.now() + ORIGIN_REJECTED_BACKOFF_MS;
|
|
2688
|
+
this.log(`Origin rejected (403); backing off ${ORIGIN_REJECTED_BACKOFF_MS / 1000}s and parking events`);
|
|
2689
|
+
throw new RateLimitError('Origin rejected (403)');
|
|
2690
|
+
}
|
|
2691
|
+
// FSR-55: a 4xx other than 403/408 (timeout) is PERMANENT — invalid event shape
|
|
2692
|
+
// (400) or auth (401). Retrying / parking it just blocks the queue and hammers
|
|
2693
|
+
// ingest forever. Tag it so the caller drops the batch.
|
|
2338
2694
|
if (response.status >= 400 && response.status < 500 && response.status !== 408) {
|
|
2339
2695
|
throw new PermanentError(response.status, `HTTP ${response.status}: ${response.statusText}`);
|
|
2340
2696
|
}
|
|
@@ -2421,9 +2777,25 @@ class EventQueue {
|
|
|
2421
2777
|
// purge. Gating here (a persistence sink, not just the send sinks) closes it.
|
|
2422
2778
|
if (!this.enabled)
|
|
2423
2779
|
return;
|
|
2424
|
-
//
|
|
2780
|
+
// WEB-24. This early return used to `return` outright — losing the batch, because
|
|
2781
|
+
// _flush() has ALREADY spliced these events out of the live queue before calling
|
|
2782
|
+
// here, so they would exist in neither queue.
|
|
2783
|
+
//
|
|
2784
|
+
// Verified 2026-07-25 that the loss is NOT currently reachable: this method is the
|
|
2785
|
+
// only user of offlineQueueLock, its critical section is fully synchronous (push,
|
|
2786
|
+
// splice, then a synchronous saveOfflineQueue → storage.set), and JS is
|
|
2787
|
+
// single-threaded — so the flag can never be observed set on entry. The path is
|
|
2788
|
+
// dead code today, and the review's "drops the batch on lock contention" is REFUTED
|
|
2789
|
+
// as a live loss path.
|
|
2790
|
+
//
|
|
2791
|
+
// It is kept and made safe anyway, because it is one `await` away from being live:
|
|
2792
|
+
// the moment saveOfflineQueue becomes async (IndexedDB, encrypted-at-rest storage,
|
|
2793
|
+
// anything), contention becomes real and silent data loss returns instantly.
|
|
2794
|
+
// Enqueue first, then bail — the events are never dropped either way.
|
|
2425
2795
|
if (this.offlineQueueLock) {
|
|
2426
|
-
console.warn('[Datalyr Queue] Offline queue
|
|
2796
|
+
console.warn('[Datalyr Queue] Offline queue busy; buffering events without persisting');
|
|
2797
|
+
if (events)
|
|
2798
|
+
this.offlineQueue.push(...events);
|
|
2427
2799
|
return;
|
|
2428
2800
|
}
|
|
2429
2801
|
// Acquire lock
|
|
@@ -2442,6 +2814,10 @@ class EventQueue {
|
|
|
2442
2814
|
if (this.offlineQueue.length > this.config.maxOfflineQueueSize) {
|
|
2443
2815
|
const excess = this.offlineQueue.length - this.config.maxOfflineQueueSize;
|
|
2444
2816
|
this.offlineQueue.splice(0, excess); // Remove oldest events
|
|
2817
|
+
// WEB-27: overflow is data loss too, and a sustained 403/park loop is
|
|
2818
|
+
// exactly what produces it. Count it rather than trimming silently.
|
|
2819
|
+
this.droppedEventCount += excess;
|
|
2820
|
+
console.warn(`[Datalyr Queue] Offline queue full — dropped ${excess} oldest event(s).`);
|
|
2445
2821
|
}
|
|
2446
2822
|
this.saveOfflineQueue();
|
|
2447
2823
|
}
|
|
@@ -2514,6 +2890,16 @@ class EventQueue {
|
|
|
2514
2890
|
// two of them could splice the same offlineQueue concurrently and double-send.
|
|
2515
2891
|
if (!this.enabled)
|
|
2516
2892
|
return;
|
|
2893
|
+
// TR-15: re-check consent at DRAIN time. A returning DECLINED visitor whose consent
|
|
2894
|
+
// signal loads asynchronously (Shopify customerPrivacy) fires no visitorConsentCollected
|
|
2895
|
+
// event, so `enabled` stayed at its fail-open init value. If consent now says no, latch
|
|
2896
|
+
// off and PURGE the persisted backlog instead of draining it (a later grant re-enables
|
|
2897
|
+
// via setEnabled). Mirrors the withdrawal purge in optOut()/setConsent().
|
|
2898
|
+
if (this.consentCheck && this.consentCheck() === false) {
|
|
2899
|
+
this.enabled = false;
|
|
2900
|
+
this.clearOffline();
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2517
2903
|
if (Date.now() < this.rateLimitedUntil)
|
|
2518
2904
|
return; // FIXED (429): honor Retry-After window
|
|
2519
2905
|
if (this.offlineProcessing)
|
|
@@ -2540,7 +2926,15 @@ class EventQueue {
|
|
|
2540
2926
|
// that would block every event behind it forever and hammer ingest every tick.
|
|
2541
2927
|
// Drop it (already spliced off the front) and keep draining the rest.
|
|
2542
2928
|
if (error instanceof PermanentError) {
|
|
2543
|
-
|
|
2929
|
+
// WEB-27: count and surface these too. WEB-23's whole effect is to
|
|
2930
|
+
// PARK more batches offline, so the drain — not the live flush — is
|
|
2931
|
+
// where drops now predominantly happen. Counting only _flush left the
|
|
2932
|
+
// dominant loss path invisible, which is the exact failure mode the
|
|
2933
|
+
// counter was added to end.
|
|
2934
|
+
this.droppedEventCount += batch.length;
|
|
2935
|
+
this.lastDropStatus = error.status;
|
|
2936
|
+
console.warn(`[Datalyr Queue] Dropped ${batch.length} offline events — permanent error ${error.status}. ` +
|
|
2937
|
+
`${this.droppedEventCount} dropped this session.`);
|
|
2544
2938
|
this.saveOfflineQueue();
|
|
2545
2939
|
continue;
|
|
2546
2940
|
}
|
|
@@ -2573,6 +2967,23 @@ class EventQueue {
|
|
|
2573
2967
|
getOfflineQueueSize() {
|
|
2574
2968
|
return this.offlineQueue.length;
|
|
2575
2969
|
}
|
|
2970
|
+
/**
|
|
2971
|
+
* WEB-23: queue health, including events the SDK gave up on.
|
|
2972
|
+
*
|
|
2973
|
+
* `droppedEvents` is the number this session discarded as permanently rejected
|
|
2974
|
+
* (a 400/401). It exists because a silent drop is precisely the failure mode
|
|
2975
|
+
* that let the 2026-07-13 `allowed_origins` outage run for eleven days looking
|
|
2976
|
+
* exactly like a healthy queue. A non-zero value here means data was lost.
|
|
2977
|
+
*/
|
|
2978
|
+
getStats() {
|
|
2979
|
+
return {
|
|
2980
|
+
queued: this.queue.length,
|
|
2981
|
+
offline: this.offlineQueue.length,
|
|
2982
|
+
droppedEvents: this.droppedEventCount,
|
|
2983
|
+
lastDropStatus: this.lastDropStatus,
|
|
2984
|
+
backoffUntil: this.rateLimitedUntil,
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2576
2987
|
/**
|
|
2577
2988
|
* Get network status
|
|
2578
2989
|
*/
|
|
@@ -2605,10 +3016,13 @@ class EventQueue {
|
|
|
2605
3016
|
* - Non-terminal (tab switch): deliver the LIVE queue via a response-checked keepalive
|
|
2606
3017
|
* fetch (flush) and response-check-drain the offline backlog (processOfflineQueue) —
|
|
2607
3018
|
* neither erases the backlog on failure. The destructive beacon path is not used.
|
|
2608
|
-
* - Terminal (unload):
|
|
2609
|
-
* and NEVER storage.remove() on a mere beacon enqueue.
|
|
2610
|
-
*
|
|
2611
|
-
*
|
|
3019
|
+
* - Terminal (unload): PERSIST everything first (live + the already-persisted offline
|
|
3020
|
+
* backlog) and NEVER storage.remove() on a mere beacon enqueue. Beacon ONLY the LIVE
|
|
3021
|
+
* queue best-effort — NOT the offline backlog (TR-13): the backlog is already durable and
|
|
3022
|
+
* the next-load drain delivers it exactly once, so beaconing it here too risked a
|
|
3023
|
+
* delivered-now-AND-re-drained-next-load double-send that, when >6h apart, outlives
|
|
3024
|
+
* ingest's dedup window → a duplicate purchase. The next page load's response-checked
|
|
3025
|
+
* drain (normal fetch, no 64KB cap) is the source of truth and clears the copy.
|
|
2612
3026
|
* - Both paths honor rateLimitedUntil (don't beacon/flush into the backoff window).
|
|
2613
3027
|
*
|
|
2614
3028
|
* Still excludes the in-flight _flush batch (its keepalive fetch already carries it,
|
|
@@ -2650,6 +3064,14 @@ class EventQueue {
|
|
|
2650
3064
|
}
|
|
2651
3065
|
storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
|
|
2652
3066
|
}
|
|
3067
|
+
// TR-13: beacon ONLY the LIVE queue (never-persisted events from THIS session). The
|
|
3068
|
+
// offline backlog is already durable and the next-load drain delivers it once, so
|
|
3069
|
+
// beaconing it here would double-send it — and a stale backlog event (e.g. a purchase
|
|
3070
|
+
// parked during an outage) re-drained on a later day lands >6h from the beacon, past
|
|
3071
|
+
// ingest's dedup window. The live queue's beacon + its own next-load drain both land THIS
|
|
3072
|
+
// session (<6h), so dedup absorbs that pair.
|
|
3073
|
+
if (live.length === 0)
|
|
3074
|
+
return; // only the persisted backlog remained → it drains next load
|
|
2653
3075
|
// Within the 429 window, don't beacon into it — the persisted copy drains next load.
|
|
2654
3076
|
if (Date.now() < this.rateLimitedUntil)
|
|
2655
3077
|
return;
|
|
@@ -2660,7 +3082,7 @@ class EventQueue {
|
|
|
2660
3082
|
const chunks = [];
|
|
2661
3083
|
let current = [];
|
|
2662
3084
|
let currentBytes = 2; // approx for the JSON array/object wrapper
|
|
2663
|
-
for (const ev of
|
|
3085
|
+
for (const ev of live) {
|
|
2664
3086
|
// Byte length (not UTF-16 .length) so multibyte product names / emoji can't
|
|
2665
3087
|
// under-count and overflow the cap.
|
|
2666
3088
|
const evBytes = new Blob([JSON.stringify(ev)]).size + 1;
|
|
@@ -2686,7 +3108,7 @@ class EventQueue {
|
|
|
2686
3108
|
break;
|
|
2687
3109
|
beaconed += chunk.length;
|
|
2688
3110
|
}
|
|
2689
|
-
this.log(`forceFlush(terminal): beaconed ${beaconed}/${
|
|
3111
|
+
this.log(`forceFlush(terminal): beaconed ${beaconed}/${live.length} live events; backlog (${pending.length - live.length}) persisted for next-load drain`);
|
|
2690
3112
|
});
|
|
2691
3113
|
}
|
|
2692
3114
|
/**
|
|
@@ -2707,6 +3129,15 @@ class EventQueue {
|
|
|
2707
3129
|
setEnabled(enabled) {
|
|
2708
3130
|
this.enabled = enabled;
|
|
2709
3131
|
}
|
|
3132
|
+
/**
|
|
3133
|
+
* TR-15: register a live consent predicate the offline drain re-evaluates at drain time.
|
|
3134
|
+
* `enabled` is latched at init to a fail-open value (Shopify's customerPrivacy loads
|
|
3135
|
+
* async), and a returning DECLINED visitor fires no visitorConsentCollected event — so
|
|
3136
|
+
* without this, their persisted backlog would drain before the decline is known.
|
|
3137
|
+
*/
|
|
3138
|
+
setConsentCheck(fn) {
|
|
3139
|
+
this.consentCheck = fn;
|
|
3140
|
+
}
|
|
2710
3141
|
/**
|
|
2711
3142
|
* Clear the offline queue and its persisted copy (used by opt-out to purge any
|
|
2712
3143
|
* PII-bearing events that were parked before the user opted out).
|
|
@@ -4181,6 +4612,256 @@ class AutoIdentifyManager {
|
|
|
4181
4612
|
// someone else (and feeds that person's email to Meta advanced matching).
|
|
4182
4613
|
AutoIdentifyManager.THIRD_PARTY_EMAIL_FIELD = /(recipient|friend|gift|refer|invite|colleague|coworker|share|to[-_]?email|email[-_]?to|second(ary)?[-_]?email)/i;
|
|
4183
4614
|
|
|
4615
|
+
/**
|
|
4616
|
+
* Stripe Checkout Session capture.
|
|
4617
|
+
*
|
|
4618
|
+
* WHY THIS EXISTS
|
|
4619
|
+
* syncStripePaymentLinks stamps `client_reference_id` onto buy.stripe.com
|
|
4620
|
+
* anchors, and deliberately never onto checkout.stripe.com — a Checkout Session
|
|
4621
|
+
* URL ignores the param because the session already exists server-side. That
|
|
4622
|
+
* leaves the single most common SaaS pattern uncovered: the merchant's backend
|
|
4623
|
+
* creates a Session and the browser is sent to checkout.stripe.com/c/pay/cs_...
|
|
4624
|
+
* The payment then reaches our webhook with no visitor at all.
|
|
4625
|
+
*
|
|
4626
|
+
* Measured 2026-07-25, 30d: of payers arriving via Stripe webhooks, 0.5% (one
|
|
4627
|
+
* tenant, 128/28,187) and 13.3% (another, 13/98) had ANY web pageview on the
|
|
4628
|
+
* same visitor. Not a misconfiguration — no tenant is wired, because being
|
|
4629
|
+
* wired currently requires the merchant to hand-write client_reference_id.
|
|
4630
|
+
*
|
|
4631
|
+
* WHAT THIS DOES
|
|
4632
|
+
* The session id must reach the browser for the redirect to happen at all, so
|
|
4633
|
+
* we observe it rather than trying to inject anything:
|
|
4634
|
+
* 1. fetch/XHR response bodies — `{url}` or `{sessionId}` from the
|
|
4635
|
+
* merchant's own create-session endpoint
|
|
4636
|
+
* 2. anchor clicks + window.open — direct links to checkout.stripe.com
|
|
4637
|
+
* A server-side 302 straight to Stripe never exposes the id to JS and is NOT
|
|
4638
|
+
* covered here; that case falls back to the email bridge.
|
|
4639
|
+
*
|
|
4640
|
+
* WHY THIS IS SAFE TO DEFAULT ON, WHEN autoIdentifyAPI IS NOT
|
|
4641
|
+
* autoIdentifyAPI scans the same traffic for EMAILS, which is why it defaults
|
|
4642
|
+
* off: an admin viewing a customer record gets identified as that customer, and
|
|
4643
|
+
* a wrong email propagates into Meta advanced matching. A `cs_live_...` token is
|
|
4644
|
+
* unambiguous, belongs to exactly one checkout, and is not PII. There is no
|
|
4645
|
+
* mis-identification failure mode to guard against — only the wrapper itself,
|
|
4646
|
+
* which is why every hook below is transparent and failure-isolated.
|
|
4647
|
+
*/
|
|
4648
|
+
/** Stripe Checkout Session ids: cs_live_… / cs_test_… */
|
|
4649
|
+
const SESSION_ID_RE = /cs_(?:live|test)_[A-Za-z0-9]{8,}/;
|
|
4650
|
+
/** Hosts whose URLs carry a session id in the path. Exact match, never substring. */
|
|
4651
|
+
const CHECKOUT_HOSTS = new Set(['checkout.stripe.com']);
|
|
4652
|
+
/**
|
|
4653
|
+
* Response bodies are scanned in full, so cap what we're willing to read. A
|
|
4654
|
+
* create-session response is a few hundred bytes; anything large is not it, and
|
|
4655
|
+
* reading it would cost the merchant's page real memory and main-thread time.
|
|
4656
|
+
*/
|
|
4657
|
+
const MAX_SCAN_BYTES = 65536;
|
|
4658
|
+
/** Bodies worth scanning. Stripe ids arrive as JSON or text, never as binary. */
|
|
4659
|
+
const SCANNABLE_TYPE_RE = /(json|text|javascript)/i;
|
|
4660
|
+
function extractSessionId(text) {
|
|
4661
|
+
if (!text)
|
|
4662
|
+
return null;
|
|
4663
|
+
const m = SESSION_ID_RE.exec(text);
|
|
4664
|
+
return m ? m[0] : null;
|
|
4665
|
+
}
|
|
4666
|
+
/**
|
|
4667
|
+
* True when `href` points at a Stripe-hosted checkout. Uses the URL API and
|
|
4668
|
+
* exact host equality — `a[href*="checkout.stripe.com"]` would match
|
|
4669
|
+
* `evil.com/checkout.stripe.com/x` and `checkout.stripe.com.evil.com`.
|
|
4670
|
+
*/
|
|
4671
|
+
function isCheckoutUrl(href, base) {
|
|
4672
|
+
try {
|
|
4673
|
+
const host = new URL(href, base !== null && base !== void 0 ? base : (typeof window !== 'undefined' ? window.location.href : undefined)).hostname.toLowerCase();
|
|
4674
|
+
return CHECKOUT_HOSTS.has(host);
|
|
4675
|
+
}
|
|
4676
|
+
catch (_a) {
|
|
4677
|
+
return false;
|
|
4678
|
+
}
|
|
4679
|
+
}
|
|
4680
|
+
class StripeSessionWatcher {
|
|
4681
|
+
constructor(options = {}) {
|
|
4682
|
+
var _a, _b;
|
|
4683
|
+
this.seen = new Set();
|
|
4684
|
+
this.disposers = [];
|
|
4685
|
+
this.started = false;
|
|
4686
|
+
this.maxSessions = (_a = options.maxSessions) !== null && _a !== void 0 ? _a : 5;
|
|
4687
|
+
this.maxScanBytes = (_b = options.maxScanBytes) !== null && _b !== void 0 ? _b : MAX_SCAN_BYTES;
|
|
4688
|
+
}
|
|
4689
|
+
start(sink) {
|
|
4690
|
+
if (this.started || typeof window === 'undefined')
|
|
4691
|
+
return;
|
|
4692
|
+
this.started = true;
|
|
4693
|
+
this.sink = sink;
|
|
4694
|
+
this.hookFetch();
|
|
4695
|
+
this.hookXhr();
|
|
4696
|
+
this.hookClicks();
|
|
4697
|
+
this.hookWindowOpen();
|
|
4698
|
+
}
|
|
4699
|
+
stop() {
|
|
4700
|
+
// Restore in reverse so a later wrapper never re-installs an earlier one.
|
|
4701
|
+
for (const dispose of this.disposers.reverse()) {
|
|
4702
|
+
try {
|
|
4703
|
+
dispose();
|
|
4704
|
+
}
|
|
4705
|
+
catch (_a) {
|
|
4706
|
+
/* idempotent */
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4709
|
+
this.disposers = [];
|
|
4710
|
+
this.started = false;
|
|
4711
|
+
this.sink = undefined;
|
|
4712
|
+
}
|
|
4713
|
+
/** Report a session id at most once, and never more than maxSessions per page. */
|
|
4714
|
+
emit(sessionId) {
|
|
4715
|
+
var _a;
|
|
4716
|
+
if (!sessionId || this.seen.has(sessionId))
|
|
4717
|
+
return;
|
|
4718
|
+
if (this.seen.size >= this.maxSessions)
|
|
4719
|
+
return;
|
|
4720
|
+
this.seen.add(sessionId);
|
|
4721
|
+
try {
|
|
4722
|
+
(_a = this.sink) === null || _a === void 0 ? void 0 : _a.call(this, sessionId);
|
|
4723
|
+
}
|
|
4724
|
+
catch (_b) {
|
|
4725
|
+
// A throwing sink must never surface inside the merchant's fetch chain.
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
/**
|
|
4729
|
+
* Scan a Response WITHOUT disturbing the merchant's own read. `clone()` must
|
|
4730
|
+
* happen synchronously, before the caller can consume the body; the read of
|
|
4731
|
+
* the clone is deliberately not awaited by the caller.
|
|
4732
|
+
*/
|
|
4733
|
+
scanResponse(response) {
|
|
4734
|
+
var _a, _b, _c, _d, _e, _f;
|
|
4735
|
+
try {
|
|
4736
|
+
const type = (_c = (_b = (_a = response.headers) === null || _a === void 0 ? void 0 : _a.get) === null || _b === void 0 ? void 0 : _b.call(_a, 'content-type')) !== null && _c !== void 0 ? _c : '';
|
|
4737
|
+
if (type && !SCANNABLE_TYPE_RE.test(type))
|
|
4738
|
+
return;
|
|
4739
|
+
const declared = Number((_f = (_e = (_d = response.headers) === null || _d === void 0 ? void 0 : _d.get) === null || _e === void 0 ? void 0 : _e.call(_d, 'content-length')) !== null && _f !== void 0 ? _f : NaN);
|
|
4740
|
+
if (Number.isFinite(declared) && declared > this.maxScanBytes)
|
|
4741
|
+
return;
|
|
4742
|
+
const clone = typeof response.clone === 'function' ? response.clone() : null;
|
|
4743
|
+
if (!clone)
|
|
4744
|
+
return;
|
|
4745
|
+
void clone
|
|
4746
|
+
.text()
|
|
4747
|
+
.then((body) => this.emit(extractSessionId(body.slice(0, this.maxScanBytes))))
|
|
4748
|
+
.catch(() => {
|
|
4749
|
+
/* body already disturbed / opaque response — nothing to do */
|
|
4750
|
+
});
|
|
4751
|
+
}
|
|
4752
|
+
catch (_g) {
|
|
4753
|
+
/* never let observation break the request */
|
|
4754
|
+
}
|
|
4755
|
+
}
|
|
4756
|
+
hookFetch() {
|
|
4757
|
+
if (typeof window.fetch !== 'function')
|
|
4758
|
+
return;
|
|
4759
|
+
const original = window.fetch;
|
|
4760
|
+
const self = this;
|
|
4761
|
+
const patched = function patchedFetch(...args) {
|
|
4762
|
+
const result = original.apply(this !== null && this !== void 0 ? this : window, args);
|
|
4763
|
+
try {
|
|
4764
|
+
// Attach passively: the merchant still gets the original promise, and a
|
|
4765
|
+
// rejection here is theirs to handle, not ours to observe twice.
|
|
4766
|
+
result.then((response) => self.scanResponse(response)).catch(() => { });
|
|
4767
|
+
}
|
|
4768
|
+
catch (_a) {
|
|
4769
|
+
/* ignore */
|
|
4770
|
+
}
|
|
4771
|
+
return result;
|
|
4772
|
+
};
|
|
4773
|
+
window.fetch = patched;
|
|
4774
|
+
this.disposers.push(() => {
|
|
4775
|
+
// Only restore if nobody wrapped us afterwards — clobbering a later
|
|
4776
|
+
// wrapper would silently disable whatever installed it.
|
|
4777
|
+
if (window.fetch === patched)
|
|
4778
|
+
window.fetch = original;
|
|
4779
|
+
});
|
|
4780
|
+
}
|
|
4781
|
+
hookXhr() {
|
|
4782
|
+
if (typeof XMLHttpRequest === 'undefined')
|
|
4783
|
+
return;
|
|
4784
|
+
const proto = XMLHttpRequest.prototype;
|
|
4785
|
+
const originalSend = proto.send;
|
|
4786
|
+
if (typeof originalSend !== 'function')
|
|
4787
|
+
return;
|
|
4788
|
+
const self = this;
|
|
4789
|
+
const patched = function patchedSend(...args) {
|
|
4790
|
+
try {
|
|
4791
|
+
this.addEventListener('load', () => {
|
|
4792
|
+
try {
|
|
4793
|
+
// responseText throws for blob/arraybuffer response types.
|
|
4794
|
+
if (this.responseType !== '' && this.responseType !== 'text')
|
|
4795
|
+
return;
|
|
4796
|
+
const body = this.responseText;
|
|
4797
|
+
if (!body || body.length > self.maxScanBytes)
|
|
4798
|
+
return;
|
|
4799
|
+
self.emit(extractSessionId(body));
|
|
4800
|
+
}
|
|
4801
|
+
catch (_a) {
|
|
4802
|
+
/* ignore */
|
|
4803
|
+
}
|
|
4804
|
+
});
|
|
4805
|
+
}
|
|
4806
|
+
catch (_a) {
|
|
4807
|
+
/* ignore */
|
|
4808
|
+
}
|
|
4809
|
+
return originalSend.apply(this, args);
|
|
4810
|
+
};
|
|
4811
|
+
proto.send = patched;
|
|
4812
|
+
this.disposers.push(() => {
|
|
4813
|
+
if (proto.send === patched)
|
|
4814
|
+
proto.send = originalSend;
|
|
4815
|
+
});
|
|
4816
|
+
}
|
|
4817
|
+
/**
|
|
4818
|
+
* Direct links to checkout.stripe.com. Capture phase so we still see the click
|
|
4819
|
+
* when the merchant's own handler calls stopPropagation().
|
|
4820
|
+
*/
|
|
4821
|
+
hookClicks() {
|
|
4822
|
+
if (typeof document === 'undefined')
|
|
4823
|
+
return;
|
|
4824
|
+
const onClick = (e) => {
|
|
4825
|
+
var _a, _b;
|
|
4826
|
+
try {
|
|
4827
|
+
const target = (_b = (_a = e.target) === null || _a === void 0 ? void 0 : _a.closest) === null || _b === void 0 ? void 0 : _b.call(_a, 'a[href]');
|
|
4828
|
+
if (!target || !target.href || !isCheckoutUrl(target.href))
|
|
4829
|
+
return;
|
|
4830
|
+
this.emit(extractSessionId(target.href));
|
|
4831
|
+
}
|
|
4832
|
+
catch (_c) {
|
|
4833
|
+
/* never block navigation */
|
|
4834
|
+
}
|
|
4835
|
+
};
|
|
4836
|
+
document.addEventListener('click', onClick, true);
|
|
4837
|
+
this.disposers.push(() => document.removeEventListener('click', onClick, true));
|
|
4838
|
+
}
|
|
4839
|
+
hookWindowOpen() {
|
|
4840
|
+
if (typeof window.open !== 'function')
|
|
4841
|
+
return;
|
|
4842
|
+
const original = window.open;
|
|
4843
|
+
const self = this;
|
|
4844
|
+
const patched = function patchedOpen(...args) {
|
|
4845
|
+
var _a;
|
|
4846
|
+
try {
|
|
4847
|
+
const url = args[0];
|
|
4848
|
+
const href = typeof url === 'string' ? url : (_a = url === null || url === void 0 ? void 0 : url.toString) === null || _a === void 0 ? void 0 : _a.call(url);
|
|
4849
|
+
if (href && isCheckoutUrl(href))
|
|
4850
|
+
self.emit(extractSessionId(href));
|
|
4851
|
+
}
|
|
4852
|
+
catch (_b) {
|
|
4853
|
+
/* ignore */
|
|
4854
|
+
}
|
|
4855
|
+
return original.apply(this !== null && this !== void 0 ? this : window, args);
|
|
4856
|
+
};
|
|
4857
|
+
window.open = patched;
|
|
4858
|
+
this.disposers.push(() => {
|
|
4859
|
+
if (window.open === patched)
|
|
4860
|
+
window.open = original;
|
|
4861
|
+
});
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
|
|
4184
4865
|
/** Keys the remote config is allowed to fill on DatalyrConfig. */
|
|
4185
4866
|
const REMOTE_KEYS = [
|
|
4186
4867
|
'autoIdentify',
|
|
@@ -4188,6 +4869,7 @@ const REMOTE_KEYS = [
|
|
|
4188
4869
|
'autoIdentifyAPI',
|
|
4189
4870
|
'autoIdentifyShopify',
|
|
4190
4871
|
'shopifyCartAttributes',
|
|
4872
|
+
'stripeCheckoutSessions',
|
|
4191
4873
|
'checkoutChampDomains',
|
|
4192
4874
|
'respectGlobalPrivacyControl',
|
|
4193
4875
|
'respectDoNotTrack',
|
|
@@ -4226,6 +4908,16 @@ function applyRemoteConfig(config, remote, explicitKeys) {
|
|
|
4226
4908
|
* Datalyr Web SDK
|
|
4227
4909
|
* Modern attribution tracking for web applications
|
|
4228
4910
|
*/
|
|
4911
|
+
/**
|
|
4912
|
+
* WEB-20. Fingerprint of the last identify() that actually emitted, so a host
|
|
4913
|
+
* app calling identify() on every route change does not re-emit an unchanged
|
|
4914
|
+
* identity. Cleared by reset(). Mirrors iOS `datalyr_last_identity_fingerprint`
|
|
4915
|
+
* and RN `@datalyr/last_identity_fingerprint`.
|
|
4916
|
+
*
|
|
4917
|
+
* Not PII: the stored value is a hash, and it is written through the same
|
|
4918
|
+
* `storage` wrapper as every other key (so it inherits the `dl_` prefix).
|
|
4919
|
+
*/
|
|
4920
|
+
const IDENTIFY_FINGERPRINT_KEY = 'dl_identify_fingerprint';
|
|
4229
4921
|
class Datalyr {
|
|
4230
4922
|
constructor() {
|
|
4231
4923
|
// Keys the caller passed to init() (before built-in defaults were merged).
|
|
@@ -4241,10 +4933,26 @@ class Datalyr {
|
|
|
4241
4933
|
this.errors = [];
|
|
4242
4934
|
this.MAX_ERRORS = 50;
|
|
4243
4935
|
this.lastSpaPath = null; // dedups SPA pageviews (replaceState-on-mount double-fire)
|
|
4936
|
+
// Shopify loads Customer Privacy asynchronously. Keep the initial pageview
|
|
4937
|
+
// pending until initialization is complete and analytics consent is known,
|
|
4938
|
+
// then release it exactly once when consent allows tracking.
|
|
4939
|
+
this.initialPageViewReady = false;
|
|
4940
|
+
this.initialPageViewSent = false;
|
|
4244
4941
|
// FIXED (ISSUE-01): Async initialization promise to prevent race conditions
|
|
4245
4942
|
this.initializationPromise = null;
|
|
4246
4943
|
// Opt-out check moved to init() after cookies configured (Issue #14)
|
|
4247
4944
|
}
|
|
4945
|
+
/**
|
|
4946
|
+
* Return the workspace configured for this instance, or null before init().
|
|
4947
|
+
*
|
|
4948
|
+
* CDN bootstrap uses this public accessor instead of reaching into the
|
|
4949
|
+
* private runtime config when deciding whether an existing global belongs
|
|
4950
|
+
* to the requested workspace.
|
|
4951
|
+
*/
|
|
4952
|
+
getWorkspaceId() {
|
|
4953
|
+
var _a, _b;
|
|
4954
|
+
return (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.workspaceId) !== null && _b !== void 0 ? _b : null;
|
|
4955
|
+
}
|
|
4248
4956
|
/**
|
|
4249
4957
|
* Initialize the SDK
|
|
4250
4958
|
*/
|
|
@@ -4299,7 +5007,11 @@ class Datalyr {
|
|
|
4299
5007
|
this.session = new SessionManager(this.config.sessionTimeout);
|
|
4300
5008
|
this.attribution = new AttributionManager({
|
|
4301
5009
|
attributionWindow: this.config.attributionWindow,
|
|
4302
|
-
trackedParams: this.config.trackedParams
|
|
5010
|
+
trackedParams: this.config.trackedParams,
|
|
5011
|
+
// TR-03: gate ad-signal synthesis + shipping on LIVE marketing consent. Returns true by
|
|
5012
|
+
// default (no consent signal) so behavior is unchanged for the common case; false only on
|
|
5013
|
+
// an explicit decline (Shopify marketing:false / setConsent marketing|sale=false).
|
|
5014
|
+
marketingAllowed: () => this.consentAllowsMarketing()
|
|
4303
5015
|
});
|
|
4304
5016
|
this.queue = new EventQueue(this.config);
|
|
4305
5017
|
this.fingerprint = new FingerprintCollector({
|
|
@@ -4312,11 +5024,18 @@ class Datalyr {
|
|
|
4312
5024
|
// Gate the queue by the FULL tracking policy (opt-out + analytics consent + DNT +
|
|
4313
5025
|
// GPC) so a returning opted-out / DNT / GPC visitor's persisted events don't drain.
|
|
4314
5026
|
this.queue.setEnabled(this.shouldTrack());
|
|
5027
|
+
// TR-15: also give the offline drain a LIVE consent gate. setEnabled above is latched to
|
|
5028
|
+
// a fail-open value while Shopify's customerPrivacy loads async; a returning declined
|
|
5029
|
+
// visitor fires no visitorConsentCollected event, so the drain must re-check per run.
|
|
5030
|
+
this.queue.setConsentCheck(() => this.shouldTrack());
|
|
4315
5031
|
// FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
|
|
4316
5032
|
// This allows encryption to initialize before any events are tracked
|
|
4317
5033
|
this.initializeAsync();
|
|
4318
5034
|
// Setup page unload handler
|
|
4319
5035
|
this.setupUnloadHandler();
|
|
5036
|
+
// Shopify Customer Privacy (9.A.1): react to the native consent banner's
|
|
5037
|
+
// decision mid-session (grant AND revoke), not just at the next page load.
|
|
5038
|
+
this.setupShopifyConsentListener();
|
|
4320
5039
|
// Initialize plugins
|
|
4321
5040
|
if (this.config.plugins) {
|
|
4322
5041
|
for (const plugin of this.config.plugins) {
|
|
@@ -4348,6 +5067,27 @@ class Datalyr {
|
|
|
4348
5067
|
this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
|
|
4349
5068
|
var _a, _b;
|
|
4350
5069
|
try {
|
|
5070
|
+
// TR-22: capture the LANDING attribution NOW, before any await. The first read used to
|
|
5071
|
+
// sit inside this.page() AFTER `await container.init()` (up to a ~3s budget), so a
|
|
5072
|
+
// router / consent tool that strips fbclid via history.replaceState within that window
|
|
5073
|
+
// — or a fast bounce — lost the click id entirely (nothing persisted to first/last
|
|
5074
|
+
// touch until the first event). getAttributionData() warms queryParamsCache (freezing
|
|
5075
|
+
// the landing params) AND persists first/last touch immediately. Wrapped so a failure
|
|
5076
|
+
// never blocks init.
|
|
5077
|
+
// CONSENT (Track-3 review P1): gate the eager capture behind shouldTrack(). This call
|
|
5078
|
+
// synthesizes _fbp/_fbc and persists first/last touch — which must NOT happen at page
|
|
5079
|
+
// load for an opted-out / DNT / GPC visitor (shouldTrack()=false), or it undoes the
|
|
5080
|
+
// id-less-at-init privacy invariant. For a TRACKED visitor with marketing declined,
|
|
5081
|
+
// getAttributionData already strips the click-ids / marketing cookies (TR-03), so TR-22's
|
|
5082
|
+
// early-capture benefit is preserved for consenting/default visitors.
|
|
5083
|
+
if (this.shouldTrack()) {
|
|
5084
|
+
try {
|
|
5085
|
+
this.attribution.getAttributionData();
|
|
5086
|
+
}
|
|
5087
|
+
catch (e) {
|
|
5088
|
+
this.log('Eager attribution capture failed:', e);
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
4351
5091
|
// SEC-03: encryption is for PII-AT-REST only — it must NOT gate event delivery,
|
|
4352
5092
|
// pageviews, or pixels. On a non-secure context (http://) or an old browser,
|
|
4353
5093
|
// crypto.subtle is absent and initialize() throws; isolate it so the rest of init
|
|
@@ -4356,6 +5096,11 @@ class Datalyr {
|
|
|
4356
5096
|
const deviceId = this.identity.getAnonymousId();
|
|
4357
5097
|
yield dataEncryption.initialize(this.config.workspaceId, deviceId);
|
|
4358
5098
|
this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
|
|
5099
|
+
// WEB-22: restore a PII user id that was persisted encrypted. Must run
|
|
5100
|
+
// here — it needs dataEncryption to be initialized, so it cannot happen
|
|
5101
|
+
// in the IdentityManager constructor. It never overwrites an id already
|
|
5102
|
+
// set by an explicit identify() earlier in this page load.
|
|
5103
|
+
yield this.identity.hydrateEncryptedUserId();
|
|
4359
5104
|
this.log('Encryption initialized, user properties loaded');
|
|
4360
5105
|
}
|
|
4361
5106
|
catch (encErr) {
|
|
@@ -4430,7 +5175,10 @@ class Datalyr {
|
|
|
4430
5175
|
// Initialize auto-identify when enabled (explicit or remote) AND
|
|
4431
5176
|
// tracking is allowed. The shouldTrack() gate keeps capture from even
|
|
4432
5177
|
// setting up its form/API interceptors for opted-out / DNT / GPC users.
|
|
4433
|
-
|
|
5178
|
+
// 9.A.1: the captured email feeds Meta advanced matching / CAPI, so a
|
|
5179
|
+
// declined Shopify marketing consent also blocks setup (incl. the
|
|
5180
|
+
// /account.json polling); null = no Shopify signal → unchanged.
|
|
5181
|
+
if (this.config.autoIdentify === true && this.shouldTrack() && this.shopifyMarketingConsent() !== false) {
|
|
4434
5182
|
this.autoIdentify = new AutoIdentifyManager({
|
|
4435
5183
|
enabled: true,
|
|
4436
5184
|
captureFromForms: this.config.autoIdentifyForms,
|
|
@@ -4442,21 +5190,29 @@ class Datalyr {
|
|
|
4442
5190
|
// Setup auto-identify callback
|
|
4443
5191
|
this.autoIdentify.initialize((email, source) => {
|
|
4444
5192
|
this.log(`Auto-identified user: ${email} from ${source}`);
|
|
4445
|
-
//
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
//
|
|
4452
|
-
|
|
5193
|
+
// WEB-21: ONE event, not two.
|
|
5194
|
+
//
|
|
5195
|
+
// This used to emit `$auto_identify` and then call identify(),
|
|
5196
|
+
// which emits `$identify` — two events ~1ms apart carrying the same
|
|
5197
|
+
// email. Production over 7 days on workspace f6260736 showed a
|
|
5198
|
+
// perfect 379 / 379 / 379 split ($identify / $auto_identify /
|
|
5199
|
+
// visitors): every auto-identification was booked twice.
|
|
5200
|
+
//
|
|
5201
|
+
// Nothing consumed `$auto_identify`: grepped across the whole
|
|
5202
|
+
// platform (app/, lib/, all Cloudflare workers, all Tinybird pipes)
|
|
5203
|
+
// — zero readers, while prod carried 6,197 of them in 30 days.
|
|
5204
|
+
// The only information it added over `$identify` was WHICH detector
|
|
5205
|
+
// found the email, so that is preserved as a trait instead.
|
|
5206
|
+
this.identify(email, { email, auto_identify_source: source });
|
|
4453
5207
|
});
|
|
4454
5208
|
}
|
|
4455
5209
|
// Stamp attribution signals into the Shopify cart (OPT-IN, default off).
|
|
4456
5210
|
// Lets server-side order webhooks recover the browser visitor + Meta click
|
|
4457
5211
|
// signals (the postback webhook reads these as note_attributes). Inert unless
|
|
4458
|
-
// enabled; best-effort and never blocks init.
|
|
4459
|
-
|
|
5212
|
+
// enabled; best-effort and never blocks init. 9.A.1: the stamped visitor_id +
|
|
5213
|
+
// fbc/fbp/fbclid are marketing signals, so a declined Shopify marketing
|
|
5214
|
+
// consent blocks stamping too (null = no signal → unchanged).
|
|
5215
|
+
if (this.config.shopifyCartAttributes === true && this.shouldTrack() && this.shopifyMarketingConsent() !== false) {
|
|
4460
5216
|
this.syncShopifyCartAttributes().catch((error) => {
|
|
4461
5217
|
this.log('Shopify cart attribute sync failed:', error);
|
|
4462
5218
|
});
|
|
@@ -4478,10 +5234,22 @@ class Datalyr {
|
|
|
4478
5234
|
if (this.config.stripePaymentLinks !== false && this.shouldTrack()) {
|
|
4479
5235
|
this.syncStripePaymentLinks((_b = this.config.stripeLinkDomains) !== null && _b !== void 0 ? _b : []);
|
|
4480
5236
|
}
|
|
4481
|
-
//
|
|
4482
|
-
|
|
4483
|
-
|
|
5237
|
+
// Checkout Session capture (DEFAULT ON). The decorator above cannot help
|
|
5238
|
+
// when the merchant's BACKEND creates the session — checkout.stripe.com
|
|
5239
|
+
// ignores client_reference_id post-creation — which is how most SaaS
|
|
5240
|
+
// checkouts work, and why payers arrive at our webhook with no visitor
|
|
5241
|
+
// (measured 0.5% and 13.3% visitor coverage on the two Stripe tenants).
|
|
5242
|
+
// The session id still has to reach the browser, so we observe it and
|
|
5243
|
+
// let the server join on session.id. Same shouldTrack() gate as the
|
|
5244
|
+
// decorator: an opted-out visitor's id is never paired with a checkout.
|
|
5245
|
+
if (this.config.stripeCheckoutSessions !== false && this.shouldTrack()) {
|
|
5246
|
+
this.startStripeSessionCapture();
|
|
4484
5247
|
}
|
|
5248
|
+
// Track the initial page view after encryption is ready. On Shopify the
|
|
5249
|
+
// Customer Privacy API can still be loading, so retain one pending
|
|
5250
|
+
// pageview and release it from onShopifyConsentChanged() once allowed.
|
|
5251
|
+
this.initialPageViewReady = true;
|
|
5252
|
+
this.trackInitialPageViewOnce();
|
|
4485
5253
|
this.log('Async initialization complete');
|
|
4486
5254
|
}
|
|
4487
5255
|
catch (error) {
|
|
@@ -4614,6 +5382,50 @@ class Datalyr {
|
|
|
4614
5382
|
/**
|
|
4615
5383
|
* Identify a user
|
|
4616
5384
|
*/
|
|
5385
|
+
/**
|
|
5386
|
+
* Stable fingerprint of an identity for redundant-emit suppression (WEB-20).
|
|
5387
|
+
*
|
|
5388
|
+
* Keys are sorted and each value is JSON-serialized, so `{a,b}` and `{b,a}`
|
|
5389
|
+
* collapse to one identity while a genuinely changed nested trait does not.
|
|
5390
|
+
* (The mobile SDKs use `String(value)`, which flattens every object to
|
|
5391
|
+
* `[object Object]` and can swallow a real change; web traits are plain JSON,
|
|
5392
|
+
* so it can afford to be exact.)
|
|
5393
|
+
*
|
|
5394
|
+
* Hashed rather than stored raw so the key does not become another copy of the
|
|
5395
|
+
* user's traits at rest. Being honest about the strength: a 32-bit
|
|
5396
|
+
* non-cryptographic digest sitting beside a readable `dl_dl_anonymous_id` is
|
|
5397
|
+
* NOT protection against a determined reader — it defeats casual inspection
|
|
5398
|
+
* and bulk scraping, nothing more. The PII that matters is handled properly
|
|
5399
|
+
* (encrypted `dl_user_id_pii`, encrypted `dl_user_traits`). 32 bits also means
|
|
5400
|
+
* a ~1-in-4.3e9 chance per identity that a genuine change is suppressed;
|
|
5401
|
+
* acceptable for change detection, which is all this is.
|
|
5402
|
+
*/
|
|
5403
|
+
identityFingerprint(userId, traits = {}) {
|
|
5404
|
+
const stableTraits = Object.keys(traits || {})
|
|
5405
|
+
.sort()
|
|
5406
|
+
.map((key) => {
|
|
5407
|
+
let value;
|
|
5408
|
+
try {
|
|
5409
|
+
value = JSON.stringify(traits[key]);
|
|
5410
|
+
}
|
|
5411
|
+
catch (_a) {
|
|
5412
|
+
// Circular/unserializable trait — fall back to a coarse marker rather
|
|
5413
|
+
// than throwing inside identify().
|
|
5414
|
+
value = '[unserializable]';
|
|
5415
|
+
}
|
|
5416
|
+
return `${key}=${value}`;
|
|
5417
|
+
})
|
|
5418
|
+
.join('&');
|
|
5419
|
+
const input = `${this.identity.getAnonymousId()}|${userId}|${stableTraits}`;
|
|
5420
|
+
// 32-bit FNV-1a. Not cryptographic — this only needs to detect change, and
|
|
5421
|
+
// it must be synchronous (crypto.subtle is async and identify() is not).
|
|
5422
|
+
let hash = 0x811c9dc5;
|
|
5423
|
+
for (let i = 0; i < input.length; i++) {
|
|
5424
|
+
hash ^= input.charCodeAt(i);
|
|
5425
|
+
hash = Math.imul(hash, 0x01000193);
|
|
5426
|
+
}
|
|
5427
|
+
return (hash >>> 0).toString(16);
|
|
5428
|
+
}
|
|
4617
5429
|
identify(userId, traits = {}) {
|
|
4618
5430
|
if (!this.initialized) {
|
|
4619
5431
|
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
@@ -4626,6 +5438,13 @@ class Datalyr {
|
|
|
4626
5438
|
return;
|
|
4627
5439
|
}
|
|
4628
5440
|
try {
|
|
5441
|
+
// A different authenticated user on the same device is a privacy
|
|
5442
|
+
// boundary. Rotate the anonymous/session identities and clear the prior
|
|
5443
|
+
// user's traits/attribution before creating the new link, even when the
|
|
5444
|
+
// integrator forgot to call reset() on logout.
|
|
5445
|
+
const currentUserId = this.identity.getUserId();
|
|
5446
|
+
if (currentUserId && currentUserId !== userId)
|
|
5447
|
+
this.reset();
|
|
4629
5448
|
// FSR-53: do NOT rotate the session id on identify(). 'Session fixation' is an
|
|
4630
5449
|
// auth-credential concept; a client-generated analytics session_id is not one.
|
|
4631
5450
|
// Rotating split every identified visit (and every auto-identify form submit) into
|
|
@@ -4646,8 +5465,31 @@ class Datalyr {
|
|
|
4646
5465
|
// If encryption fails, user traits are only stored in memory (this.userProperties)
|
|
4647
5466
|
// and will be lost on page reload - but PII is NOT exposed in localStorage
|
|
4648
5467
|
});
|
|
4649
|
-
//
|
|
4650
|
-
|
|
5468
|
+
// WEB-20: redundant-identify suppression, matching iOS 2.1.11 / RN 1.7.16.
|
|
5469
|
+
//
|
|
5470
|
+
// identify() is host-app-driven and SPA routers commonly call it in a
|
|
5471
|
+
// route effect, so an unchanged identity was re-emitted on every
|
|
5472
|
+
// navigation. The mobile SDKs got this in 2.1.10/1.7.15; web did not.
|
|
5473
|
+
//
|
|
5474
|
+
// Fingerprint = anonymousId | userId | sorted traits. The anonymous id is
|
|
5475
|
+
// included so a reset()/rotation always re-emits and identity links are
|
|
5476
|
+
// never lost — only exact repeats are skipped.
|
|
5477
|
+
//
|
|
5478
|
+
// Only the EVENT is suppressed. `this.identity.identify()` above has
|
|
5479
|
+
// already persisted `dl_user_id`, traits are still merged and encrypted,
|
|
5480
|
+
// and the plugin handlers below still run on every call — a plugin's
|
|
5481
|
+
// `identify` hook is a customer extension point whose semantics we cannot
|
|
5482
|
+
// assume, so its contract ("called when identify() is called") is
|
|
5483
|
+
// deliberately left intact.
|
|
5484
|
+
const identityFingerprint = this.identityFingerprint(userId, traits);
|
|
5485
|
+
const isRedundantIdentify = storage.getString(IDENTIFY_FINGERPRINT_KEY, null) === identityFingerprint;
|
|
5486
|
+
if (isRedundantIdentify) {
|
|
5487
|
+
this.log('Skipping redundant identify (unchanged identity):', userId);
|
|
5488
|
+
}
|
|
5489
|
+
else {
|
|
5490
|
+
storage.set(IDENTIFY_FINGERPRINT_KEY, identityFingerprint);
|
|
5491
|
+
this.track('$identify', Object.assign(Object.assign({}, identityLink), { traits }));
|
|
5492
|
+
}
|
|
4651
5493
|
// Call plugin handlers
|
|
4652
5494
|
if (this.config.plugins) {
|
|
4653
5495
|
for (const plugin of this.config.plugins) {
|
|
@@ -4667,6 +5509,18 @@ class Datalyr {
|
|
|
4667
5509
|
this.trackError(error, { userId });
|
|
4668
5510
|
}
|
|
4669
5511
|
}
|
|
5512
|
+
/**
|
|
5513
|
+
* WEB-27: queue health, including events the SDK gave up on.
|
|
5514
|
+
*
|
|
5515
|
+
* `getStats()` existed on EventQueue but was unreachable — `queue` is private
|
|
5516
|
+
* and nothing exposed it, so the observability fix could not actually be
|
|
5517
|
+
* observed. A non-zero `droppedEvents` means data was lost.
|
|
5518
|
+
*/
|
|
5519
|
+
getQueueStats() {
|
|
5520
|
+
if (!this.queue)
|
|
5521
|
+
return null;
|
|
5522
|
+
return this.queue.getStats();
|
|
5523
|
+
}
|
|
4670
5524
|
/**
|
|
4671
5525
|
* Track a page view
|
|
4672
5526
|
*/
|
|
@@ -4688,7 +5542,9 @@ class Datalyr {
|
|
|
4688
5542
|
if (!lastTouchpoint || lastTouchpoint.sessionId !== sid) {
|
|
4689
5543
|
this.attribution.addTouchpoint(sid, this.attribution.captureAttribution());
|
|
4690
5544
|
}
|
|
4691
|
-
|
|
5545
|
+
// 9.A.4: url/search/referrer are redacted (secret/PII query-param values →
|
|
5546
|
+
// __redacted__) before they ever leave the browser — see redactUrl in utils.
|
|
5547
|
+
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);
|
|
4692
5548
|
// Add referrer data
|
|
4693
5549
|
const referrerData = getReferrerData();
|
|
4694
5550
|
Object.assign(pageData, referrerData);
|
|
@@ -4758,6 +5614,13 @@ class Datalyr {
|
|
|
4758
5614
|
// Gate before mutating persisted identity (identity.alias writes dl_user_id).
|
|
4759
5615
|
if (!this.shouldTrack())
|
|
4760
5616
|
return;
|
|
5617
|
+
if (previousId && previousId !== this.identity.getAnonymousId()) {
|
|
5618
|
+
console.warn('[Datalyr] alias() only accepts the current anonymous ID as previousId');
|
|
5619
|
+
return;
|
|
5620
|
+
}
|
|
5621
|
+
const currentUserId = this.identity.getUserId();
|
|
5622
|
+
if (currentUserId && currentUserId !== userId)
|
|
5623
|
+
this.reset();
|
|
4761
5624
|
const aliasData = this.identity.alias(userId, previousId);
|
|
4762
5625
|
this.track('$alias', aliasData);
|
|
4763
5626
|
}
|
|
@@ -4775,6 +5638,9 @@ class Datalyr {
|
|
|
4775
5638
|
// values to the next user's events (cross-user contamination on shared devices).
|
|
4776
5639
|
this.superProperties = {};
|
|
4777
5640
|
storage.remove('dl_user_traits');
|
|
5641
|
+
// WEB-20: drop the identify fingerprint, so a logout→login (or a different
|
|
5642
|
+
// user on this browser) always re-emits $identify and the link is rebuilt.
|
|
5643
|
+
storage.remove(IDENTIFY_FINGERPRINT_KEY);
|
|
4778
5644
|
// Clear the auto-identify guard so a different user on the same browser is
|
|
4779
5645
|
// re-captured (it short-circuits while dl_auto_identified_email is present), and so
|
|
4780
5646
|
// the prior user's email isn't left at rest after logout.
|
|
@@ -4913,6 +5779,32 @@ class Datalyr {
|
|
|
4913
5779
|
/**
|
|
4914
5780
|
* Opt out of tracking
|
|
4915
5781
|
*/
|
|
5782
|
+
/**
|
|
5783
|
+
* X-1 (consent): stop the Stripe Payment Link + CheckoutChamp outbound-link decorators from
|
|
5784
|
+
* stamping `client_reference_id`/`prefilled_email` onto <a> hrefs once consent/marketing is
|
|
5785
|
+
* withdrawn. Their MutationObservers otherwise keep rewriting links after a CMP or Shopify
|
|
5786
|
+
* decline — and the server still reads the stamped `client_reference_id` — so attribution
|
|
5787
|
+
* continues post-withdrawal. Idempotent; decorators re-init on the next load if consent returns
|
|
5788
|
+
* (same convention as pixels/auto-identify).
|
|
5789
|
+
*/
|
|
5790
|
+
disposeMarketingLinkDecorators() {
|
|
5791
|
+
var _a, _b, _c;
|
|
5792
|
+
try {
|
|
5793
|
+
(_a = this.stripeLinksDisposer) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
5794
|
+
}
|
|
5795
|
+
catch ( /* best-effort */_d) { /* best-effort */ }
|
|
5796
|
+
this.stripeLinksDisposer = undefined;
|
|
5797
|
+
try {
|
|
5798
|
+
(_b = this.stripeSessionWatcher) === null || _b === void 0 ? void 0 : _b.stop();
|
|
5799
|
+
}
|
|
5800
|
+
catch ( /* best-effort */_e) { /* best-effort */ }
|
|
5801
|
+
this.stripeSessionWatcher = undefined;
|
|
5802
|
+
try {
|
|
5803
|
+
(_c = this.outboundDisposer) === null || _c === void 0 ? void 0 : _c.call(this);
|
|
5804
|
+
}
|
|
5805
|
+
catch ( /* best-effort */_f) { /* best-effort */ }
|
|
5806
|
+
this.outboundDisposer = undefined;
|
|
5807
|
+
}
|
|
4916
5808
|
optOut() {
|
|
4917
5809
|
var _a;
|
|
4918
5810
|
if (!this.initialized) {
|
|
@@ -4934,11 +5826,27 @@ class Datalyr {
|
|
|
4934
5826
|
this.container.cleanupAllIframes();
|
|
4935
5827
|
this.container = undefined;
|
|
4936
5828
|
}
|
|
5829
|
+
// X-1: stop stamping Stripe/CC outbound links with the visitor id / email.
|
|
5830
|
+
this.disposeMarketingLinkDecorators();
|
|
4937
5831
|
// Purge PII at rest.
|
|
4938
5832
|
this.userProperties = {};
|
|
4939
5833
|
storage.remove('dl_user_traits');
|
|
5834
|
+
// WEB-26: remove the PLAINTEXT id as well. optOut() purged only the
|
|
5835
|
+
// encrypted copy, so an opted-out visitor whose email was written by an
|
|
5836
|
+
// older build kept it at rest — the one state where that is least
|
|
5837
|
+
// defensible.
|
|
5838
|
+
storage.remove('dl_user_id');
|
|
5839
|
+
storage.remove('dl_user_id_pii');
|
|
5840
|
+
storage.remove(IDENTIFY_FINGERPRINT_KEY);
|
|
4940
5841
|
storage.remove('dl_auto_identified_email');
|
|
4941
5842
|
storage.remove('dl_journey');
|
|
5843
|
+
// WEB-25: first/last touch hold redacted-but-full landing URLs, referrers and
|
|
5844
|
+
// click ids for up to 90 days. reset() has always cleared them; optOut() did
|
|
5845
|
+
// not — so an opted-out visitor kept marketing attribution at rest, which is
|
|
5846
|
+
// the one state where it is least defensible. Same rationale as dl_journey
|
|
5847
|
+
// directly above.
|
|
5848
|
+
storage.remove('dl_first_touch');
|
|
5849
|
+
storage.remove('dl_last_touch');
|
|
4942
5850
|
this.log('User opted out');
|
|
4943
5851
|
}
|
|
4944
5852
|
/**
|
|
@@ -4955,6 +5863,9 @@ class Datalyr {
|
|
|
4955
5863
|
// merely because opt-out was lifted — otherwise a GPC/DNT visitor's persisted
|
|
4956
5864
|
// events would start draining again. (Pixels / auto-identify resume on next load.)
|
|
4957
5865
|
this.queue.setEnabled(this.shouldTrack());
|
|
5866
|
+
// TR-15 (P3): opt-in → persist the in-memory anon id now (see onShopifyConsentChanged).
|
|
5867
|
+
if (this.shouldTrack())
|
|
5868
|
+
this.identity.enablePersistence();
|
|
4958
5869
|
this.log('User opted in');
|
|
4959
5870
|
}
|
|
4960
5871
|
/**
|
|
@@ -4967,6 +5878,7 @@ class Datalyr {
|
|
|
4967
5878
|
* Set consent preferences
|
|
4968
5879
|
*/
|
|
4969
5880
|
setConsent(consent) {
|
|
5881
|
+
var _a;
|
|
4970
5882
|
// Always record + persist consent FIRST, even before init(). FSR-51: a consent-
|
|
4971
5883
|
// management platform commonly calls setConsent() before init() so tracking is gated
|
|
4972
5884
|
// from the very first event. The old code then dereferenced this.queue/this.config
|
|
@@ -4987,6 +5899,30 @@ class Datalyr {
|
|
|
4987
5899
|
if (!allowed) {
|
|
4988
5900
|
this.queue.clear();
|
|
4989
5901
|
this.queue.clearOffline();
|
|
5902
|
+
// TR-14: mirror optOut() on analytics withdrawal. Previously setConsent tore down the
|
|
5903
|
+
// queue + container but left autoIdentify alive — its form/fetch interceptors and
|
|
5904
|
+
// /account.json polling kept running and triggerIdentify persisted the captured email
|
|
5905
|
+
// to storage AFTER withdrawal (PII newly written at rest → GDPR/consent-audit failure
|
|
5906
|
+
// on non-Shopify CMP installs). Destroy it and purge PII at rest. (Auto-identify
|
|
5907
|
+
// resumes on the next page load if consent is re-granted, matching optOut/optIn.)
|
|
5908
|
+
(_a = this.autoIdentify) === null || _a === void 0 ? void 0 : _a.destroy();
|
|
5909
|
+
this.autoIdentify = undefined;
|
|
5910
|
+
this.userProperties = {};
|
|
5911
|
+
storage.remove('dl_user_traits');
|
|
5912
|
+
// WEB-26: see optOut() — the plaintext id must go too.
|
|
5913
|
+
storage.remove('dl_user_id');
|
|
5914
|
+
storage.remove('dl_user_id_pii');
|
|
5915
|
+
storage.remove(IDENTIFY_FINGERPRINT_KEY);
|
|
5916
|
+
storage.remove('dl_auto_identified_email');
|
|
5917
|
+
storage.remove('dl_journey');
|
|
5918
|
+
// WEB-25: see optOut() — withdrawn analytics consent must not leave 90-day
|
|
5919
|
+
// marketing attribution at rest either.
|
|
5920
|
+
storage.remove('dl_first_touch');
|
|
5921
|
+
storage.remove('dl_last_touch');
|
|
5922
|
+
}
|
|
5923
|
+
else {
|
|
5924
|
+
// TR-15 (P3): grant → persist the in-memory anon id now (see onShopifyConsentChanged).
|
|
5925
|
+
this.identity.enablePersistence();
|
|
4990
5926
|
}
|
|
4991
5927
|
// Marketing / "do not sell" withdrawal: stop FEEDING the third-party pixels and
|
|
4992
5928
|
// prevent them from being initialized on the next page load. NOTE: an
|
|
@@ -4996,6 +5932,12 @@ class Datalyr {
|
|
|
4996
5932
|
this.container.cleanupAllIframes();
|
|
4997
5933
|
this.container = undefined;
|
|
4998
5934
|
}
|
|
5935
|
+
// X-1: on marketing/sale withdrawal, stop the Stripe/CC decorators from stamping the
|
|
5936
|
+
// visitor id / email onto outbound links (the observers otherwise keep rewriting hrefs
|
|
5937
|
+
// after withdrawal, and the server still reads the stamped client_reference_id).
|
|
5938
|
+
if (!this.consentAllowsMarketing()) {
|
|
5939
|
+
this.disposeMarketingLinkDecorators();
|
|
5940
|
+
}
|
|
4999
5941
|
this.log('Consent updated:', consent);
|
|
5000
5942
|
}
|
|
5001
5943
|
/**
|
|
@@ -5080,91 +6022,27 @@ class Datalyr {
|
|
|
5080
6022
|
}
|
|
5081
6023
|
});
|
|
5082
6024
|
}
|
|
5083
|
-
/**
|
|
5084
|
-
* Restore _dl_* URL bridge params on a Checkout Champ funnel page.
|
|
5085
|
-
* Runs BEFORE IdentityManager so the storefront's visitor_id wins over a
|
|
5086
|
-
* freshly auto-generated one. Also restores _fbc / _fbp cookies and the
|
|
5087
|
-
* fbclid click time so server-side rebuilds carry the real click moment.
|
|
5088
|
-
*
|
|
5089
|
-
* Strategy: stamp matching cookies (without clobbering pre-existing values),
|
|
5090
|
-
* then rewrite the URL so `_dl_fbclid` becomes `fbclid` — that way the rest
|
|
5091
|
-
* of the SDK's attribution layer (which reads `params.fbclid`) works
|
|
5092
|
-
* unchanged, and any merchant analytics also see the canonical click ID.
|
|
5093
|
-
*/
|
|
6025
|
+
/** Strip the retired unsigned Checkout Champ bridge parameters. */
|
|
5094
6026
|
restoreFromURL() {
|
|
5095
6027
|
var _a;
|
|
5096
6028
|
if (typeof window === "undefined" || typeof document === "undefined")
|
|
5097
6029
|
return;
|
|
5098
6030
|
try {
|
|
5099
6031
|
const params = new URLSearchParams(window.location.search);
|
|
5100
|
-
|
|
5101
|
-
const vid = get("_dl_vid");
|
|
5102
|
-
const fbc = get("_dl_fbc");
|
|
5103
|
-
const fbp = get("_dl_fbp");
|
|
5104
|
-
const fbclid = get("_dl_fbclid");
|
|
5105
|
-
const fbclidAt = get("_dl_fbclid_at");
|
|
5106
|
-
const gclid = get("_dl_gclid");
|
|
5107
|
-
const gclidAt = get("_dl_gclid_at");
|
|
5108
|
-
// visitor_id: bridge wins. The whole point of `_dl_vid` is to unify the
|
|
5109
|
-
// storefront's session with the CC funnel session. A pre-existing local
|
|
5110
|
-
// cookie from a prior direct CC visit would silently sink the integration
|
|
5111
|
-
// (CC events stay on the local id; storefront events use the bridged id;
|
|
5112
|
-
// the two never link). Overwrite — orphaned local events are fine.
|
|
5113
|
-
if (vid)
|
|
5114
|
-
this.cookies.set("__dl_visitor_id", vid, 365);
|
|
5115
|
-
// Meta cookies + click-time cookies: existing wins. _fbc / _fbp may have
|
|
5116
|
-
// been written by Meta Pixel on the CC funnel page itself (more recent
|
|
5117
|
-
// than the bridged value); _dl_fbclid_at should record first-touch click
|
|
5118
|
-
// time per device, not get reset by a bridge from a new campaign.
|
|
5119
|
-
const setIfMissing = (name, value) => {
|
|
5120
|
-
if (!value)
|
|
5121
|
-
return;
|
|
5122
|
-
if (this.cookies.get(name))
|
|
5123
|
-
return;
|
|
5124
|
-
this.cookies.set(name, value, 365);
|
|
5125
|
-
};
|
|
5126
|
-
setIfMissing("_fbc", fbc);
|
|
5127
|
-
setIfMissing("_fbp", fbp);
|
|
5128
|
-
setIfMissing("_dl_fbclid_at", fbclidAt);
|
|
5129
|
-
setIfMissing("_dl_gclid_at", gclidAt);
|
|
5130
|
-
// Rewrite URL: _dl_fbclid → fbclid (etc.) so captureAttribution() picks
|
|
5131
|
-
// them up via its existing `params.fbclid` path. Strip the _dl_* params
|
|
5132
|
-
// either way so they don't leak into downstream analytics URLs.
|
|
5133
|
-
let rewrote = false;
|
|
5134
|
-
const mappings = [
|
|
5135
|
-
["fbclid", fbclid],
|
|
5136
|
-
["gclid", gclid]
|
|
5137
|
-
];
|
|
5138
|
-
for (const [canonical, value] of mappings) {
|
|
5139
|
-
if (value && !params.get(canonical)) {
|
|
5140
|
-
params.set(canonical, value);
|
|
5141
|
-
rewrote = true;
|
|
5142
|
-
}
|
|
5143
|
-
}
|
|
6032
|
+
let changed = false;
|
|
5144
6033
|
for (const k of ["_dl_vid", "_dl_fbc", "_dl_fbp", "_dl_fbclid", "_dl_fbclid_at", "_dl_gclid", "_dl_gclid_at"]) {
|
|
5145
6034
|
if (params.has(k)) {
|
|
5146
6035
|
params.delete(k);
|
|
5147
|
-
|
|
6036
|
+
changed = true;
|
|
5148
6037
|
}
|
|
5149
6038
|
}
|
|
5150
|
-
if (
|
|
6039
|
+
if (changed && typeof ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState) === "function") {
|
|
5151
6040
|
const newSearch = params.toString();
|
|
5152
|
-
const newUrl = window.location.pathname +
|
|
5153
|
-
(newSearch ? "?" + newSearch : "") +
|
|
5154
|
-
window.location.hash;
|
|
6041
|
+
const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
|
|
5155
6042
|
window.history.replaceState(window.history.state, "", newUrl);
|
|
5156
6043
|
}
|
|
5157
|
-
this.log("Checkout Champ bridge restored:", {
|
|
5158
|
-
had_vid: !!vid,
|
|
5159
|
-
had_fbc: !!fbc,
|
|
5160
|
-
had_fbp: !!fbp,
|
|
5161
|
-
had_fbclid: !!fbclid,
|
|
5162
|
-
had_gclid: !!gclid
|
|
5163
|
-
});
|
|
5164
6044
|
}
|
|
5165
6045
|
catch (error) {
|
|
5166
|
-
// Don't let bridge restoration block init — fall through to normal SDK
|
|
5167
|
-
// behavior (a fresh visitor_id, no restored click signals).
|
|
5168
6046
|
this.log("restoreFromURL failed:", error);
|
|
5169
6047
|
}
|
|
5170
6048
|
}
|
|
@@ -5265,138 +6143,9 @@ class Datalyr {
|
|
|
5265
6143
|
this.log("fireCheckoutChampPurchasePixel failed:", error);
|
|
5266
6144
|
}
|
|
5267
6145
|
}
|
|
5268
|
-
/**
|
|
5269
|
-
* Storefront → Checkout Champ link stamping. Finds every `<a href>` whose host
|
|
5270
|
-
* matches the configured CC domain list and appends `?_dl_vid=…&_dl_fbc=…&
|
|
5271
|
-
* _dl_fbp=…&_dl_fbclid=…&_dl_fbclid_at=…&_dl_gclid=…` so the user's
|
|
5272
|
-
* visitor_id + Meta click signals cross the domain. MutationObserver watches
|
|
5273
|
-
* for dynamically-injected links. On click, force-flush the event queue so
|
|
5274
|
-
* any pending track() events land before the browser navigates away.
|
|
5275
|
-
*/
|
|
6146
|
+
/** Checkout Champ identity bridging remains disabled pending signed tokens. */
|
|
5276
6147
|
syncOutboundLinkParams(domains) {
|
|
5277
|
-
|
|
5278
|
-
return;
|
|
5279
|
-
const lowerDomains = domains.map((d) => d.toLowerCase());
|
|
5280
|
-
const matchesCcDomain = (href) => {
|
|
5281
|
-
try {
|
|
5282
|
-
const host = new URL(href, window.location.href).hostname.toLowerCase();
|
|
5283
|
-
return lowerDomains.some((d) => host === d || host.endsWith("." + d));
|
|
5284
|
-
}
|
|
5285
|
-
catch (_a) {
|
|
5286
|
-
return false;
|
|
5287
|
-
}
|
|
5288
|
-
};
|
|
5289
|
-
const buildBridgeParams = () => {
|
|
5290
|
-
var _a, _b;
|
|
5291
|
-
const attribution = this.attribution.getAttributionData();
|
|
5292
|
-
const out = {};
|
|
5293
|
-
const vid = this.identity.getAnonymousId();
|
|
5294
|
-
const fbc = this.cookies.get("_fbc") || attribution._fbc;
|
|
5295
|
-
const fbp = this.cookies.get("_fbp") || attribution._fbp;
|
|
5296
|
-
// FSR-104: read the named click-id fields directly (captureAttribution stores every
|
|
5297
|
-
// present click id under its own key) so a landing URL carrying BOTH fbclid and
|
|
5298
|
-
// gclid bridges both — the old `clickIdType === 'fbclid' ? clickId : null` dropped
|
|
5299
|
-
// gclid whenever fbclid was the primary, silently losing Google attribution on the
|
|
5300
|
-
// cross-domain CC path. Fall back to the primary clickId for older stored shapes.
|
|
5301
|
-
const fbclid = (_a = attribution.fbclid) !== null && _a !== void 0 ? _a : (attribution.clickIdType === "fbclid" ? attribution.clickId : null);
|
|
5302
|
-
const fbclidAt = this.cookies.get("_dl_fbclid_at");
|
|
5303
|
-
const gclid = (_b = attribution.gclid) !== null && _b !== void 0 ? _b : (attribution.clickIdType === "gclid" ? attribution.clickId : null);
|
|
5304
|
-
const gclidAt = this.cookies.get("_dl_gclid_at");
|
|
5305
|
-
if (vid)
|
|
5306
|
-
out._dl_vid = vid;
|
|
5307
|
-
if (fbc)
|
|
5308
|
-
out._dl_fbc = String(fbc);
|
|
5309
|
-
if (fbp)
|
|
5310
|
-
out._dl_fbp = String(fbp);
|
|
5311
|
-
if (fbclid)
|
|
5312
|
-
out._dl_fbclid = String(fbclid);
|
|
5313
|
-
if (fbclidAt)
|
|
5314
|
-
out._dl_fbclid_at = String(fbclidAt);
|
|
5315
|
-
if (gclid)
|
|
5316
|
-
out._dl_gclid = String(gclid);
|
|
5317
|
-
if (gclidAt)
|
|
5318
|
-
out._dl_gclid_at = String(gclidAt);
|
|
5319
|
-
return out;
|
|
5320
|
-
};
|
|
5321
|
-
const stampLink = (anchor) => {
|
|
5322
|
-
if (!anchor.href || !matchesCcDomain(anchor.href))
|
|
5323
|
-
return;
|
|
5324
|
-
try {
|
|
5325
|
-
const u = new URL(anchor.href, window.location.href);
|
|
5326
|
-
const bridge = buildBridgeParams();
|
|
5327
|
-
let mutated = false;
|
|
5328
|
-
for (const [k, v] of Object.entries(bridge)) {
|
|
5329
|
-
if (!u.searchParams.get(k)) {
|
|
5330
|
-
u.searchParams.set(k, v);
|
|
5331
|
-
mutated = true;
|
|
5332
|
-
}
|
|
5333
|
-
}
|
|
5334
|
-
if (mutated)
|
|
5335
|
-
anchor.href = u.toString();
|
|
5336
|
-
}
|
|
5337
|
-
catch (_a) {
|
|
5338
|
-
// Ignore malformed URLs — don't break the page.
|
|
5339
|
-
}
|
|
5340
|
-
};
|
|
5341
|
-
const stampAll = () => {
|
|
5342
|
-
document.querySelectorAll("a[href]").forEach((el) => stampLink(el));
|
|
5343
|
-
};
|
|
5344
|
-
const onClick = (e) => {
|
|
5345
|
-
var _a, _b;
|
|
5346
|
-
const target = (_a = e.target) === null || _a === void 0 ? void 0 : _a.closest("a[href]");
|
|
5347
|
-
if (!target)
|
|
5348
|
-
return;
|
|
5349
|
-
const anchor = target;
|
|
5350
|
-
if (!matchesCcDomain(anchor.href))
|
|
5351
|
-
return;
|
|
5352
|
-
stampLink(anchor); // re-stamp in case attribution changed since DOMReady
|
|
5353
|
-
try {
|
|
5354
|
-
(_b = this.queue) === null || _b === void 0 ? void 0 : _b.flush();
|
|
5355
|
-
}
|
|
5356
|
-
catch (_c) {
|
|
5357
|
-
// Best-effort — never block navigation.
|
|
5358
|
-
}
|
|
5359
|
-
};
|
|
5360
|
-
stampAll();
|
|
5361
|
-
document.addEventListener("click", onClick, true);
|
|
5362
|
-
try {
|
|
5363
|
-
// Debounce re-stamping. A busy SPA (infinite scroll, animations,
|
|
5364
|
-
// React/Vue updates) can fire thousands of mutations per second; a naive
|
|
5365
|
-
// re-stamp on every notification would burn CPU pointlessly when
|
|
5366
|
-
// outbound link sets only change occasionally. 150ms is small enough to
|
|
5367
|
-
// catch links before the user can click them, large enough to coalesce
|
|
5368
|
-
// bursts.
|
|
5369
|
-
let restampTimer = null;
|
|
5370
|
-
const scheduleRestamp = () => {
|
|
5371
|
-
if (restampTimer != null)
|
|
5372
|
-
return;
|
|
5373
|
-
restampTimer = setTimeout(() => {
|
|
5374
|
-
restampTimer = null;
|
|
5375
|
-
stampAll();
|
|
5376
|
-
}, 150);
|
|
5377
|
-
};
|
|
5378
|
-
const observer = new MutationObserver(scheduleRestamp);
|
|
5379
|
-
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
5380
|
-
// Observer + click listener live for the session; pagehide cleans up on full-page
|
|
5381
|
-
// unload, and destroy() can tear them down early via this disposer (H2 — otherwise
|
|
5382
|
-
// a destroy()+re-init() leaks the observer + capturing click listener).
|
|
5383
|
-
this.outboundDisposer = () => {
|
|
5384
|
-
try {
|
|
5385
|
-
observer.disconnect();
|
|
5386
|
-
}
|
|
5387
|
-
catch ( /* idempotent */_a) { /* idempotent */ }
|
|
5388
|
-
if (restampTimer != null) {
|
|
5389
|
-
clearTimeout(restampTimer);
|
|
5390
|
-
restampTimer = null;
|
|
5391
|
-
}
|
|
5392
|
-
document.removeEventListener("click", onClick, true);
|
|
5393
|
-
};
|
|
5394
|
-
window.addEventListener("pagehide", () => { var _a; return (_a = this.outboundDisposer) === null || _a === void 0 ? void 0 : _a.call(this); }, { once: true });
|
|
5395
|
-
}
|
|
5396
|
-
catch (error) {
|
|
5397
|
-
this.log("MutationObserver setup failed (CC link sync):", error);
|
|
5398
|
-
}
|
|
5399
|
-
this.log("Checkout Champ outbound-link sync active for:", lowerDomains);
|
|
6148
|
+
this.log("Checkout Champ outbound identity bridge disabled (signed token required)", domains);
|
|
5400
6149
|
}
|
|
5401
6150
|
/**
|
|
5402
6151
|
* Stripe Payment Link auto-decoration (D1). Structurally mirrors
|
|
@@ -5423,6 +6172,39 @@ class Datalyr {
|
|
|
5423
6172
|
* changes. window.open(paymentLink) is not covered (use getVisitorId()
|
|
5424
6173
|
* manually); iframe/shadow-DOM links are unreachable from document.
|
|
5425
6174
|
*/
|
|
6175
|
+
/**
|
|
6176
|
+
* Observe Stripe Checkout Session ids and report each one once.
|
|
6177
|
+
*
|
|
6178
|
+
* The event is what carries the pairing: the server stores
|
|
6179
|
+
* (stripe_session_id -> visitor_id) and, when checkout.session.completed
|
|
6180
|
+
* arrives without a client_reference_id, joins on session.id and feeds the
|
|
6181
|
+
* SAME cacheCustomerVisitor path the Payment Link flow already uses — so
|
|
6182
|
+
* every later invoice for that customer inherits the visitor too.
|
|
6183
|
+
*
|
|
6184
|
+
* Consent is re-checked at emit time, not just at init: a visitor can
|
|
6185
|
+
* withdraw between page load and checkout, and this pairing is exactly the
|
|
6186
|
+
* kind of identity link that must stop when they do.
|
|
6187
|
+
*/
|
|
6188
|
+
startStripeSessionCapture() {
|
|
6189
|
+
if (this.stripeSessionWatcher)
|
|
6190
|
+
return;
|
|
6191
|
+
const watcher = new StripeSessionWatcher();
|
|
6192
|
+
this.stripeSessionWatcher = watcher;
|
|
6193
|
+
watcher.start((stripeSessionId) => {
|
|
6194
|
+
var _a;
|
|
6195
|
+
if (!this.shouldTrack())
|
|
6196
|
+
return;
|
|
6197
|
+
this.track('$stripe_checkout_session', { stripe_session_id: stripeSessionId });
|
|
6198
|
+
// The very next thing this page does is navigate to Stripe, so flush now
|
|
6199
|
+
// rather than let the batch timer lose the pairing to the unload.
|
|
6200
|
+
try {
|
|
6201
|
+
(_a = this.queue) === null || _a === void 0 ? void 0 : _a.flush();
|
|
6202
|
+
}
|
|
6203
|
+
catch (_b) {
|
|
6204
|
+
/* best-effort — never block the checkout */
|
|
6205
|
+
}
|
|
6206
|
+
});
|
|
6207
|
+
}
|
|
5426
6208
|
syncStripePaymentLinks(extraDomains) {
|
|
5427
6209
|
if (typeof window === "undefined" || typeof document === "undefined")
|
|
5428
6210
|
return;
|
|
@@ -5555,6 +6337,7 @@ class Datalyr {
|
|
|
5555
6337
|
* Create event payload
|
|
5556
6338
|
*/
|
|
5557
6339
|
createEventPayload(eventName, properties, eventIdArg) {
|
|
6340
|
+
var _a;
|
|
5558
6341
|
// NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
|
|
5559
6342
|
// The session id changes on session timeout (and previously on identify(), removed in
|
|
5560
6343
|
// FSR-53), but identity only synced it at init/start — so the top-level
|
|
@@ -5591,17 +6374,30 @@ class Datalyr {
|
|
|
5591
6374
|
device_fingerprint: fingerprintData // Use snake_case only (matches backend)
|
|
5592
6375
|
});
|
|
5593
6376
|
}
|
|
5594
|
-
// Add browser context (caller wins on collisions)
|
|
6377
|
+
// Add browser context (caller wins on collisions). 9.A.4: url/referrer are
|
|
6378
|
+
// redacted — secret/PII query-param values must not ship on events.
|
|
5595
6379
|
assignMissing(eventData, {
|
|
5596
|
-
url: window.location.href,
|
|
6380
|
+
url: redactUrl(window.location.href),
|
|
5597
6381
|
path: window.location.pathname,
|
|
5598
|
-
referrer: document.referrer,
|
|
6382
|
+
referrer: redactUrl(document.referrer),
|
|
5599
6383
|
title: document.title,
|
|
5600
6384
|
screen_width: screen.width,
|
|
5601
6385
|
screen_height: screen.height,
|
|
5602
6386
|
viewport_width: window.innerWidth,
|
|
5603
6387
|
viewport_height: window.innerHeight
|
|
5604
6388
|
});
|
|
6389
|
+
// Consent travels with the event so downstream profile enrichment and ad
|
|
6390
|
+
// postbacks enforce the decision that applied at occurrence time. The SDK
|
|
6391
|
+
// snapshot is authoritative over caller properties.
|
|
6392
|
+
const consentSnapshot = Object.assign({}, ((_a = this.consent) !== null && _a !== void 0 ? _a : {}));
|
|
6393
|
+
const shopifyAnalytics = this.shopifyAnalyticsConsent();
|
|
6394
|
+
const shopifyMarketing = this.shopifyMarketingConsent();
|
|
6395
|
+
if (typeof shopifyAnalytics === 'boolean')
|
|
6396
|
+
consentSnapshot.analytics = shopifyAnalytics;
|
|
6397
|
+
if (typeof shopifyMarketing === 'boolean')
|
|
6398
|
+
consentSnapshot.marketing = shopifyMarketing;
|
|
6399
|
+
if (Object.keys(consentSnapshot).length > 0)
|
|
6400
|
+
eventData.consent = consentSnapshot;
|
|
5605
6401
|
// Create payload using snake_case only (matches backend API and production script)
|
|
5606
6402
|
const identityFields = this.identity.getIdentityFields();
|
|
5607
6403
|
// Use the caller-provided event_id (shared with the Meta Pixel co-fire for
|
|
@@ -5630,11 +6426,17 @@ class Datalyr {
|
|
|
5630
6426
|
// Resolution metadata
|
|
5631
6427
|
resolution_method: 'browser_sdk',
|
|
5632
6428
|
resolution_confidence: 1.0,
|
|
5633
|
-
// SDK metadata.
|
|
5634
|
-
// (
|
|
5635
|
-
//
|
|
5636
|
-
|
|
5637
|
-
|
|
6429
|
+
// SDK metadata. The placeholder is replaced with package.json "version" at build
|
|
6430
|
+
// time (rollup.config.js injectSdkVersion — 9.A.3), so the literal can never
|
|
6431
|
+
// drift from the package again (it sat at 1.7.4 across the 1.7.5 release). The
|
|
6432
|
+
// build guard (scripts/check-bundle.js, run by build:check) still verifies the
|
|
6433
|
+
// deployable bundles carry the package.json version. (FSR-103)
|
|
6434
|
+
sdk_version: '1.7.8',
|
|
6435
|
+
sdk_name: 'datalyr-web-sdk',
|
|
6436
|
+
// A3-25: versioned-envelope stamp. Every SDK emits schema_version so the ingest/contract
|
|
6437
|
+
// layer can key on ONE canonical envelope version (snake_case fields, event_name/event_data
|
|
6438
|
+
// keys, canonical click-ids, clamped client timestamp — all now converged across SDKs).
|
|
6439
|
+
schema_version: 1
|
|
5638
6440
|
};
|
|
5639
6441
|
return payload;
|
|
5640
6442
|
}
|
|
@@ -5650,6 +6452,14 @@ class Datalyr {
|
|
|
5650
6452
|
if (this.consent && this.consent.analytics === false) {
|
|
5651
6453
|
return false;
|
|
5652
6454
|
}
|
|
6455
|
+
// Shopify Customer Privacy (9.A.1 — LEGAL): on a Shopify storefront, a shopper who
|
|
6456
|
+
// declined the native consent banner must not be tracked, even when the merchant
|
|
6457
|
+
// never wired setConsent(). null = API absent (non-Plus store not using it, or
|
|
6458
|
+
// script loaded off-Shopify) → fall through. A detected Shopify storefront
|
|
6459
|
+
// fails closed while the asynchronous Customer Privacy API is unresolved.
|
|
6460
|
+
if (this.shopifyAnalyticsConsent() === false) {
|
|
6461
|
+
return false;
|
|
6462
|
+
}
|
|
5653
6463
|
// Check Do Not Track
|
|
5654
6464
|
if (this.config.respectDoNotTrack && isDoNotTrackEnabled()) {
|
|
5655
6465
|
return false;
|
|
@@ -5666,8 +6476,196 @@ class Datalyr {
|
|
|
5666
6476
|
* pixels (which share data). No consent set = allowed (default).
|
|
5667
6477
|
*/
|
|
5668
6478
|
consentAllowsMarketing() {
|
|
6479
|
+
// Shopify Customer Privacy (9.A.1): a declined native banner blocks marketing use
|
|
6480
|
+
// (pixel loads, click-id/email forwarding) even with no setConsent() call.
|
|
6481
|
+
// null = no signal → defer to the setConsent-based policy below, unchanged.
|
|
6482
|
+
if (this.shopifyMarketingConsent() === false) {
|
|
6483
|
+
return false;
|
|
6484
|
+
}
|
|
5669
6485
|
return !this.consent || (this.consent.marketing !== false && this.consent.sale !== false);
|
|
5670
6486
|
}
|
|
6487
|
+
/**
|
|
6488
|
+
* Shopify Customer Privacy API handle (9.A.1), or null when it doesn't apply.
|
|
6489
|
+
*
|
|
6490
|
+
* X-2 (LEGAL): gated on RUNTIME detection of `window.Shopify.customerPrivacy`, NOT on
|
|
6491
|
+
* `config.platform === 'shopify'`. A Shopify merchant who installs via a plain <script>
|
|
6492
|
+
* snippet or a headless storefront (no `platform:'shopify'`) still exposes customerPrivacy;
|
|
6493
|
+
* gating on config.platform meant a shopper who DECLINED the native banner (with no
|
|
6494
|
+
* setConsent() wired) was fully tracked — nullifying the consent feature for a whole install
|
|
6495
|
+
* class, and unfixable server-side (platform is install-time only). Every access is wrapped —
|
|
6496
|
+
* a Shopify API shape change (or a non-Shopify site) must NEVER break tracking (→ null = fail open).
|
|
6497
|
+
*/
|
|
6498
|
+
getShopifyCustomerPrivacy() {
|
|
6499
|
+
var _a, _b;
|
|
6500
|
+
if (typeof window === 'undefined')
|
|
6501
|
+
return null;
|
|
6502
|
+
try {
|
|
6503
|
+
return (_b = (_a = window.Shopify) === null || _a === void 0 ? void 0 : _a.customerPrivacy) !== null && _b !== void 0 ? _b : null;
|
|
6504
|
+
}
|
|
6505
|
+
catch (_c) {
|
|
6506
|
+
return null;
|
|
6507
|
+
}
|
|
6508
|
+
}
|
|
6509
|
+
isShopifyStorefront() {
|
|
6510
|
+
var _a;
|
|
6511
|
+
if (((_a = this.config) === null || _a === void 0 ? void 0 : _a.platform) === 'shopify')
|
|
6512
|
+
return true;
|
|
6513
|
+
if (typeof window === 'undefined')
|
|
6514
|
+
return false;
|
|
6515
|
+
try {
|
|
6516
|
+
return Boolean(window.Shopify) ||
|
|
6517
|
+
window.location.hostname.toLowerCase().endsWith('.myshopify.com');
|
|
6518
|
+
}
|
|
6519
|
+
catch (_b) {
|
|
6520
|
+
return false;
|
|
6521
|
+
}
|
|
6522
|
+
}
|
|
6523
|
+
/**
|
|
6524
|
+
* Shopify's analytics-consent signal: true/false when the Customer Privacy API is
|
|
6525
|
+
* present and answers, null when there's no signal (API absent / unexpected shape)
|
|
6526
|
+
* — null means "no restriction from Shopify", i.e. today's behavior.
|
|
6527
|
+
*/
|
|
6528
|
+
shopifyAnalyticsConsent() {
|
|
6529
|
+
try {
|
|
6530
|
+
const cp = this.getShopifyCustomerPrivacy();
|
|
6531
|
+
if (!cp)
|
|
6532
|
+
return this.isShopifyStorefront() ? false : null;
|
|
6533
|
+
if (typeof cp.analyticsProcessingAllowed === 'function') {
|
|
6534
|
+
const allowed = cp.analyticsProcessingAllowed();
|
|
6535
|
+
return typeof allowed === 'boolean' ? allowed : null;
|
|
6536
|
+
}
|
|
6537
|
+
// Older API surface: the aggregate "can this visitor be tracked" signal.
|
|
6538
|
+
if (typeof cp.userCanBeTracked === 'function') {
|
|
6539
|
+
const allowed = cp.userCanBeTracked();
|
|
6540
|
+
return typeof allowed === 'boolean' ? allowed : null;
|
|
6541
|
+
}
|
|
6542
|
+
return this.isShopifyStorefront() ? false : null;
|
|
6543
|
+
}
|
|
6544
|
+
catch (_a) {
|
|
6545
|
+
return this.isShopifyStorefront() ? false : null;
|
|
6546
|
+
}
|
|
6547
|
+
}
|
|
6548
|
+
/**
|
|
6549
|
+
* Shopify's marketing-consent signal (gates pixels, click-id/email → CAPI, cart
|
|
6550
|
+
* attribute stamping, auto-identify email capture). Same null semantics as
|
|
6551
|
+
* shopifyAnalyticsConsent().
|
|
6552
|
+
*/
|
|
6553
|
+
shopifyMarketingConsent() {
|
|
6554
|
+
try {
|
|
6555
|
+
const cp = this.getShopifyCustomerPrivacy();
|
|
6556
|
+
if (!cp || typeof cp.marketingAllowed !== 'function') {
|
|
6557
|
+
return this.isShopifyStorefront() ? false : null;
|
|
6558
|
+
}
|
|
6559
|
+
const allowed = cp.marketingAllowed();
|
|
6560
|
+
return typeof allowed === 'boolean'
|
|
6561
|
+
? allowed
|
|
6562
|
+
: (this.isShopifyStorefront() ? false : null);
|
|
6563
|
+
}
|
|
6564
|
+
catch (_a) {
|
|
6565
|
+
return this.isShopifyStorefront() ? false : null;
|
|
6566
|
+
}
|
|
6567
|
+
}
|
|
6568
|
+
/**
|
|
6569
|
+
* Listen for Shopify's `visitorConsentCollected` document event (9.A.1) so a
|
|
6570
|
+
* consent decision made mid-session takes effect without a reload. Revocation is
|
|
6571
|
+
* enforced immediately, mirroring setConsent(): queue gated + purged, pixels torn
|
|
6572
|
+
* down, auto-identify (email capture + /account.json polling) destroyed. On grant
|
|
6573
|
+
* the queue re-enables (track() re-checks shouldTrack() per event anyway) and cart
|
|
6574
|
+
* attribute stamping runs; pixels / auto-identify resume on the next page load —
|
|
6575
|
+
* the same convention as optIn().
|
|
6576
|
+
*/
|
|
6577
|
+
setupShopifyConsentListener() {
|
|
6578
|
+
// X-2: NOT gated on config.platform — a plain-snippet/headless Shopify install must also honor
|
|
6579
|
+
// a mid-session consent decision. The `visitorConsentCollected` event only fires on Shopify
|
|
6580
|
+
// storefronts, and the handler + loadFeatures are fully guarded, so this is a safe no-op
|
|
6581
|
+
// everywhere else.
|
|
6582
|
+
if (typeof document === 'undefined')
|
|
6583
|
+
return;
|
|
6584
|
+
try {
|
|
6585
|
+
this.shopifyConsentHandler = () => {
|
|
6586
|
+
try {
|
|
6587
|
+
this.onShopifyConsentChanged();
|
|
6588
|
+
}
|
|
6589
|
+
catch (error) {
|
|
6590
|
+
this.log('Shopify consent change handling failed:', error);
|
|
6591
|
+
}
|
|
6592
|
+
};
|
|
6593
|
+
document.addEventListener('visitorConsentCollected', this.shopifyConsentHandler);
|
|
6594
|
+
// TR-15: customerPrivacy loads ASYNCHRONOUSLY. Until it's present shopifyAnalyticsConsent()
|
|
6595
|
+
// returns null (fail-open → events send in the pre-load window). Force the feature to load
|
|
6596
|
+
// and re-run the full consent evaluation in the callback so a declined visitor is gated
|
|
6597
|
+
// (queue disabled + purged) as soon as the API answers, without waiting for a reload or a
|
|
6598
|
+
// banner interaction. Guarded — a missing/changed loadFeatures must never break tracking.
|
|
6599
|
+
const shopify = window.Shopify;
|
|
6600
|
+
if (shopify && typeof shopify.loadFeatures === 'function') {
|
|
6601
|
+
shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }], (error) => {
|
|
6602
|
+
if (error) {
|
|
6603
|
+
this.log('Shopify loadFeatures(consent-tracking-api) failed:', error);
|
|
6604
|
+
return;
|
|
6605
|
+
}
|
|
6606
|
+
try {
|
|
6607
|
+
this.onShopifyConsentChanged();
|
|
6608
|
+
}
|
|
6609
|
+
catch (e) {
|
|
6610
|
+
this.log('Shopify post-load consent eval failed:', e);
|
|
6611
|
+
}
|
|
6612
|
+
});
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6615
|
+
catch (error) {
|
|
6616
|
+
this.log('Shopify consent listener setup failed:', error);
|
|
6617
|
+
}
|
|
6618
|
+
}
|
|
6619
|
+
onShopifyConsentChanged() {
|
|
6620
|
+
const allowed = this.shouldTrack();
|
|
6621
|
+
this.queue.setEnabled(allowed);
|
|
6622
|
+
// TR-15 (P3): a mid-session grant must persist the in-memory anon id NOW — otherwise a
|
|
6623
|
+
// visitor declined at init keeps a memory-only id and this session's events land under a
|
|
6624
|
+
// visitor_id that vanishes on the next page load. Idempotent.
|
|
6625
|
+
if (allowed)
|
|
6626
|
+
this.identity.enablePersistence();
|
|
6627
|
+
if (allowed)
|
|
6628
|
+
this.trackInitialPageViewOnce();
|
|
6629
|
+
if (!allowed) {
|
|
6630
|
+
// Mirror setConsent() withdrawal: purge buffered events so events captured
|
|
6631
|
+
// before the decline can't drain if consent is later re-granted.
|
|
6632
|
+
this.queue.clear();
|
|
6633
|
+
this.queue.clearOffline();
|
|
6634
|
+
}
|
|
6635
|
+
const marketingBlocked = this.shopifyMarketingConsent() === false;
|
|
6636
|
+
// Stop email capture (form interceptors + /account.json polling) immediately.
|
|
6637
|
+
if ((!allowed || marketingBlocked) && this.autoIdentify) {
|
|
6638
|
+
this.autoIdentify.destroy();
|
|
6639
|
+
this.autoIdentify = undefined;
|
|
6640
|
+
}
|
|
6641
|
+
// Same caveat as setConsent(): an already-injected pixel global (fbq/gtag/ttq)
|
|
6642
|
+
// keeps running in the page; full removal is on reload.
|
|
6643
|
+
if (!this.consentAllowsMarketing() && this.container) {
|
|
6644
|
+
this.container.cleanupAllIframes();
|
|
6645
|
+
this.container = undefined;
|
|
6646
|
+
}
|
|
6647
|
+
// X-1: marketing withdrawn (Shopify decline) → stop stamping Stripe/CC outbound links.
|
|
6648
|
+
if (!this.consentAllowsMarketing()) {
|
|
6649
|
+
this.disposeMarketingLinkDecorators();
|
|
6650
|
+
}
|
|
6651
|
+
// Grant direction: cart-attribute stamping is cheap and idempotent (merges via
|
|
6652
|
+
// /cart/update.js), so run it now instead of waiting for the next page load.
|
|
6653
|
+
if (allowed && !marketingBlocked && this.config.shopifyCartAttributes === true) {
|
|
6654
|
+
this.syncShopifyCartAttributes().catch((error) => {
|
|
6655
|
+
this.log('Shopify cart attribute sync failed:', error);
|
|
6656
|
+
});
|
|
6657
|
+
}
|
|
6658
|
+
this.log('Shopify consent collected — analytics allowed:', allowed, '— marketing blocked:', marketingBlocked);
|
|
6659
|
+
}
|
|
6660
|
+
/** Release the automatic landing pageview once, after init and consent. */
|
|
6661
|
+
trackInitialPageViewOnce() {
|
|
6662
|
+
if (!this.initialPageViewReady || this.initialPageViewSent)
|
|
6663
|
+
return;
|
|
6664
|
+
if (!this.config.trackPageViews || !this.initialized || !this.shouldTrack())
|
|
6665
|
+
return;
|
|
6666
|
+
this.initialPageViewSent = true;
|
|
6667
|
+
this.page();
|
|
6668
|
+
}
|
|
5671
6669
|
/**
|
|
5672
6670
|
* Setup SPA tracking
|
|
5673
6671
|
* Fixed Issue #15: Store original methods for cleanup
|
|
@@ -5768,7 +6766,7 @@ class Datalyr {
|
|
|
5768
6766
|
stack: error.stack,
|
|
5769
6767
|
context,
|
|
5770
6768
|
timestamp: new Date().toISOString(),
|
|
5771
|
-
url: window.location.href
|
|
6769
|
+
url: redactUrl(window.location.href) // 9.A.4: no secret query values in error logs
|
|
5772
6770
|
};
|
|
5773
6771
|
this.errors.push(errorInfo);
|
|
5774
6772
|
// Keep only recent errors
|
|
@@ -5850,6 +6848,10 @@ class Datalyr {
|
|
|
5850
6848
|
window.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
5851
6849
|
this.visibilityHandler = undefined;
|
|
5852
6850
|
}
|
|
6851
|
+
if (this.shopifyConsentHandler) {
|
|
6852
|
+
document.removeEventListener('visitorConsentCollected', this.shopifyConsentHandler);
|
|
6853
|
+
this.shopifyConsentHandler = undefined;
|
|
6854
|
+
}
|
|
5853
6855
|
if (this.outboundDisposer) {
|
|
5854
6856
|
this.outboundDisposer();
|
|
5855
6857
|
this.outboundDisposer = undefined;
|
|
@@ -5858,6 +6860,10 @@ class Datalyr {
|
|
|
5858
6860
|
this.stripeLinksDisposer();
|
|
5859
6861
|
this.stripeLinksDisposer = undefined;
|
|
5860
6862
|
}
|
|
6863
|
+
if (this.stripeSessionWatcher) {
|
|
6864
|
+
this.stripeSessionWatcher.stop();
|
|
6865
|
+
this.stripeSessionWatcher = undefined;
|
|
6866
|
+
}
|
|
5861
6867
|
// Clean up queue
|
|
5862
6868
|
if (this.queue) {
|
|
5863
6869
|
this.queue.destroy();
|
|
@@ -5883,6 +6889,8 @@ class Datalyr {
|
|
|
5883
6889
|
this.container = undefined;
|
|
5884
6890
|
this.autoIdentify = undefined;
|
|
5885
6891
|
this.lastSpaPath = null;
|
|
6892
|
+
this.initialPageViewReady = false;
|
|
6893
|
+
this.initialPageViewSent = false;
|
|
5886
6894
|
// Clear any remaining data
|
|
5887
6895
|
this.superProperties = {};
|
|
5888
6896
|
this.userProperties = {};
|
|
@@ -5891,13 +6899,27 @@ class Datalyr {
|
|
|
5891
6899
|
this.log('SDK destroyed');
|
|
5892
6900
|
}
|
|
5893
6901
|
}
|
|
5894
|
-
|
|
5895
|
-
|
|
6902
|
+
/**
|
|
6903
|
+
* Create an independent SDK instance without reading or mutating window.
|
|
6904
|
+
*
|
|
6905
|
+
* The CDN bootstrap uses this when an existing global is configured for a
|
|
6906
|
+
* different workspace. Package consumers can also opt into multiple explicit
|
|
6907
|
+
* instances without changing the default singleton contract below.
|
|
6908
|
+
*/
|
|
6909
|
+
function createDatalyrInstance() {
|
|
6910
|
+
return new Datalyr();
|
|
6911
|
+
}
|
|
6912
|
+
// Create the default singleton. TR-23: reuse an existing window.datalyr (a
|
|
6913
|
+
// prior tag's instance or a package singleton) so npm/ESM/CJS consumers retain
|
|
6914
|
+
// the established one-global behavior. Workspace conflict resolution belongs
|
|
6915
|
+
// to the CDN bootstrap, which has the authoritative script-tag configuration.
|
|
6916
|
+
const datalyr = (typeof window !== 'undefined' && window.datalyr) || createDatalyrInstance();
|
|
5896
6917
|
// Expose global API
|
|
5897
6918
|
if (typeof window !== 'undefined') {
|
|
5898
6919
|
window.datalyr = datalyr;
|
|
5899
6920
|
}
|
|
5900
6921
|
|
|
6922
|
+
exports.createDatalyrInstance = createDatalyrInstance;
|
|
5901
6923
|
exports.datalyr = datalyr;
|
|
5902
6924
|
exports.default = datalyr;
|
|
5903
6925
|
//# sourceMappingURL=datalyr.cjs.js.map
|