@datalyr/web 1.7.6 → 1.7.9

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 (46) 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/container.d.ts +7 -3
  10. package/dist/container.d.ts.map +1 -1
  11. package/dist/container.test.d.ts +2 -0
  12. package/dist/container.test.d.ts.map +1 -0
  13. package/dist/datalyr.cjs.js +816 -51
  14. package/dist/datalyr.cjs.js.map +1 -1
  15. package/dist/datalyr.esm.js +816 -52
  16. package/dist/datalyr.esm.js.map +1 -1
  17. package/dist/datalyr.esm.min.js +1 -1
  18. package/dist/datalyr.esm.min.js.map +1 -1
  19. package/dist/datalyr.js +816 -51
  20. package/dist/datalyr.js.map +1 -1
  21. package/dist/datalyr.min.js +1 -1
  22. package/dist/datalyr.min.js.map +1 -1
  23. package/dist/identify-dedupe.test.d.ts +2 -0
  24. package/dist/identify-dedupe.test.d.ts.map +1 -0
  25. package/dist/identity-pii.test.d.ts +2 -0
  26. package/dist/identity-pii.test.d.ts.map +1 -0
  27. package/dist/identity.d.ts +46 -0
  28. package/dist/identity.d.ts.map +1 -1
  29. package/dist/index.d.ts +68 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.test.d.ts +3 -0
  32. package/dist/index.test.d.ts.map +1 -0
  33. package/dist/queue-403.test.d.ts +2 -0
  34. package/dist/queue-403.test.d.ts.map +1 -0
  35. package/dist/queue.d.ts +17 -0
  36. package/dist/queue.d.ts.map +1 -1
  37. package/dist/storage-migration.test.d.ts +2 -0
  38. package/dist/storage-migration.test.d.ts.map +1 -0
  39. package/dist/storage.d.ts.map +1 -1
  40. package/dist/stripe-session.d.ts +75 -0
  41. package/dist/stripe-session.d.ts.map +1 -0
  42. package/dist/stripe-session.test.d.ts +2 -0
  43. package/dist/stripe-session.test.d.ts.map +1 -0
  44. package/dist/types.d.ts +1 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +1 -1
package/dist/datalyr.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.7.6
2
+ * @datalyr/web v1.7.9
3
3
  * Datalyr Web SDK - Modern attribution tracking for web applications
4
4
  * (c) 2026 Datalyr Inc.
5
5
  * Released under the MIT License
@@ -418,8 +418,23 @@ var Datalyr = (function (exports) {
418
418
  // Get value from legacy key
419
419
  const value = this.storage.getItem(legacyKey);
420
420
  if (value) {
421
- // Extract the actual key name (remove '__dl_' prefix, keep 'dl_' part)
422
- const actualKey = legacyKey.slice('__dl_'.length); // e.g., '__dl_dl_anonymous_id' -> 'dl_anonymous_id'
421
+ // WEB-29: strip ONLY the leading '__'.
422
+ //
423
+ // This used to strip '__dl_', producing 'dl_anonymous_id' — but that
424
+ // is the LOGICAL key callers pass to get(), not the key it reads.
425
+ // get() prepends this.prefix ('dl_'), so the value actually lives at
426
+ // 'dl_dl_anonymous_id'. The migration therefore wrote to an address
427
+ // nothing reads AND then deleted the legacy key below, destroying
428
+ // the visitor's id instead of migrating it: a silent visitor reset
429
+ // and attribution loss for anyone still on the old prefix.
430
+ //
431
+ // legacy real key : __dl_dl_anonymous_id
432
+ // current real key: dl_dl_anonymous_id ← strip '__'
433
+ // logical key : dl_anonymous_id ← what get() is called with
434
+ //
435
+ // NOTE: the double 'dl_dl_' looks like a typo and is not. Renaming
436
+ // the prefix would orphan every stored id across the install base.
437
+ const actualKey = legacyKey.slice('__'.length); // '__dl_dl_anonymous_id' -> 'dl_dl_anonymous_id'
423
438
  // Only migrate if new key doesn't already exist (don't overwrite newer data)
424
439
  if (!this.storage.getItem(actualKey)) {
425
440
  this.storage.setItem(actualKey, value);
@@ -1092,6 +1107,9 @@ var Datalyr = (function (exports) {
1092
1107
  constructor(options = {}) {
1093
1108
  this.userId = null;
1094
1109
  this.sessionId = null;
1110
+ /** WEB-26: bumps whenever the persisted identity changes, so an in-flight
1111
+ * encrypted write can tell it has been superseded. */
1112
+ this.piiGeneration = 0;
1095
1113
  this.persistNewId = options.persistNewId !== false;
1096
1114
  this.anonymousId = this.getOrCreateAnonymousId();
1097
1115
  this.userId = this.getStoredUserId();
@@ -1218,6 +1236,126 @@ var Datalyr = (function (exports) {
1218
1236
  // corrupted by JSON.parse — both would fragment identity from the second page on.
1219
1237
  return storage.getString('dl_user_id');
1220
1238
  }
1239
+ /**
1240
+ * WEB-22 — is this user id personally identifying, and therefore not
1241
+ * something we may write to localStorage in the clear?
1242
+ *
1243
+ * The auto-identify path calls `identify(email, { email })`, so the raw
1244
+ * address became the `user_id` and was written unencrypted to `dl_user_id`.
1245
+ * Measured 2026-07-25: on the four workspaces using auto-identify, **every**
1246
+ * event carrying a `user_id` had an email in that column (20,502 / 20,502 on
1247
+ * `f6260736` over 7 days) — while the surrounding code went to real lengths to
1248
+ * encrypt the *same* address in `dl_auto_identified_email`.
1249
+ *
1250
+ * Deliberately narrow: an email is the case that actually occurs and the one
1251
+ * the SDK itself creates. Opaque application ids — the overwhelming majority —
1252
+ * keep the existing fast, synchronous, plaintext path with no behaviour change.
1253
+ */
1254
+ static looksLikePII(userId) {
1255
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userId);
1256
+ }
1257
+ /**
1258
+ * Persist the user id, encrypting it when it is PII.
1259
+ *
1260
+ * Three-way, and the ordering matters:
1261
+ * - not PII → plaintext `dl_user_id`, exactly as before (sync, always works)
1262
+ * - PII + crypto → encrypted `dl_user_id_pii`, and the plaintext key is REMOVED
1263
+ * - PII, no crypto → memory only, and both keys removed
1264
+ *
1265
+ * That last branch is the deliberate trade. `crypto.subtle` is absent on
1266
+ * http:// and old browsers, and index.ts is explicit that encryption is for
1267
+ * PII-at-rest only and "must NOT gate event delivery". So we never block an
1268
+ * event — `this.userId` is set in memory first and every event still carries
1269
+ * it — we only decline to *persist* an address we cannot protect. The cost is
1270
+ * that a returning visitor on an insecure origin is re-identified by the next
1271
+ * `identify()` call rather than restored from storage; the alternative is
1272
+ * writing their email to disk in the clear, which is what this fixes.
1273
+ */
1274
+ persistUserId(userId) {
1275
+ if (!IdentityManager.looksLikePII(userId)) {
1276
+ storage.set('dl_user_id', userId);
1277
+ // WEB-26: a previous PII user's encrypted id must not linger once an
1278
+ // opaque id takes over. Reachable when identify('opaque') runs before
1279
+ // hydrateEncryptedUserId() has populated this.userId, so index.ts sees no
1280
+ // current user and skips its account-switch reset().
1281
+ this.piiGeneration += 1;
1282
+ storage.remove('dl_user_id_pii');
1283
+ return;
1284
+ }
1285
+ // Never leave a plaintext copy behind — including one written by a previous
1286
+ // SDK version before this fix existed.
1287
+ storage.remove('dl_user_id');
1288
+ // WEB-26: the encrypted write is async, and reset()/optOut()/consent
1289
+ // withdrawal are not. Without a generation guard this sequence resurrects a
1290
+ // logged-out user's email: identify(email) → write pending →
1291
+ // remove('dl_user_id_pii') deletes NOTHING because the write has not landed
1292
+ // → the write lands → next page load hydrates the previous user's address
1293
+ // and every event ships user_id = their email. Stamp the write and discard
1294
+ // it if the identity moved on while it was in flight.
1295
+ const generation = ++this.piiGeneration;
1296
+ storage.setEncrypted('dl_user_id_pii', userId)
1297
+ .then(() => {
1298
+ if (generation !== this.piiGeneration)
1299
+ storage.remove('dl_user_id_pii');
1300
+ })
1301
+ .catch(() => {
1302
+ // No crypto.subtle (http://, legacy browser) or a storage failure. Drop
1303
+ // the at-rest copy rather than falling back to plaintext PII — but only
1304
+ // if this write is still the current one, or we would delete a newer
1305
+ // user's successfully-written value.
1306
+ if (generation === this.piiGeneration)
1307
+ storage.remove('dl_user_id_pii');
1308
+ });
1309
+ }
1310
+ /**
1311
+ * Restore a PII user id that was persisted encrypted. Async by necessity, so
1312
+ * it cannot run in the constructor; index.ts calls it during init, right after
1313
+ * `dataEncryption.initialize()`.
1314
+ *
1315
+ * Never overwrites an id already established this page load — an explicit
1316
+ * `identify()` that has already run is fresher than anything on disk.
1317
+ */
1318
+ hydrateEncryptedUserId() {
1319
+ return __awaiter(this, void 0, void 0, function* () {
1320
+ // WEB-26 (a) — migrate a plaintext address written by <= 1.7.7.
1321
+ //
1322
+ // persistUserId() only removes `dl_user_id` when identify() runs again, and
1323
+ // for the exact population this fix targets it never does:
1324
+ // AutoIdentifyManager.initialize() returns early while
1325
+ // `dl_auto_identified_email` exists (auto-identify.ts:88), so the callback
1326
+ // never fires. Without this the raw email would sit in localStorage forever
1327
+ // on every already-auto-identified browser.
1328
+ const legacy = storage.getString('dl_user_id');
1329
+ if (legacy && IdentityManager.looksLikePII(legacy)) {
1330
+ if (!this.userId)
1331
+ this.userId = legacy;
1332
+ this.persistUserId(legacy);
1333
+ return;
1334
+ }
1335
+ // WEB-26 (b) — recover a write that was dropped because encryption was not
1336
+ // keyed yet. index.ts sets `initialized = true` before initializeAsync()
1337
+ // finishes, so the documented `init(); identify(email)` pattern can reach
1338
+ // persistUserId() before dataEncryption has a key; that write rejects and
1339
+ // nothing is persisted. By here the key exists, so retry once.
1340
+ if (this.userId && IdentityManager.looksLikePII(this.userId)) {
1341
+ const alreadyStored = yield storage.getEncrypted('dl_user_id_pii', null);
1342
+ if (!alreadyStored)
1343
+ this.persistUserId(this.userId);
1344
+ return;
1345
+ }
1346
+ if (this.userId)
1347
+ return;
1348
+ try {
1349
+ const stored = yield storage.getEncrypted('dl_user_id_pii', null);
1350
+ if (typeof stored === 'string' && stored && !this.userId) {
1351
+ this.userId = stored;
1352
+ }
1353
+ }
1354
+ catch (_a) {
1355
+ // Undecryptable (rotated key, corrupt blob) — stay anonymous.
1356
+ }
1357
+ });
1358
+ }
1221
1359
  /**
1222
1360
  * Get the anonymous ID
1223
1361
  */
@@ -1266,8 +1404,9 @@ var Datalyr = (function (exports) {
1266
1404
  }
1267
1405
  const previousUserId = this.userId;
1268
1406
  this.userId = userId;
1269
- // Persist for future sessions
1270
- storage.set('dl_user_id', userId);
1407
+ // Persist for future sessions. WEB-22: encrypted when the id is PII (the
1408
+ // auto-identify path makes it the raw email); plaintext otherwise.
1409
+ this.persistUserId(userId);
1271
1410
  // Return identity link data (will be sent as $identify event)
1272
1411
  return {
1273
1412
  anonymous_id: this.anonymousId,
@@ -1290,7 +1429,7 @@ var Datalyr = (function (exports) {
1290
1429
  // Update current user ID if aliasing to current anonymous ID
1291
1430
  if (!previousId || previousId === this.anonymousId) {
1292
1431
  this.userId = userId;
1293
- storage.set('dl_user_id', userId);
1432
+ this.persistUserId(userId);
1294
1433
  }
1295
1434
  return aliasData;
1296
1435
  }
@@ -1300,7 +1439,12 @@ var Datalyr = (function (exports) {
1300
1439
  */
1301
1440
  reset() {
1302
1441
  this.userId = null;
1442
+ // WEB-26: invalidate any encrypted write still in flight.
1443
+ this.piiGeneration += 1;
1303
1444
  storage.remove('dl_user_id');
1445
+ // WEB-22: the encrypted PII copy must go too, or a logout would leave the
1446
+ // previous user's email at rest.
1447
+ storage.remove('dl_user_id_pii');
1304
1448
  storage.remove('dl_user_traits');
1305
1449
  // Generate new anonymous ID for privacy
1306
1450
  this.anonymousId = `anon_${generateUUID()}`;
@@ -1627,6 +1771,12 @@ var Datalyr = (function (exports) {
1627
1771
  'gbraid', // Google Ads (iOS)
1628
1772
  'wbraid', // Google Ads (web)
1629
1773
  'ttclid', // TikTok
1774
+ // WEB-28: OpenAI Ads. Captured by iOS and React Native since launch but
1775
+ // never by the web SDK, so every OpenAI Ads *web* conversion delivered with
1776
+ // attribution_type: none unless the customer passed the value by hand.
1777
+ // Placed after the Google block to match the mobile SDKs' ordering — the
1778
+ // first click id present wins, and this must not outrank fbclid/gclid.
1779
+ 'oppref', // OpenAI Ads
1630
1780
  'msclkid', // Microsoft/Bing
1631
1781
  'twclid', // Twitter/X
1632
1782
  'li_fat_id', // LinkedIn
@@ -1975,8 +2125,21 @@ var Datalyr = (function (exports) {
1975
2125
  // The old `hasCurrentAttribution` (truthy because source is always at least 'direct')
1976
2126
  // made the fallback below dead code AND caused storeLastTouch to overwrite a real
1977
2127
  // paid last-touch with 'direct'/'none' on the next internal navigation. (WEB NEW-1)
2128
+ // `lyr` counts as a real signal. It is the Datalyr tracking-link tag — the one
2129
+ // attribution parameter the product itself tells customers to put on a URL — and
2130
+ // omitting it meant a bare `?lyr=X` landing (no UTMs, no click id) scored FALSE:
2131
+ // the fallback below then replaced `current` wholesale with the stored touch,
2132
+ // discarding the tag that had just been parsed, and neither store* call ran, so it
2133
+ // never persisted either. Measured 2026-07-25 on the one workspace whose links are
2134
+ // bare: 96 landing URLs carried `lyr=`, 93 events kept it — 3 lost (3.1%), all of
2135
+ // them returning visitors (a first-time visitor has no stored touch to be replaced
2136
+ // by, which is the only reason the number is 3 and not 96). The edge worker's own
2137
+ // `buildDestination` emits exactly this URL shape when a link defines no UTMs.
2138
+ // iOS (AttributionManager.swift) and React Native persist `lyr` unconditionally;
2139
+ // this brings web into line.
1978
2140
  const hasRealAttribution = !!(current.clickId ||
1979
2141
  current.campaign ||
2142
+ current.lyr ||
1980
2143
  (current.source && current.source !== 'direct'));
1981
2144
  // Direct / internal navigation: fall back to persisted attribution so the event
1982
2145
  // isn't mis-attributed to 'direct'. FSR-48: prefer the LAST touch (the most recent
@@ -2176,15 +2339,20 @@ var Datalyr = (function (exports) {
2176
2339
  const DEFAULT_CRITICAL_EVENTS = ['purchase', 'signup', 'subscribe', 'lead', 'conversion'];
2177
2340
  // Default high priority events that use faster batching
2178
2341
  const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_item', 'search'];
2179
- // A 429 is a deliberate backpressure signal, not a transient failure tagged so the
2180
- // send path can route it to the offline queue WITHOUT retrying (which would storm the
2181
- // already-overloaded server). See sendBatch / the rateLimitedUntil gate.
2342
+ // WEB-23: how long to stop sending after a 403 (origin not allowed). Long enough not to
2343
+ // hammer a rejecting origin, short enough that a server-side config fix is picked up
2344
+ // within the same visit. A 403 carries no Retry-After, so this is fixed.
2345
+ const ORIGIN_REJECTED_BACKOFF_MS = 5 * 60 * 1000;
2346
+ // Backpressure, not failure — routed to the offline queue WITHOUT retrying (which would
2347
+ // storm an already-overloaded server) and gated behind rateLimitedUntil. Raised for a 429
2348
+ // and, since WEB-23, for a 403: both mean "not now", not "never". See sendBatch.
2182
2349
  class RateLimitError extends Error {
2183
2350
  }
2184
- // A permanent (non-retryable) failure — a 4xx other than 408/429 (invalid event shape,
2185
- // origin not allowed, auth). Retrying or parking it just head-of-line-blocks the offline
2186
- // queue forever and hammers ingest. Tagged so the send paths DROP the batch instead of
2187
- // unshifting it back to the head. (FSR-55)
2351
+ // A permanent (non-retryable) failure — a 4xx other than 403/408/429, i.e. an invalid
2352
+ // event shape (400) or bad auth (401). Retrying or parking it just head-of-line-blocks
2353
+ // the offline queue forever and hammers ingest. Tagged so the send paths DROP the batch
2354
+ // instead of unshifting it back to the head. (FSR-55, narrowed by WEB-23 — a 403 is
2355
+ // server *configuration* state and does change, so it is no longer in this class.)
2188
2356
  class PermanentError extends Error {
2189
2357
  constructor(status, message) {
2190
2358
  super(message || `HTTP ${status}`);
@@ -2207,6 +2375,11 @@ var Datalyr = (function (exports) {
2207
2375
  this.inFlight = []; // FIXED (WEB-3): batch currently in _flight's keepalive fetch — forceFlush must NOT re-beacon it
2208
2376
  this.enabled = true; // FIXED (consent): when false (opt-out / withdrawn analytics consent) all enqueue/flush/drain is a no-op
2209
2377
  this.rateLimitedUntil = 0; // FIXED (429): skip flush/drain until the server's Retry-After window passes
2378
+ // WEB-23: permanent drops used to be invisible — a debug-gated log line and
2379
+ // nothing else, so 11 days of 403s looked identical to a healthy queue. Counted
2380
+ // here and exposed via getStats() so a drop is at least observable.
2381
+ this.droppedEventCount = 0;
2382
+ this.lastDropStatus = null;
2210
2383
  // Coerce numeric config to sane values. The old `config.X || default` both turned a
2211
2384
  // legitimate 0 into the default AND let invalid values through — a NEGATIVE batchSize
2212
2385
  // made processOfflineQueue's `splice(0, -1)` loop forever and FREEZE the host tab.
@@ -2432,7 +2605,12 @@ var Datalyr = (function (exports) {
2432
2605
  // FSR-55: don't park a permanently-rejected batch — it would never succeed and
2433
2606
  // would block the offline drain. Drop it.
2434
2607
  if (error instanceof PermanentError) {
2435
- this.log(`Dropping ${events.length} events — permanent error ${error.status}`);
2608
+ this.droppedEventCount += events.length;
2609
+ this.lastDropStatus = error.status;
2610
+ // console.warn, not this.log: a silent drop is the failure mode that let the
2611
+ // 2026-07-13 outage run for 11 days. This must be visible without debug mode.
2612
+ console.warn(`[Datalyr Queue] Dropped ${events.length} events — permanent error ${error.status}. ` +
2613
+ `${this.droppedEventCount} dropped this session.`);
2436
2614
  }
2437
2615
  else {
2438
2616
  this.moveToOfflineQueue(events);
@@ -2494,9 +2672,24 @@ var Datalyr = (function (exports) {
2494
2672
  this.log(`Rate limited; backing off ${retryAfter}s`);
2495
2673
  throw new RateLimitError('Rate limited (429)');
2496
2674
  }
2497
- // FSR-55: a 4xx other than 408 (timeout) is PERMANENT invalid event shape
2498
- // (400), origin not allowed (403), auth (401). Retrying / parking it just blocks
2499
- // the queue and hammers ingest forever. Tag it so the caller drops the batch.
2675
+ // WEB-23: a 403 is NOT permanent it is server *configuration* state, and it
2676
+ // changes. On 2026-07-13 an allowed_origins regression made ingest reject three
2677
+ // workspaces with 403; the config was fixed 11 days later, but every event sent
2678
+ // in between had already been dropped on the floor by the FSR-55 rule below.
2679
+ // Eleven days of data, unrecoverable, with only a debug log to show for it.
2680
+ //
2681
+ // Treated like 429 instead: park the events offline behind a backoff window, so
2682
+ // we neither hammer a rejecting origin (FSR-55's real concern) nor discard data
2683
+ // that would have been accepted an hour later. The window is fixed because a 403
2684
+ // carries no Retry-After.
2685
+ if (response.status === 403) {
2686
+ this.rateLimitedUntil = Date.now() + ORIGIN_REJECTED_BACKOFF_MS;
2687
+ this.log(`Origin rejected (403); backing off ${ORIGIN_REJECTED_BACKOFF_MS / 1000}s and parking events`);
2688
+ throw new RateLimitError('Origin rejected (403)');
2689
+ }
2690
+ // FSR-55: a 4xx other than 403/408 (timeout) is PERMANENT — invalid event shape
2691
+ // (400) or auth (401). Retrying / parking it just blocks the queue and hammers
2692
+ // ingest forever. Tag it so the caller drops the batch.
2500
2693
  if (response.status >= 400 && response.status < 500 && response.status !== 408) {
2501
2694
  throw new PermanentError(response.status, `HTTP ${response.status}: ${response.statusText}`);
2502
2695
  }
@@ -2583,9 +2776,25 @@ var Datalyr = (function (exports) {
2583
2776
  // purge. Gating here (a persistence sink, not just the send sinks) closes it.
2584
2777
  if (!this.enabled)
2585
2778
  return;
2586
- // FIXED (DATA-03): Check if offline queue operation already in progress
2779
+ // WEB-24. This early return used to `return` outright — losing the batch, because
2780
+ // _flush() has ALREADY spliced these events out of the live queue before calling
2781
+ // here, so they would exist in neither queue.
2782
+ //
2783
+ // Verified 2026-07-25 that the loss is NOT currently reachable: this method is the
2784
+ // only user of offlineQueueLock, its critical section is fully synchronous (push,
2785
+ // splice, then a synchronous saveOfflineQueue → storage.set), and JS is
2786
+ // single-threaded — so the flag can never be observed set on entry. The path is
2787
+ // dead code today, and the review's "drops the batch on lock contention" is REFUTED
2788
+ // as a live loss path.
2789
+ //
2790
+ // It is kept and made safe anyway, because it is one `await` away from being live:
2791
+ // the moment saveOfflineQueue becomes async (IndexedDB, encrypted-at-rest storage,
2792
+ // anything), contention becomes real and silent data loss returns instantly.
2793
+ // Enqueue first, then bail — the events are never dropped either way.
2587
2794
  if (this.offlineQueueLock) {
2588
- console.warn('[Datalyr Queue] Offline queue operation already in progress');
2795
+ console.warn('[Datalyr Queue] Offline queue busy; buffering events without persisting');
2796
+ if (events)
2797
+ this.offlineQueue.push(...events);
2589
2798
  return;
2590
2799
  }
2591
2800
  // Acquire lock
@@ -2604,6 +2813,10 @@ var Datalyr = (function (exports) {
2604
2813
  if (this.offlineQueue.length > this.config.maxOfflineQueueSize) {
2605
2814
  const excess = this.offlineQueue.length - this.config.maxOfflineQueueSize;
2606
2815
  this.offlineQueue.splice(0, excess); // Remove oldest events
2816
+ // WEB-27: overflow is data loss too, and a sustained 403/park loop is
2817
+ // exactly what produces it. Count it rather than trimming silently.
2818
+ this.droppedEventCount += excess;
2819
+ console.warn(`[Datalyr Queue] Offline queue full — dropped ${excess} oldest event(s).`);
2607
2820
  }
2608
2821
  this.saveOfflineQueue();
2609
2822
  }
@@ -2712,7 +2925,15 @@ var Datalyr = (function (exports) {
2712
2925
  // that would block every event behind it forever and hammer ingest every tick.
2713
2926
  // Drop it (already spliced off the front) and keep draining the rest.
2714
2927
  if (error instanceof PermanentError) {
2715
- this.log(`Dropping poison offline batch (${batch.length}) permanent error ${error.status}`);
2928
+ // WEB-27: count and surface these too. WEB-23's whole effect is to
2929
+ // PARK more batches offline, so the drain — not the live flush — is
2930
+ // where drops now predominantly happen. Counting only _flush left the
2931
+ // dominant loss path invisible, which is the exact failure mode the
2932
+ // counter was added to end.
2933
+ this.droppedEventCount += batch.length;
2934
+ this.lastDropStatus = error.status;
2935
+ console.warn(`[Datalyr Queue] Dropped ${batch.length} offline events — permanent error ${error.status}. ` +
2936
+ `${this.droppedEventCount} dropped this session.`);
2716
2937
  this.saveOfflineQueue();
2717
2938
  continue;
2718
2939
  }
@@ -2745,6 +2966,23 @@ var Datalyr = (function (exports) {
2745
2966
  getOfflineQueueSize() {
2746
2967
  return this.offlineQueue.length;
2747
2968
  }
2969
+ /**
2970
+ * WEB-23: queue health, including events the SDK gave up on.
2971
+ *
2972
+ * `droppedEvents` is the number this session discarded as permanently rejected
2973
+ * (a 400/401). It exists because a silent drop is precisely the failure mode
2974
+ * that let the 2026-07-13 `allowed_origins` outage run for eleven days looking
2975
+ * exactly like a healthy queue. A non-zero value here means data was lost.
2976
+ */
2977
+ getStats() {
2978
+ return {
2979
+ queued: this.queue.length,
2980
+ offline: this.offlineQueue.length,
2981
+ droppedEvents: this.droppedEventCount,
2982
+ lastDropStatus: this.lastDropStatus,
2983
+ backoffUntil: this.rateLimitedUntil,
2984
+ };
2985
+ }
2748
2986
  /**
2749
2987
  * Get network status
2750
2988
  */
@@ -3452,12 +3690,10 @@ var Datalyr = (function (exports) {
3452
3690
  img.dataset.datalyrPixel = script.id;
3453
3691
  document.body.appendChild(img);
3454
3692
  }
3455
- /**
3456
- * Initialize third-party pixels (Meta, Google, TikTok)
3457
- */
3693
+ /** Initialize configured third-party pixels in the merchant's page context. */
3458
3694
  initializePixels() {
3459
3695
  return __awaiter(this, void 0, void 0, function* () {
3460
- var _a, _b, _c;
3696
+ var _a, _b, _c, _e;
3461
3697
  if (!this.pixels)
3462
3698
  return;
3463
3699
  // Initialize Meta Pixel
@@ -3472,8 +3708,58 @@ var Datalyr = (function (exports) {
3472
3708
  if (((_c = this.pixels.tiktok) === null || _c === void 0 ? void 0 : _c.enabled) && this.pixels.tiktok.pixel_id) {
3473
3709
  this.initializeTikTokPixel(this.pixels.tiktok);
3474
3710
  }
3711
+ // The Whop Pixel needs only the merchant's public company ID. Loading it
3712
+ // here lets Whop link this external-site visitor to a later hosted or
3713
+ // embedded Whop checkout without a Datalyr visitor_id in checkout metadata.
3714
+ if (((_e = this.pixels.whop) === null || _e === void 0 ? void 0 : _e.enabled) && this.pixels.whop.company_id) {
3715
+ this.initializeWhopPixel(this.pixels.whop);
3716
+ }
3475
3717
  });
3476
3718
  }
3719
+ /** Initialize Whop's official first-party attribution pixel. */
3720
+ initializeWhopPixel(config) {
3721
+ var _a, _b;
3722
+ try {
3723
+ const host = window;
3724
+ if (!host.whop) {
3725
+ const queue = host.whop = {
3726
+ q: [],
3727
+ t: Date.now(),
3728
+ s: [],
3729
+ o: 'https://t.whop.tw',
3730
+ track: function (...args) {
3731
+ queue.q.push([Date.now(), ...args]);
3732
+ },
3733
+ setScope: function (...args) {
3734
+ queue.s = args.filter((value) => typeof value === 'string');
3735
+ queue.q.push([Date.now(), 'setScope', ...queue.s]);
3736
+ },
3737
+ scope: function (...scope) {
3738
+ return {
3739
+ track: function (...args) {
3740
+ queue.q.push([Date.now(), ...args, { __scope: scope }]);
3741
+ },
3742
+ };
3743
+ },
3744
+ };
3745
+ const script = document.createElement('script');
3746
+ script.async = true;
3747
+ script.src = 'https://t.whop.tw/s.js';
3748
+ const firstScript = document.getElementsByTagName('script')[0];
3749
+ (_a = firstScript === null || firstScript === void 0 ? void 0 : firstScript.parentNode) === null || _a === void 0 ? void 0 : _a.insertBefore(script, firstScript);
3750
+ if (!firstScript)
3751
+ document.head.appendChild(script);
3752
+ }
3753
+ if (typeof ((_b = host.whop) === null || _b === void 0 ? void 0 : _b.setScope) !== 'function') {
3754
+ throw new Error('Existing window.whop does not expose setScope');
3755
+ }
3756
+ host.whop.setScope(config.company_id);
3757
+ this.log('Whop Pixel initialized:', config.company_id);
3758
+ }
3759
+ catch (error) {
3760
+ this.log('Error initializing Whop Pixel:', error);
3761
+ }
3762
+ }
3477
3763
  /**
3478
3764
  * Initialize Meta (Facebook) Pixel
3479
3765
  *
@@ -3629,7 +3915,7 @@ var Datalyr = (function (exports) {
3629
3915
  * Track event to all initialized pixels
3630
3916
  */
3631
3917
  trackToPixels(eventName, properties = {}, eventId) {
3632
- var _a, _b, _c, _e, _f, _g, _h, _j, _k, _l;
3918
+ var _a, _b, _c, _e, _f, _g, _h, _j, _k, _l, _m, _p;
3633
3919
  // Datalyr-internal events ($identify, $group, $alias, $auto_identify,
3634
3920
  // $app_download_click, …) are not conversions — never forward them to ad
3635
3921
  // pixels. (Previously they fired as noise custom events with the $ stripped.)
@@ -3726,6 +4012,18 @@ var Datalyr = (function (exports) {
3726
4012
  this.log('Error tracking TikTok Pixel event:', error);
3727
4013
  }
3728
4014
  }
4015
+ // Whop records checkout and payment events server-side. Datalyr only sends
4016
+ // page views here, including SPA navigations, so purchases are never doubled.
4017
+ if (((_p = (_m = this.pixels) === null || _m === void 0 ? void 0 : _m.whop) === null || _p === void 0 ? void 0 : _p.enabled) &&
4018
+ window.whop &&
4019
+ (eventName === 'pageview' || eventName === 'page_view')) {
4020
+ try {
4021
+ window.whop.track('page');
4022
+ }
4023
+ catch (error) {
4024
+ this.log('Error tracking Whop Pixel page:', error);
4025
+ }
4026
+ }
3729
4027
  }
3730
4028
  /**
3731
4029
  * Manually trigger a custom script
@@ -4373,6 +4671,256 @@ var Datalyr = (function (exports) {
4373
4671
  // someone else (and feeds that person's email to Meta advanced matching).
4374
4672
  AutoIdentifyManager.THIRD_PARTY_EMAIL_FIELD = /(recipient|friend|gift|refer|invite|colleague|coworker|share|to[-_]?email|email[-_]?to|second(ary)?[-_]?email)/i;
4375
4673
 
4674
+ /**
4675
+ * Stripe Checkout Session capture.
4676
+ *
4677
+ * WHY THIS EXISTS
4678
+ * syncStripePaymentLinks stamps `client_reference_id` onto buy.stripe.com
4679
+ * anchors, and deliberately never onto checkout.stripe.com — a Checkout Session
4680
+ * URL ignores the param because the session already exists server-side. That
4681
+ * leaves the single most common SaaS pattern uncovered: the merchant's backend
4682
+ * creates a Session and the browser is sent to checkout.stripe.com/c/pay/cs_...
4683
+ * The payment then reaches our webhook with no visitor at all.
4684
+ *
4685
+ * Measured 2026-07-25, 30d: of payers arriving via Stripe webhooks, 0.5% (one
4686
+ * tenant, 128/28,187) and 13.3% (another, 13/98) had ANY web pageview on the
4687
+ * same visitor. Not a misconfiguration — no tenant is wired, because being
4688
+ * wired currently requires the merchant to hand-write client_reference_id.
4689
+ *
4690
+ * WHAT THIS DOES
4691
+ * The session id must reach the browser for the redirect to happen at all, so
4692
+ * we observe it rather than trying to inject anything:
4693
+ * 1. fetch/XHR response bodies — `{url}` or `{sessionId}` from the
4694
+ * merchant's own create-session endpoint
4695
+ * 2. anchor clicks + window.open — direct links to checkout.stripe.com
4696
+ * A server-side 302 straight to Stripe never exposes the id to JS and is NOT
4697
+ * covered here; that case falls back to the email bridge.
4698
+ *
4699
+ * WHY THIS IS SAFE TO DEFAULT ON, WHEN autoIdentifyAPI IS NOT
4700
+ * autoIdentifyAPI scans the same traffic for EMAILS, which is why it defaults
4701
+ * off: an admin viewing a customer record gets identified as that customer, and
4702
+ * a wrong email propagates into Meta advanced matching. A `cs_live_...` token is
4703
+ * unambiguous, belongs to exactly one checkout, and is not PII. There is no
4704
+ * mis-identification failure mode to guard against — only the wrapper itself,
4705
+ * which is why every hook below is transparent and failure-isolated.
4706
+ */
4707
+ /** Stripe Checkout Session ids: cs_live_… / cs_test_… */
4708
+ const SESSION_ID_RE = /cs_(?:live|test)_[A-Za-z0-9]{8,}/;
4709
+ /** Hosts whose URLs carry a session id in the path. Exact match, never substring. */
4710
+ const CHECKOUT_HOSTS = new Set(['checkout.stripe.com']);
4711
+ /**
4712
+ * Response bodies are scanned in full, so cap what we're willing to read. A
4713
+ * create-session response is a few hundred bytes; anything large is not it, and
4714
+ * reading it would cost the merchant's page real memory and main-thread time.
4715
+ */
4716
+ const MAX_SCAN_BYTES = 65536;
4717
+ /** Bodies worth scanning. Stripe ids arrive as JSON or text, never as binary. */
4718
+ const SCANNABLE_TYPE_RE = /(json|text|javascript)/i;
4719
+ function extractSessionId(text) {
4720
+ if (!text)
4721
+ return null;
4722
+ const m = SESSION_ID_RE.exec(text);
4723
+ return m ? m[0] : null;
4724
+ }
4725
+ /**
4726
+ * True when `href` points at a Stripe-hosted checkout. Uses the URL API and
4727
+ * exact host equality — `a[href*="checkout.stripe.com"]` would match
4728
+ * `evil.com/checkout.stripe.com/x` and `checkout.stripe.com.evil.com`.
4729
+ */
4730
+ function isCheckoutUrl(href, base) {
4731
+ try {
4732
+ const host = new URL(href, base !== null && base !== void 0 ? base : (typeof window !== 'undefined' ? window.location.href : undefined)).hostname.toLowerCase();
4733
+ return CHECKOUT_HOSTS.has(host);
4734
+ }
4735
+ catch (_a) {
4736
+ return false;
4737
+ }
4738
+ }
4739
+ class StripeSessionWatcher {
4740
+ constructor(options = {}) {
4741
+ var _a, _b;
4742
+ this.seen = new Set();
4743
+ this.disposers = [];
4744
+ this.started = false;
4745
+ this.maxSessions = (_a = options.maxSessions) !== null && _a !== void 0 ? _a : 5;
4746
+ this.maxScanBytes = (_b = options.maxScanBytes) !== null && _b !== void 0 ? _b : MAX_SCAN_BYTES;
4747
+ }
4748
+ start(sink) {
4749
+ if (this.started || typeof window === 'undefined')
4750
+ return;
4751
+ this.started = true;
4752
+ this.sink = sink;
4753
+ this.hookFetch();
4754
+ this.hookXhr();
4755
+ this.hookClicks();
4756
+ this.hookWindowOpen();
4757
+ }
4758
+ stop() {
4759
+ // Restore in reverse so a later wrapper never re-installs an earlier one.
4760
+ for (const dispose of this.disposers.reverse()) {
4761
+ try {
4762
+ dispose();
4763
+ }
4764
+ catch (_a) {
4765
+ /* idempotent */
4766
+ }
4767
+ }
4768
+ this.disposers = [];
4769
+ this.started = false;
4770
+ this.sink = undefined;
4771
+ }
4772
+ /** Report a session id at most once, and never more than maxSessions per page. */
4773
+ emit(sessionId) {
4774
+ var _a;
4775
+ if (!sessionId || this.seen.has(sessionId))
4776
+ return;
4777
+ if (this.seen.size >= this.maxSessions)
4778
+ return;
4779
+ this.seen.add(sessionId);
4780
+ try {
4781
+ (_a = this.sink) === null || _a === void 0 ? void 0 : _a.call(this, sessionId);
4782
+ }
4783
+ catch (_b) {
4784
+ // A throwing sink must never surface inside the merchant's fetch chain.
4785
+ }
4786
+ }
4787
+ /**
4788
+ * Scan a Response WITHOUT disturbing the merchant's own read. `clone()` must
4789
+ * happen synchronously, before the caller can consume the body; the read of
4790
+ * the clone is deliberately not awaited by the caller.
4791
+ */
4792
+ scanResponse(response) {
4793
+ var _a, _b, _c, _d, _e, _f;
4794
+ try {
4795
+ 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 : '';
4796
+ if (type && !SCANNABLE_TYPE_RE.test(type))
4797
+ return;
4798
+ 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);
4799
+ if (Number.isFinite(declared) && declared > this.maxScanBytes)
4800
+ return;
4801
+ const clone = typeof response.clone === 'function' ? response.clone() : null;
4802
+ if (!clone)
4803
+ return;
4804
+ void clone
4805
+ .text()
4806
+ .then((body) => this.emit(extractSessionId(body.slice(0, this.maxScanBytes))))
4807
+ .catch(() => {
4808
+ /* body already disturbed / opaque response — nothing to do */
4809
+ });
4810
+ }
4811
+ catch (_g) {
4812
+ /* never let observation break the request */
4813
+ }
4814
+ }
4815
+ hookFetch() {
4816
+ if (typeof window.fetch !== 'function')
4817
+ return;
4818
+ const original = window.fetch;
4819
+ const self = this;
4820
+ const patched = function patchedFetch(...args) {
4821
+ const result = original.apply(this !== null && this !== void 0 ? this : window, args);
4822
+ try {
4823
+ // Attach passively: the merchant still gets the original promise, and a
4824
+ // rejection here is theirs to handle, not ours to observe twice.
4825
+ result.then((response) => self.scanResponse(response)).catch(() => { });
4826
+ }
4827
+ catch (_a) {
4828
+ /* ignore */
4829
+ }
4830
+ return result;
4831
+ };
4832
+ window.fetch = patched;
4833
+ this.disposers.push(() => {
4834
+ // Only restore if nobody wrapped us afterwards — clobbering a later
4835
+ // wrapper would silently disable whatever installed it.
4836
+ if (window.fetch === patched)
4837
+ window.fetch = original;
4838
+ });
4839
+ }
4840
+ hookXhr() {
4841
+ if (typeof XMLHttpRequest === 'undefined')
4842
+ return;
4843
+ const proto = XMLHttpRequest.prototype;
4844
+ const originalSend = proto.send;
4845
+ if (typeof originalSend !== 'function')
4846
+ return;
4847
+ const self = this;
4848
+ const patched = function patchedSend(...args) {
4849
+ try {
4850
+ this.addEventListener('load', () => {
4851
+ try {
4852
+ // responseText throws for blob/arraybuffer response types.
4853
+ if (this.responseType !== '' && this.responseType !== 'text')
4854
+ return;
4855
+ const body = this.responseText;
4856
+ if (!body || body.length > self.maxScanBytes)
4857
+ return;
4858
+ self.emit(extractSessionId(body));
4859
+ }
4860
+ catch (_a) {
4861
+ /* ignore */
4862
+ }
4863
+ });
4864
+ }
4865
+ catch (_a) {
4866
+ /* ignore */
4867
+ }
4868
+ return originalSend.apply(this, args);
4869
+ };
4870
+ proto.send = patched;
4871
+ this.disposers.push(() => {
4872
+ if (proto.send === patched)
4873
+ proto.send = originalSend;
4874
+ });
4875
+ }
4876
+ /**
4877
+ * Direct links to checkout.stripe.com. Capture phase so we still see the click
4878
+ * when the merchant's own handler calls stopPropagation().
4879
+ */
4880
+ hookClicks() {
4881
+ if (typeof document === 'undefined')
4882
+ return;
4883
+ const onClick = (e) => {
4884
+ var _a, _b;
4885
+ try {
4886
+ 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]');
4887
+ if (!target || !target.href || !isCheckoutUrl(target.href))
4888
+ return;
4889
+ this.emit(extractSessionId(target.href));
4890
+ }
4891
+ catch (_c) {
4892
+ /* never block navigation */
4893
+ }
4894
+ };
4895
+ document.addEventListener('click', onClick, true);
4896
+ this.disposers.push(() => document.removeEventListener('click', onClick, true));
4897
+ }
4898
+ hookWindowOpen() {
4899
+ if (typeof window.open !== 'function')
4900
+ return;
4901
+ const original = window.open;
4902
+ const self = this;
4903
+ const patched = function patchedOpen(...args) {
4904
+ var _a;
4905
+ try {
4906
+ const url = args[0];
4907
+ 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);
4908
+ if (href && isCheckoutUrl(href))
4909
+ self.emit(extractSessionId(href));
4910
+ }
4911
+ catch (_b) {
4912
+ /* ignore */
4913
+ }
4914
+ return original.apply(this !== null && this !== void 0 ? this : window, args);
4915
+ };
4916
+ window.open = patched;
4917
+ this.disposers.push(() => {
4918
+ if (window.open === patched)
4919
+ window.open = original;
4920
+ });
4921
+ }
4922
+ }
4923
+
4376
4924
  /** Keys the remote config is allowed to fill on DatalyrConfig. */
4377
4925
  const REMOTE_KEYS = [
4378
4926
  'autoIdentify',
@@ -4380,6 +4928,7 @@ var Datalyr = (function (exports) {
4380
4928
  'autoIdentifyAPI',
4381
4929
  'autoIdentifyShopify',
4382
4930
  'shopifyCartAttributes',
4931
+ 'stripeCheckoutSessions',
4383
4932
  'checkoutChampDomains',
4384
4933
  'respectGlobalPrivacyControl',
4385
4934
  'respectDoNotTrack',
@@ -4418,6 +4967,16 @@ var Datalyr = (function (exports) {
4418
4967
  * Datalyr Web SDK
4419
4968
  * Modern attribution tracking for web applications
4420
4969
  */
4970
+ /**
4971
+ * WEB-20. Fingerprint of the last identify() that actually emitted, so a host
4972
+ * app calling identify() on every route change does not re-emit an unchanged
4973
+ * identity. Cleared by reset(). Mirrors iOS `datalyr_last_identity_fingerprint`
4974
+ * and RN `@datalyr/last_identity_fingerprint`.
4975
+ *
4976
+ * Not PII: the stored value is a hash, and it is written through the same
4977
+ * `storage` wrapper as every other key (so it inherits the `dl_` prefix).
4978
+ */
4979
+ const IDENTIFY_FINGERPRINT_KEY = 'dl_identify_fingerprint';
4421
4980
  class Datalyr {
4422
4981
  constructor() {
4423
4982
  // Keys the caller passed to init() (before built-in defaults were merged).
@@ -4433,10 +4992,26 @@ var Datalyr = (function (exports) {
4433
4992
  this.errors = [];
4434
4993
  this.MAX_ERRORS = 50;
4435
4994
  this.lastSpaPath = null; // dedups SPA pageviews (replaceState-on-mount double-fire)
4995
+ // Shopify loads Customer Privacy asynchronously. Keep the initial pageview
4996
+ // pending until initialization is complete and analytics consent is known,
4997
+ // then release it exactly once when consent allows tracking.
4998
+ this.initialPageViewReady = false;
4999
+ this.initialPageViewSent = false;
4436
5000
  // FIXED (ISSUE-01): Async initialization promise to prevent race conditions
4437
5001
  this.initializationPromise = null;
4438
5002
  // Opt-out check moved to init() after cookies configured (Issue #14)
4439
5003
  }
5004
+ /**
5005
+ * Return the workspace configured for this instance, or null before init().
5006
+ *
5007
+ * CDN bootstrap uses this public accessor instead of reaching into the
5008
+ * private runtime config when deciding whether an existing global belongs
5009
+ * to the requested workspace.
5010
+ */
5011
+ getWorkspaceId() {
5012
+ var _a, _b;
5013
+ return (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.workspaceId) !== null && _b !== void 0 ? _b : null;
5014
+ }
4440
5015
  /**
4441
5016
  * Initialize the SDK
4442
5017
  */
@@ -4580,6 +5155,11 @@ var Datalyr = (function (exports) {
4580
5155
  const deviceId = this.identity.getAnonymousId();
4581
5156
  yield dataEncryption.initialize(this.config.workspaceId, deviceId);
4582
5157
  this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
5158
+ // WEB-22: restore a PII user id that was persisted encrypted. Must run
5159
+ // here — it needs dataEncryption to be initialized, so it cannot happen
5160
+ // in the IdentityManager constructor. It never overwrites an id already
5161
+ // set by an explicit identify() earlier in this page load.
5162
+ yield this.identity.hydrateEncryptedUserId();
4583
5163
  this.log('Encryption initialized, user properties loaded');
4584
5164
  }
4585
5165
  catch (encErr) {
@@ -4669,14 +5249,20 @@ var Datalyr = (function (exports) {
4669
5249
  // Setup auto-identify callback
4670
5250
  this.autoIdentify.initialize((email, source) => {
4671
5251
  this.log(`Auto-identified user: ${email} from ${source}`);
4672
- // Track auto-identify event
4673
- this.track('$auto_identify', {
4674
- email,
4675
- source,
4676
- timestamp: Date.now()
4677
- });
4678
- // Automatically call identify with email
4679
- this.identify(email, { email });
5252
+ // WEB-21: ONE event, not two.
5253
+ //
5254
+ // This used to emit `$auto_identify` and then call identify(),
5255
+ // which emits `$identify` — two events ~1ms apart carrying the same
5256
+ // email. Production over 7 days on workspace f6260736 showed a
5257
+ // perfect 379 / 379 / 379 split ($identify / $auto_identify /
5258
+ // visitors): every auto-identification was booked twice.
5259
+ //
5260
+ // Nothing consumed `$auto_identify`: grepped across the whole
5261
+ // platform (app/, lib/, all Cloudflare workers, all Tinybird pipes)
5262
+ // — zero readers, while prod carried 6,197 of them in 30 days.
5263
+ // The only information it added over `$identify` was WHICH detector
5264
+ // found the email, so that is preserved as a trait instead.
5265
+ this.identify(email, { email, auto_identify_source: source });
4680
5266
  });
4681
5267
  }
4682
5268
  // Stamp attribution signals into the Shopify cart (OPT-IN, default off).
@@ -4707,10 +5293,22 @@ var Datalyr = (function (exports) {
4707
5293
  if (this.config.stripePaymentLinks !== false && this.shouldTrack()) {
4708
5294
  this.syncStripePaymentLinks((_b = this.config.stripeLinkDomains) !== null && _b !== void 0 ? _b : []);
4709
5295
  }
4710
- // Track initial page view if enabled (AFTER encryption ready)
4711
- if (this.config.trackPageViews) {
4712
- this.page();
5296
+ // Checkout Session capture (DEFAULT ON). The decorator above cannot help
5297
+ // when the merchant's BACKEND creates the session — checkout.stripe.com
5298
+ // ignores client_reference_id post-creation — which is how most SaaS
5299
+ // checkouts work, and why payers arrive at our webhook with no visitor
5300
+ // (measured 0.5% and 13.3% visitor coverage on the two Stripe tenants).
5301
+ // The session id still has to reach the browser, so we observe it and
5302
+ // let the server join on session.id. Same shouldTrack() gate as the
5303
+ // decorator: an opted-out visitor's id is never paired with a checkout.
5304
+ if (this.config.stripeCheckoutSessions !== false && this.shouldTrack()) {
5305
+ this.startStripeSessionCapture();
4713
5306
  }
5307
+ // Track the initial page view after encryption is ready. On Shopify the
5308
+ // Customer Privacy API can still be loading, so retain one pending
5309
+ // pageview and release it from onShopifyConsentChanged() once allowed.
5310
+ this.initialPageViewReady = true;
5311
+ this.trackInitialPageViewOnce();
4714
5312
  this.log('Async initialization complete');
4715
5313
  }
4716
5314
  catch (error) {
@@ -4843,6 +5441,50 @@ var Datalyr = (function (exports) {
4843
5441
  /**
4844
5442
  * Identify a user
4845
5443
  */
5444
+ /**
5445
+ * Stable fingerprint of an identity for redundant-emit suppression (WEB-20).
5446
+ *
5447
+ * Keys are sorted and each value is JSON-serialized, so `{a,b}` and `{b,a}`
5448
+ * collapse to one identity while a genuinely changed nested trait does not.
5449
+ * (The mobile SDKs use `String(value)`, which flattens every object to
5450
+ * `[object Object]` and can swallow a real change; web traits are plain JSON,
5451
+ * so it can afford to be exact.)
5452
+ *
5453
+ * Hashed rather than stored raw so the key does not become another copy of the
5454
+ * user's traits at rest. Being honest about the strength: a 32-bit
5455
+ * non-cryptographic digest sitting beside a readable `dl_dl_anonymous_id` is
5456
+ * NOT protection against a determined reader — it defeats casual inspection
5457
+ * and bulk scraping, nothing more. The PII that matters is handled properly
5458
+ * (encrypted `dl_user_id_pii`, encrypted `dl_user_traits`). 32 bits also means
5459
+ * a ~1-in-4.3e9 chance per identity that a genuine change is suppressed;
5460
+ * acceptable for change detection, which is all this is.
5461
+ */
5462
+ identityFingerprint(userId, traits = {}) {
5463
+ const stableTraits = Object.keys(traits || {})
5464
+ .sort()
5465
+ .map((key) => {
5466
+ let value;
5467
+ try {
5468
+ value = JSON.stringify(traits[key]);
5469
+ }
5470
+ catch (_a) {
5471
+ // Circular/unserializable trait — fall back to a coarse marker rather
5472
+ // than throwing inside identify().
5473
+ value = '[unserializable]';
5474
+ }
5475
+ return `${key}=${value}`;
5476
+ })
5477
+ .join('&');
5478
+ const input = `${this.identity.getAnonymousId()}|${userId}|${stableTraits}`;
5479
+ // 32-bit FNV-1a. Not cryptographic — this only needs to detect change, and
5480
+ // it must be synchronous (crypto.subtle is async and identify() is not).
5481
+ let hash = 0x811c9dc5;
5482
+ for (let i = 0; i < input.length; i++) {
5483
+ hash ^= input.charCodeAt(i);
5484
+ hash = Math.imul(hash, 0x01000193);
5485
+ }
5486
+ return (hash >>> 0).toString(16);
5487
+ }
4846
5488
  identify(userId, traits = {}) {
4847
5489
  if (!this.initialized) {
4848
5490
  console.warn('[Datalyr] SDK not initialized. Call init() first.');
@@ -4882,8 +5524,31 @@ var Datalyr = (function (exports) {
4882
5524
  // If encryption fails, user traits are only stored in memory (this.userProperties)
4883
5525
  // and will be lost on page reload - but PII is NOT exposed in localStorage
4884
5526
  });
4885
- // Track $identify event
4886
- this.track('$identify', Object.assign(Object.assign({}, identityLink), { traits }));
5527
+ // WEB-20: redundant-identify suppression, matching iOS 2.1.11 / RN 1.7.16.
5528
+ //
5529
+ // identify() is host-app-driven and SPA routers commonly call it in a
5530
+ // route effect, so an unchanged identity was re-emitted on every
5531
+ // navigation. The mobile SDKs got this in 2.1.10/1.7.15; web did not.
5532
+ //
5533
+ // Fingerprint = anonymousId | userId | sorted traits. The anonymous id is
5534
+ // included so a reset()/rotation always re-emits and identity links are
5535
+ // never lost — only exact repeats are skipped.
5536
+ //
5537
+ // Only the EVENT is suppressed. `this.identity.identify()` above has
5538
+ // already persisted `dl_user_id`, traits are still merged and encrypted,
5539
+ // and the plugin handlers below still run on every call — a plugin's
5540
+ // `identify` hook is a customer extension point whose semantics we cannot
5541
+ // assume, so its contract ("called when identify() is called") is
5542
+ // deliberately left intact.
5543
+ const identityFingerprint = this.identityFingerprint(userId, traits);
5544
+ const isRedundantIdentify = storage.getString(IDENTIFY_FINGERPRINT_KEY, null) === identityFingerprint;
5545
+ if (isRedundantIdentify) {
5546
+ this.log('Skipping redundant identify (unchanged identity):', userId);
5547
+ }
5548
+ else {
5549
+ storage.set(IDENTIFY_FINGERPRINT_KEY, identityFingerprint);
5550
+ this.track('$identify', Object.assign(Object.assign({}, identityLink), { traits }));
5551
+ }
4887
5552
  // Call plugin handlers
4888
5553
  if (this.config.plugins) {
4889
5554
  for (const plugin of this.config.plugins) {
@@ -4903,6 +5568,18 @@ var Datalyr = (function (exports) {
4903
5568
  this.trackError(error, { userId });
4904
5569
  }
4905
5570
  }
5571
+ /**
5572
+ * WEB-27: queue health, including events the SDK gave up on.
5573
+ *
5574
+ * `getStats()` existed on EventQueue but was unreachable — `queue` is private
5575
+ * and nothing exposed it, so the observability fix could not actually be
5576
+ * observed. A non-zero `droppedEvents` means data was lost.
5577
+ */
5578
+ getQueueStats() {
5579
+ if (!this.queue)
5580
+ return null;
5581
+ return this.queue.getStats();
5582
+ }
4906
5583
  /**
4907
5584
  * Track a page view
4908
5585
  */
@@ -5020,6 +5697,9 @@ var Datalyr = (function (exports) {
5020
5697
  // values to the next user's events (cross-user contamination on shared devices).
5021
5698
  this.superProperties = {};
5022
5699
  storage.remove('dl_user_traits');
5700
+ // WEB-20: drop the identify fingerprint, so a logout→login (or a different
5701
+ // user on this browser) always re-emits $identify and the link is rebuilt.
5702
+ storage.remove(IDENTIFY_FINGERPRINT_KEY);
5023
5703
  // Clear the auto-identify guard so a different user on the same browser is
5024
5704
  // re-captured (it short-circuits while dl_auto_identified_email is present), and so
5025
5705
  // the prior user's email isn't left at rest after logout.
@@ -5167,16 +5847,21 @@ var Datalyr = (function (exports) {
5167
5847
  * (same convention as pixels/auto-identify).
5168
5848
  */
5169
5849
  disposeMarketingLinkDecorators() {
5170
- var _a, _b;
5850
+ var _a, _b, _c;
5171
5851
  try {
5172
5852
  (_a = this.stripeLinksDisposer) === null || _a === void 0 ? void 0 : _a.call(this);
5173
5853
  }
5174
- catch ( /* best-effort */_c) { /* best-effort */ }
5854
+ catch ( /* best-effort */_d) { /* best-effort */ }
5175
5855
  this.stripeLinksDisposer = undefined;
5176
5856
  try {
5177
- (_b = this.outboundDisposer) === null || _b === void 0 ? void 0 : _b.call(this);
5857
+ (_b = this.stripeSessionWatcher) === null || _b === void 0 ? void 0 : _b.stop();
5178
5858
  }
5179
- catch ( /* best-effort */_d) { /* best-effort */ }
5859
+ catch ( /* best-effort */_e) { /* best-effort */ }
5860
+ this.stripeSessionWatcher = undefined;
5861
+ try {
5862
+ (_c = this.outboundDisposer) === null || _c === void 0 ? void 0 : _c.call(this);
5863
+ }
5864
+ catch ( /* best-effort */_f) { /* best-effort */ }
5180
5865
  this.outboundDisposer = undefined;
5181
5866
  }
5182
5867
  optOut() {
@@ -5205,8 +5890,22 @@ var Datalyr = (function (exports) {
5205
5890
  // Purge PII at rest.
5206
5891
  this.userProperties = {};
5207
5892
  storage.remove('dl_user_traits');
5893
+ // WEB-26: remove the PLAINTEXT id as well. optOut() purged only the
5894
+ // encrypted copy, so an opted-out visitor whose email was written by an
5895
+ // older build kept it at rest — the one state where that is least
5896
+ // defensible.
5897
+ storage.remove('dl_user_id');
5898
+ storage.remove('dl_user_id_pii');
5899
+ storage.remove(IDENTIFY_FINGERPRINT_KEY);
5208
5900
  storage.remove('dl_auto_identified_email');
5209
5901
  storage.remove('dl_journey');
5902
+ // WEB-25: first/last touch hold redacted-but-full landing URLs, referrers and
5903
+ // click ids for up to 90 days. reset() has always cleared them; optOut() did
5904
+ // not — so an opted-out visitor kept marketing attribution at rest, which is
5905
+ // the one state where it is least defensible. Same rationale as dl_journey
5906
+ // directly above.
5907
+ storage.remove('dl_first_touch');
5908
+ storage.remove('dl_last_touch');
5210
5909
  this.log('User opted out');
5211
5910
  }
5212
5911
  /**
@@ -5269,8 +5968,16 @@ var Datalyr = (function (exports) {
5269
5968
  this.autoIdentify = undefined;
5270
5969
  this.userProperties = {};
5271
5970
  storage.remove('dl_user_traits');
5971
+ // WEB-26: see optOut() — the plaintext id must go too.
5972
+ storage.remove('dl_user_id');
5973
+ storage.remove('dl_user_id_pii');
5974
+ storage.remove(IDENTIFY_FINGERPRINT_KEY);
5272
5975
  storage.remove('dl_auto_identified_email');
5273
5976
  storage.remove('dl_journey');
5977
+ // WEB-25: see optOut() — withdrawn analytics consent must not leave 90-day
5978
+ // marketing attribution at rest either.
5979
+ storage.remove('dl_first_touch');
5980
+ storage.remove('dl_last_touch');
5274
5981
  }
5275
5982
  else {
5276
5983
  // TR-15 (P3): grant → persist the in-memory anon id now (see onShopifyConsentChanged).
@@ -5524,6 +6231,39 @@ var Datalyr = (function (exports) {
5524
6231
  * changes. window.open(paymentLink) is not covered (use getVisitorId()
5525
6232
  * manually); iframe/shadow-DOM links are unreachable from document.
5526
6233
  */
6234
+ /**
6235
+ * Observe Stripe Checkout Session ids and report each one once.
6236
+ *
6237
+ * The event is what carries the pairing: the server stores
6238
+ * (stripe_session_id -> visitor_id) and, when checkout.session.completed
6239
+ * arrives without a client_reference_id, joins on session.id and feeds the
6240
+ * SAME cacheCustomerVisitor path the Payment Link flow already uses — so
6241
+ * every later invoice for that customer inherits the visitor too.
6242
+ *
6243
+ * Consent is re-checked at emit time, not just at init: a visitor can
6244
+ * withdraw between page load and checkout, and this pairing is exactly the
6245
+ * kind of identity link that must stop when they do.
6246
+ */
6247
+ startStripeSessionCapture() {
6248
+ if (this.stripeSessionWatcher)
6249
+ return;
6250
+ const watcher = new StripeSessionWatcher();
6251
+ this.stripeSessionWatcher = watcher;
6252
+ watcher.start((stripeSessionId) => {
6253
+ var _a;
6254
+ if (!this.shouldTrack())
6255
+ return;
6256
+ this.track('$stripe_checkout_session', { stripe_session_id: stripeSessionId });
6257
+ // The very next thing this page does is navigate to Stripe, so flush now
6258
+ // rather than let the batch timer lose the pairing to the unload.
6259
+ try {
6260
+ (_a = this.queue) === null || _a === void 0 ? void 0 : _a.flush();
6261
+ }
6262
+ catch (_b) {
6263
+ /* best-effort — never block the checkout */
6264
+ }
6265
+ });
6266
+ }
5527
6267
  syncStripePaymentLinks(extraDomains) {
5528
6268
  if (typeof window === "undefined" || typeof document === "undefined")
5529
6269
  return;
@@ -5750,7 +6490,7 @@ var Datalyr = (function (exports) {
5750
6490
  // drift from the package again (it sat at 1.7.4 across the 1.7.5 release). The
5751
6491
  // build guard (scripts/check-bundle.js, run by build:check) still verifies the
5752
6492
  // deployable bundles carry the package.json version. (FSR-103)
5753
- sdk_version: '1.7.6',
6493
+ sdk_version: '1.7.9',
5754
6494
  sdk_name: 'datalyr-web-sdk',
5755
6495
  // A3-25: versioned-envelope stamp. Every SDK emits schema_version so the ingest/contract
5756
6496
  // layer can key on ONE canonical envelope version (snake_case fields, event_name/event_data
@@ -5943,6 +6683,8 @@ var Datalyr = (function (exports) {
5943
6683
  // visitor_id that vanishes on the next page load. Idempotent.
5944
6684
  if (allowed)
5945
6685
  this.identity.enablePersistence();
6686
+ if (allowed)
6687
+ this.trackInitialPageViewOnce();
5946
6688
  if (!allowed) {
5947
6689
  // Mirror setConsent() withdrawal: purge buffered events so events captured
5948
6690
  // before the decline can't drain if consent is later re-granted.
@@ -5974,6 +6716,15 @@ var Datalyr = (function (exports) {
5974
6716
  }
5975
6717
  this.log('Shopify consent collected — analytics allowed:', allowed, '— marketing blocked:', marketingBlocked);
5976
6718
  }
6719
+ /** Release the automatic landing pageview once, after init and consent. */
6720
+ trackInitialPageViewOnce() {
6721
+ if (!this.initialPageViewReady || this.initialPageViewSent)
6722
+ return;
6723
+ if (!this.config.trackPageViews || !this.initialized || !this.shouldTrack())
6724
+ return;
6725
+ this.initialPageViewSent = true;
6726
+ this.page();
6727
+ }
5977
6728
  /**
5978
6729
  * Setup SPA tracking
5979
6730
  * Fixed Issue #15: Store original methods for cleanup
@@ -6168,6 +6919,10 @@ var Datalyr = (function (exports) {
6168
6919
  this.stripeLinksDisposer();
6169
6920
  this.stripeLinksDisposer = undefined;
6170
6921
  }
6922
+ if (this.stripeSessionWatcher) {
6923
+ this.stripeSessionWatcher.stop();
6924
+ this.stripeSessionWatcher = undefined;
6925
+ }
6171
6926
  // Clean up queue
6172
6927
  if (this.queue) {
6173
6928
  this.queue.destroy();
@@ -6193,6 +6948,8 @@ var Datalyr = (function (exports) {
6193
6948
  this.container = undefined;
6194
6949
  this.autoIdentify = undefined;
6195
6950
  this.lastSpaPath = null;
6951
+ this.initialPageViewReady = false;
6952
+ this.initialPageViewSent = false;
6196
6953
  // Clear any remaining data
6197
6954
  this.superProperties = {};
6198
6955
  this.userProperties = {};
@@ -6201,19 +6958,27 @@ var Datalyr = (function (exports) {
6201
6958
  this.log('SDK destroyed');
6202
6959
  }
6203
6960
  }
6204
- // Create singleton instance. TR-23: reuse an existing window.datalyr (a prior tag's instance —
6205
- // Shopify App Embed + a manual snippet coexisting, or a double-included bundle) instead of
6206
- // constructing a SECOND one. Two instances each patch history.pushState and fire their own
6207
- // pageviews (distinct event_ids no server-side dedup). A plain truthy check (NOT instanceof):
6208
- // two <script> tags run two separate IIFEs whose Datalyr classes are structurally identical but
6209
- // distinct objects, so instanceof would fail across them. With one shared instance, the second
6210
- // tag's bootstrap init() hits the instance-level `initialized` guard and no-ops.
6211
- const datalyr = (typeof window !== 'undefined' && window.datalyr) || new Datalyr();
6961
+ /**
6962
+ * Create an independent SDK instance without reading or mutating window.
6963
+ *
6964
+ * The CDN bootstrap uses this when an existing global is configured for a
6965
+ * different workspace. Package consumers can also opt into multiple explicit
6966
+ * instances without changing the default singleton contract below.
6967
+ */
6968
+ function createDatalyrInstance() {
6969
+ return new Datalyr();
6970
+ }
6971
+ // Create the default singleton. TR-23: reuse an existing window.datalyr (a
6972
+ // prior tag's instance or a package singleton) so npm/ESM/CJS consumers retain
6973
+ // the established one-global behavior. Workspace conflict resolution belongs
6974
+ // to the CDN bootstrap, which has the authoritative script-tag configuration.
6975
+ const datalyr = (typeof window !== 'undefined' && window.datalyr) || createDatalyrInstance();
6212
6976
  // Expose global API
6213
6977
  if (typeof window !== 'undefined') {
6214
6978
  window.datalyr = datalyr;
6215
6979
  }
6216
6980
 
6981
+ exports.createDatalyrInstance = createDatalyrInstance;
6217
6982
  exports.datalyr = datalyr;
6218
6983
  exports.default = datalyr;
6219
6984