@datalyr/web 1.7.6 → 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 (42) 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.map +1 -1
  7. package/dist/config.d.ts +1 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/datalyr.cjs.js +751 -46
  10. package/dist/datalyr.cjs.js.map +1 -1
  11. package/dist/datalyr.esm.js +751 -47
  12. package/dist/datalyr.esm.js.map +1 -1
  13. package/dist/datalyr.esm.min.js +1 -1
  14. package/dist/datalyr.esm.min.js.map +1 -1
  15. package/dist/datalyr.js +751 -46
  16. package/dist/datalyr.js.map +1 -1
  17. package/dist/datalyr.min.js +1 -1
  18. package/dist/datalyr.min.js.map +1 -1
  19. package/dist/identify-dedupe.test.d.ts +2 -0
  20. package/dist/identify-dedupe.test.d.ts.map +1 -0
  21. package/dist/identity-pii.test.d.ts +2 -0
  22. package/dist/identity-pii.test.d.ts.map +1 -0
  23. package/dist/identity.d.ts +46 -0
  24. package/dist/identity.d.ts.map +1 -1
  25. package/dist/index.d.ts +68 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.test.d.ts +3 -0
  28. package/dist/index.test.d.ts.map +1 -0
  29. package/dist/queue-403.test.d.ts +2 -0
  30. package/dist/queue-403.test.d.ts.map +1 -0
  31. package/dist/queue.d.ts +17 -0
  32. package/dist/queue.d.ts.map +1 -1
  33. package/dist/storage-migration.test.d.ts +2 -0
  34. package/dist/storage-migration.test.d.ts.map +1 -0
  35. package/dist/storage.d.ts.map +1 -1
  36. package/dist/stripe-session.d.ts +75 -0
  37. package/dist/stripe-session.d.ts.map +1 -0
  38. package/dist/stripe-session.test.d.ts +2 -0
  39. package/dist/stripe-session.test.d.ts.map +1 -0
  40. package/dist/types.d.ts +1 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.7.6
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);
@@ -1089,6 +1104,9 @@ class IdentityManager {
1089
1104
  constructor(options = {}) {
1090
1105
  this.userId = null;
1091
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;
1092
1110
  this.persistNewId = options.persistNewId !== false;
1093
1111
  this.anonymousId = this.getOrCreateAnonymousId();
1094
1112
  this.userId = this.getStoredUserId();
@@ -1215,6 +1233,126 @@ class IdentityManager {
1215
1233
  // corrupted by JSON.parse — both would fragment identity from the second page on.
1216
1234
  return storage.getString('dl_user_id');
1217
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
+ }
1218
1356
  /**
1219
1357
  * Get the anonymous ID
1220
1358
  */
@@ -1263,8 +1401,9 @@ class IdentityManager {
1263
1401
  }
1264
1402
  const previousUserId = this.userId;
1265
1403
  this.userId = userId;
1266
- // Persist for future sessions
1267
- 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);
1268
1407
  // Return identity link data (will be sent as $identify event)
1269
1408
  return {
1270
1409
  anonymous_id: this.anonymousId,
@@ -1287,7 +1426,7 @@ class IdentityManager {
1287
1426
  // Update current user ID if aliasing to current anonymous ID
1288
1427
  if (!previousId || previousId === this.anonymousId) {
1289
1428
  this.userId = userId;
1290
- storage.set('dl_user_id', userId);
1429
+ this.persistUserId(userId);
1291
1430
  }
1292
1431
  return aliasData;
1293
1432
  }
@@ -1297,7 +1436,12 @@ class IdentityManager {
1297
1436
  */
1298
1437
  reset() {
1299
1438
  this.userId = null;
1439
+ // WEB-26: invalidate any encrypted write still in flight.
1440
+ this.piiGeneration += 1;
1300
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');
1301
1445
  storage.remove('dl_user_traits');
1302
1446
  // Generate new anonymous ID for privacy
1303
1447
  this.anonymousId = `anon_${generateUUID()}`;
@@ -1624,6 +1768,12 @@ class AttributionManager {
1624
1768
  'gbraid', // Google Ads (iOS)
1625
1769
  'wbraid', // Google Ads (web)
1626
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
1627
1777
  'msclkid', // Microsoft/Bing
1628
1778
  'twclid', // Twitter/X
1629
1779
  'li_fat_id', // LinkedIn
@@ -1972,8 +2122,21 @@ class AttributionManager {
1972
2122
  // The old `hasCurrentAttribution` (truthy because source is always at least 'direct')
1973
2123
  // made the fallback below dead code AND caused storeLastTouch to overwrite a real
1974
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.
1975
2137
  const hasRealAttribution = !!(current.clickId ||
1976
2138
  current.campaign ||
2139
+ current.lyr ||
1977
2140
  (current.source && current.source !== 'direct'));
1978
2141
  // Direct / internal navigation: fall back to persisted attribution so the event
1979
2142
  // isn't mis-attributed to 'direct'. FSR-48: prefer the LAST touch (the most recent
@@ -2173,15 +2336,20 @@ class AttributionManager {
2173
2336
  const DEFAULT_CRITICAL_EVENTS = ['purchase', 'signup', 'subscribe', 'lead', 'conversion'];
2174
2337
  // Default high priority events that use faster batching
2175
2338
  const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_item', 'search'];
2176
- // A 429 is a deliberate backpressure signal, not a transient failure tagged so the
2177
- // send path can route it to the offline queue WITHOUT retrying (which would storm the
2178
- // 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.
2179
2346
  class RateLimitError extends Error {
2180
2347
  }
2181
- // A permanent (non-retryable) failure — a 4xx other than 408/429 (invalid event shape,
2182
- // origin not allowed, auth). Retrying or parking it just head-of-line-blocks the offline
2183
- // queue forever and hammers ingest. Tagged so the send paths DROP the batch instead of
2184
- // 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.)
2185
2353
  class PermanentError extends Error {
2186
2354
  constructor(status, message) {
2187
2355
  super(message || `HTTP ${status}`);
@@ -2204,6 +2372,11 @@ class EventQueue {
2204
2372
  this.inFlight = []; // FIXED (WEB-3): batch currently in _flight's keepalive fetch — forceFlush must NOT re-beacon it
2205
2373
  this.enabled = true; // FIXED (consent): when false (opt-out / withdrawn analytics consent) all enqueue/flush/drain is a no-op
2206
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;
2207
2380
  // Coerce numeric config to sane values. The old `config.X || default` both turned a
2208
2381
  // legitimate 0 into the default AND let invalid values through — a NEGATIVE batchSize
2209
2382
  // made processOfflineQueue's `splice(0, -1)` loop forever and FREEZE the host tab.
@@ -2429,7 +2602,12 @@ class EventQueue {
2429
2602
  // FSR-55: don't park a permanently-rejected batch — it would never succeed and
2430
2603
  // would block the offline drain. Drop it.
2431
2604
  if (error instanceof PermanentError) {
2432
- 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.`);
2433
2611
  }
2434
2612
  else {
2435
2613
  this.moveToOfflineQueue(events);
@@ -2491,9 +2669,24 @@ class EventQueue {
2491
2669
  this.log(`Rate limited; backing off ${retryAfter}s`);
2492
2670
  throw new RateLimitError('Rate limited (429)');
2493
2671
  }
2494
- // FSR-55: a 4xx other than 408 (timeout) is PERMANENT invalid event shape
2495
- // (400), origin not allowed (403), auth (401). Retrying / parking it just blocks
2496
- // 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.
2497
2690
  if (response.status >= 400 && response.status < 500 && response.status !== 408) {
2498
2691
  throw new PermanentError(response.status, `HTTP ${response.status}: ${response.statusText}`);
2499
2692
  }
@@ -2580,9 +2773,25 @@ class EventQueue {
2580
2773
  // purge. Gating here (a persistence sink, not just the send sinks) closes it.
2581
2774
  if (!this.enabled)
2582
2775
  return;
2583
- // 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.
2584
2791
  if (this.offlineQueueLock) {
2585
- 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);
2586
2795
  return;
2587
2796
  }
2588
2797
  // Acquire lock
@@ -2601,6 +2810,10 @@ class EventQueue {
2601
2810
  if (this.offlineQueue.length > this.config.maxOfflineQueueSize) {
2602
2811
  const excess = this.offlineQueue.length - this.config.maxOfflineQueueSize;
2603
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).`);
2604
2817
  }
2605
2818
  this.saveOfflineQueue();
2606
2819
  }
@@ -2709,7 +2922,15 @@ class EventQueue {
2709
2922
  // that would block every event behind it forever and hammer ingest every tick.
2710
2923
  // Drop it (already spliced off the front) and keep draining the rest.
2711
2924
  if (error instanceof PermanentError) {
2712
- 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.`);
2713
2934
  this.saveOfflineQueue();
2714
2935
  continue;
2715
2936
  }
@@ -2742,6 +2963,23 @@ class EventQueue {
2742
2963
  getOfflineQueueSize() {
2743
2964
  return this.offlineQueue.length;
2744
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
+ }
2745
2983
  /**
2746
2984
  * Get network status
2747
2985
  */
@@ -4370,6 +4608,256 @@ class AutoIdentifyManager {
4370
4608
  // someone else (and feeds that person's email to Meta advanced matching).
4371
4609
  AutoIdentifyManager.THIRD_PARTY_EMAIL_FIELD = /(recipient|friend|gift|refer|invite|colleague|coworker|share|to[-_]?email|email[-_]?to|second(ary)?[-_]?email)/i;
4372
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
+
4373
4861
  /** Keys the remote config is allowed to fill on DatalyrConfig. */
4374
4862
  const REMOTE_KEYS = [
4375
4863
  'autoIdentify',
@@ -4377,6 +4865,7 @@ const REMOTE_KEYS = [
4377
4865
  'autoIdentifyAPI',
4378
4866
  'autoIdentifyShopify',
4379
4867
  'shopifyCartAttributes',
4868
+ 'stripeCheckoutSessions',
4380
4869
  'checkoutChampDomains',
4381
4870
  'respectGlobalPrivacyControl',
4382
4871
  'respectDoNotTrack',
@@ -4415,6 +4904,16 @@ function applyRemoteConfig(config, remote, explicitKeys) {
4415
4904
  * Datalyr Web SDK
4416
4905
  * Modern attribution tracking for web applications
4417
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';
4418
4917
  class Datalyr {
4419
4918
  constructor() {
4420
4919
  // Keys the caller passed to init() (before built-in defaults were merged).
@@ -4430,10 +4929,26 @@ class Datalyr {
4430
4929
  this.errors = [];
4431
4930
  this.MAX_ERRORS = 50;
4432
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;
4433
4937
  // FIXED (ISSUE-01): Async initialization promise to prevent race conditions
4434
4938
  this.initializationPromise = null;
4435
4939
  // Opt-out check moved to init() after cookies configured (Issue #14)
4436
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
+ }
4437
4952
  /**
4438
4953
  * Initialize the SDK
4439
4954
  */
@@ -4577,6 +5092,11 @@ class Datalyr {
4577
5092
  const deviceId = this.identity.getAnonymousId();
4578
5093
  yield dataEncryption.initialize(this.config.workspaceId, deviceId);
4579
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();
4580
5100
  this.log('Encryption initialized, user properties loaded');
4581
5101
  }
4582
5102
  catch (encErr) {
@@ -4666,14 +5186,20 @@ class Datalyr {
4666
5186
  // Setup auto-identify callback
4667
5187
  this.autoIdentify.initialize((email, source) => {
4668
5188
  this.log(`Auto-identified user: ${email} from ${source}`);
4669
- // Track auto-identify event
4670
- this.track('$auto_identify', {
4671
- email,
4672
- source,
4673
- timestamp: Date.now()
4674
- });
4675
- // Automatically call identify with email
4676
- 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 });
4677
5203
  });
4678
5204
  }
4679
5205
  // Stamp attribution signals into the Shopify cart (OPT-IN, default off).
@@ -4704,10 +5230,22 @@ class Datalyr {
4704
5230
  if (this.config.stripePaymentLinks !== false && this.shouldTrack()) {
4705
5231
  this.syncStripePaymentLinks((_b = this.config.stripeLinkDomains) !== null && _b !== void 0 ? _b : []);
4706
5232
  }
4707
- // Track initial page view if enabled (AFTER encryption ready)
4708
- if (this.config.trackPageViews) {
4709
- 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();
4710
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();
4711
5249
  this.log('Async initialization complete');
4712
5250
  }
4713
5251
  catch (error) {
@@ -4840,6 +5378,50 @@ class Datalyr {
4840
5378
  /**
4841
5379
  * Identify a user
4842
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
+ }
4843
5425
  identify(userId, traits = {}) {
4844
5426
  if (!this.initialized) {
4845
5427
  console.warn('[Datalyr] SDK not initialized. Call init() first.');
@@ -4879,8 +5461,31 @@ class Datalyr {
4879
5461
  // If encryption fails, user traits are only stored in memory (this.userProperties)
4880
5462
  // and will be lost on page reload - but PII is NOT exposed in localStorage
4881
5463
  });
4882
- // Track $identify event
4883
- 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
+ }
4884
5489
  // Call plugin handlers
4885
5490
  if (this.config.plugins) {
4886
5491
  for (const plugin of this.config.plugins) {
@@ -4900,6 +5505,18 @@ class Datalyr {
4900
5505
  this.trackError(error, { userId });
4901
5506
  }
4902
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
+ }
4903
5520
  /**
4904
5521
  * Track a page view
4905
5522
  */
@@ -5017,6 +5634,9 @@ class Datalyr {
5017
5634
  // values to the next user's events (cross-user contamination on shared devices).
5018
5635
  this.superProperties = {};
5019
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);
5020
5640
  // Clear the auto-identify guard so a different user on the same browser is
5021
5641
  // re-captured (it short-circuits while dl_auto_identified_email is present), and so
5022
5642
  // the prior user's email isn't left at rest after logout.
@@ -5164,16 +5784,21 @@ class Datalyr {
5164
5784
  * (same convention as pixels/auto-identify).
5165
5785
  */
5166
5786
  disposeMarketingLinkDecorators() {
5167
- var _a, _b;
5787
+ var _a, _b, _c;
5168
5788
  try {
5169
5789
  (_a = this.stripeLinksDisposer) === null || _a === void 0 ? void 0 : _a.call(this);
5170
5790
  }
5171
- catch ( /* best-effort */_c) { /* best-effort */ }
5791
+ catch ( /* best-effort */_d) { /* best-effort */ }
5172
5792
  this.stripeLinksDisposer = undefined;
5173
5793
  try {
5174
- (_b = this.outboundDisposer) === null || _b === void 0 ? void 0 : _b.call(this);
5794
+ (_b = this.stripeSessionWatcher) === null || _b === void 0 ? void 0 : _b.stop();
5175
5795
  }
5176
- catch ( /* best-effort */_d) { /* best-effort */ }
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 */ }
5177
5802
  this.outboundDisposer = undefined;
5178
5803
  }
5179
5804
  optOut() {
@@ -5202,8 +5827,22 @@ class Datalyr {
5202
5827
  // Purge PII at rest.
5203
5828
  this.userProperties = {};
5204
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);
5205
5837
  storage.remove('dl_auto_identified_email');
5206
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');
5207
5846
  this.log('User opted out');
5208
5847
  }
5209
5848
  /**
@@ -5266,8 +5905,16 @@ class Datalyr {
5266
5905
  this.autoIdentify = undefined;
5267
5906
  this.userProperties = {};
5268
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);
5269
5912
  storage.remove('dl_auto_identified_email');
5270
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');
5271
5918
  }
5272
5919
  else {
5273
5920
  // TR-15 (P3): grant → persist the in-memory anon id now (see onShopifyConsentChanged).
@@ -5521,6 +6168,39 @@ class Datalyr {
5521
6168
  * changes. window.open(paymentLink) is not covered (use getVisitorId()
5522
6169
  * manually); iframe/shadow-DOM links are unreachable from document.
5523
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
+ }
5524
6204
  syncStripePaymentLinks(extraDomains) {
5525
6205
  if (typeof window === "undefined" || typeof document === "undefined")
5526
6206
  return;
@@ -5747,7 +6427,7 @@ class Datalyr {
5747
6427
  // drift from the package again (it sat at 1.7.4 across the 1.7.5 release). The
5748
6428
  // build guard (scripts/check-bundle.js, run by build:check) still verifies the
5749
6429
  // deployable bundles carry the package.json version. (FSR-103)
5750
- sdk_version: '1.7.6',
6430
+ sdk_version: '1.7.8',
5751
6431
  sdk_name: 'datalyr-web-sdk',
5752
6432
  // A3-25: versioned-envelope stamp. Every SDK emits schema_version so the ingest/contract
5753
6433
  // layer can key on ONE canonical envelope version (snake_case fields, event_name/event_data
@@ -5940,6 +6620,8 @@ class Datalyr {
5940
6620
  // visitor_id that vanishes on the next page load. Idempotent.
5941
6621
  if (allowed)
5942
6622
  this.identity.enablePersistence();
6623
+ if (allowed)
6624
+ this.trackInitialPageViewOnce();
5943
6625
  if (!allowed) {
5944
6626
  // Mirror setConsent() withdrawal: purge buffered events so events captured
5945
6627
  // before the decline can't drain if consent is later re-granted.
@@ -5971,6 +6653,15 @@ class Datalyr {
5971
6653
  }
5972
6654
  this.log('Shopify consent collected — analytics allowed:', allowed, '— marketing blocked:', marketingBlocked);
5973
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
+ }
5974
6665
  /**
5975
6666
  * Setup SPA tracking
5976
6667
  * Fixed Issue #15: Store original methods for cleanup
@@ -6165,6 +6856,10 @@ class Datalyr {
6165
6856
  this.stripeLinksDisposer();
6166
6857
  this.stripeLinksDisposer = undefined;
6167
6858
  }
6859
+ if (this.stripeSessionWatcher) {
6860
+ this.stripeSessionWatcher.stop();
6861
+ this.stripeSessionWatcher = undefined;
6862
+ }
6168
6863
  // Clean up queue
6169
6864
  if (this.queue) {
6170
6865
  this.queue.destroy();
@@ -6190,6 +6885,8 @@ class Datalyr {
6190
6885
  this.container = undefined;
6191
6886
  this.autoIdentify = undefined;
6192
6887
  this.lastSpaPath = null;
6888
+ this.initialPageViewReady = false;
6889
+ this.initialPageViewSent = false;
6193
6890
  // Clear any remaining data
6194
6891
  this.superProperties = {};
6195
6892
  this.userProperties = {};
@@ -6198,18 +6895,25 @@ class Datalyr {
6198
6895
  this.log('SDK destroyed');
6199
6896
  }
6200
6897
  }
6201
- // Create singleton instance. TR-23: reuse an existing window.datalyr (a prior tag's instance —
6202
- // Shopify App Embed + a manual snippet coexisting, or a double-included bundle) instead of
6203
- // constructing a SECOND one. Two instances each patch history.pushState and fire their own
6204
- // pageviews (distinct event_ids no server-side dedup). A plain truthy check (NOT instanceof):
6205
- // two <script> tags run two separate IIFEs whose Datalyr classes are structurally identical but
6206
- // distinct objects, so instanceof would fail across them. With one shared instance, the second
6207
- // tag's bootstrap init() hits the instance-level `initialized` guard and no-ops.
6208
- const datalyr = (typeof window !== 'undefined' && window.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();
6209
6913
  // Expose global API
6210
6914
  if (typeof window !== 'undefined') {
6211
6915
  window.datalyr = datalyr;
6212
6916
  }
6213
6917
 
6214
- export { datalyr, datalyr as default };
6918
+ export { createDatalyrInstance, datalyr, datalyr as default };
6215
6919
  //# sourceMappingURL=datalyr.esm.js.map