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