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