@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.
- package/README.md +542 -476
- package/dist/attribution-lyr.test.d.ts +2 -0
- package/dist/attribution-lyr.test.d.ts.map +1 -0
- package/dist/attribution-oppref.test.d.ts +2 -0
- package/dist/attribution-oppref.test.d.ts.map +1 -0
- package/dist/attribution.d.ts.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/datalyr.cjs.js +751 -46
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +751 -47
- package/dist/datalyr.esm.js.map +1 -1
- package/dist/datalyr.esm.min.js +1 -1
- package/dist/datalyr.esm.min.js.map +1 -1
- package/dist/datalyr.js +751 -46
- package/dist/datalyr.js.map +1 -1
- package/dist/datalyr.min.js +1 -1
- package/dist/datalyr.min.js.map +1 -1
- package/dist/identify-dedupe.test.d.ts +2 -0
- package/dist/identify-dedupe.test.d.ts.map +1 -0
- package/dist/identity-pii.test.d.ts +2 -0
- package/dist/identity-pii.test.d.ts.map +1 -0
- package/dist/identity.d.ts +46 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.test.d.ts +3 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/queue-403.test.d.ts +2 -0
- package/dist/queue-403.test.d.ts.map +1 -0
- package/dist/queue.d.ts +17 -0
- package/dist/queue.d.ts.map +1 -1
- package/dist/storage-migration.test.d.ts +2 -0
- package/dist/storage-migration.test.d.ts.map +1 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/stripe-session.d.ts +75 -0
- package/dist/stripe-session.d.ts.map +1 -0
- package/dist/stripe-session.test.d.ts +2 -0
- package/dist/stripe-session.test.d.ts.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/datalyr.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @datalyr/web v1.7.
|
|
2
|
+
* @datalyr/web v1.7.8
|
|
3
3
|
* Datalyr Web SDK - Modern attribution tracking for web applications
|
|
4
4
|
* (c) 2026 Datalyr Inc.
|
|
5
5
|
* Released under the MIT License
|
|
@@ -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
|
-
//
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
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
|
|
2185
|
-
//
|
|
2186
|
-
// queue forever and hammers ingest. Tagged so the send paths DROP the batch
|
|
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.
|
|
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
|
-
//
|
|
2498
|
-
//
|
|
2499
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
*/
|
|
@@ -4373,6 +4611,256 @@ var Datalyr = (function (exports) {
|
|
|
4373
4611
|
// someone else (and feeds that person's email to Meta advanced matching).
|
|
4374
4612
|
AutoIdentifyManager.THIRD_PARTY_EMAIL_FIELD = /(recipient|friend|gift|refer|invite|colleague|coworker|share|to[-_]?email|email[-_]?to|second(ary)?[-_]?email)/i;
|
|
4375
4613
|
|
|
4614
|
+
/**
|
|
4615
|
+
* Stripe Checkout Session capture.
|
|
4616
|
+
*
|
|
4617
|
+
* WHY THIS EXISTS
|
|
4618
|
+
* syncStripePaymentLinks stamps `client_reference_id` onto buy.stripe.com
|
|
4619
|
+
* anchors, and deliberately never onto checkout.stripe.com — a Checkout Session
|
|
4620
|
+
* URL ignores the param because the session already exists server-side. That
|
|
4621
|
+
* leaves the single most common SaaS pattern uncovered: the merchant's backend
|
|
4622
|
+
* creates a Session and the browser is sent to checkout.stripe.com/c/pay/cs_...
|
|
4623
|
+
* The payment then reaches our webhook with no visitor at all.
|
|
4624
|
+
*
|
|
4625
|
+
* Measured 2026-07-25, 30d: of payers arriving via Stripe webhooks, 0.5% (one
|
|
4626
|
+
* tenant, 128/28,187) and 13.3% (another, 13/98) had ANY web pageview on the
|
|
4627
|
+
* same visitor. Not a misconfiguration — no tenant is wired, because being
|
|
4628
|
+
* wired currently requires the merchant to hand-write client_reference_id.
|
|
4629
|
+
*
|
|
4630
|
+
* WHAT THIS DOES
|
|
4631
|
+
* The session id must reach the browser for the redirect to happen at all, so
|
|
4632
|
+
* we observe it rather than trying to inject anything:
|
|
4633
|
+
* 1. fetch/XHR response bodies — `{url}` or `{sessionId}` from the
|
|
4634
|
+
* merchant's own create-session endpoint
|
|
4635
|
+
* 2. anchor clicks + window.open — direct links to checkout.stripe.com
|
|
4636
|
+
* A server-side 302 straight to Stripe never exposes the id to JS and is NOT
|
|
4637
|
+
* covered here; that case falls back to the email bridge.
|
|
4638
|
+
*
|
|
4639
|
+
* WHY THIS IS SAFE TO DEFAULT ON, WHEN autoIdentifyAPI IS NOT
|
|
4640
|
+
* autoIdentifyAPI scans the same traffic for EMAILS, which is why it defaults
|
|
4641
|
+
* off: an admin viewing a customer record gets identified as that customer, and
|
|
4642
|
+
* a wrong email propagates into Meta advanced matching. A `cs_live_...` token is
|
|
4643
|
+
* unambiguous, belongs to exactly one checkout, and is not PII. There is no
|
|
4644
|
+
* mis-identification failure mode to guard against — only the wrapper itself,
|
|
4645
|
+
* which is why every hook below is transparent and failure-isolated.
|
|
4646
|
+
*/
|
|
4647
|
+
/** Stripe Checkout Session ids: cs_live_… / cs_test_… */
|
|
4648
|
+
const SESSION_ID_RE = /cs_(?:live|test)_[A-Za-z0-9]{8,}/;
|
|
4649
|
+
/** Hosts whose URLs carry a session id in the path. Exact match, never substring. */
|
|
4650
|
+
const CHECKOUT_HOSTS = new Set(['checkout.stripe.com']);
|
|
4651
|
+
/**
|
|
4652
|
+
* Response bodies are scanned in full, so cap what we're willing to read. A
|
|
4653
|
+
* create-session response is a few hundred bytes; anything large is not it, and
|
|
4654
|
+
* reading it would cost the merchant's page real memory and main-thread time.
|
|
4655
|
+
*/
|
|
4656
|
+
const MAX_SCAN_BYTES = 65536;
|
|
4657
|
+
/** Bodies worth scanning. Stripe ids arrive as JSON or text, never as binary. */
|
|
4658
|
+
const SCANNABLE_TYPE_RE = /(json|text|javascript)/i;
|
|
4659
|
+
function extractSessionId(text) {
|
|
4660
|
+
if (!text)
|
|
4661
|
+
return null;
|
|
4662
|
+
const m = SESSION_ID_RE.exec(text);
|
|
4663
|
+
return m ? m[0] : null;
|
|
4664
|
+
}
|
|
4665
|
+
/**
|
|
4666
|
+
* True when `href` points at a Stripe-hosted checkout. Uses the URL API and
|
|
4667
|
+
* exact host equality — `a[href*="checkout.stripe.com"]` would match
|
|
4668
|
+
* `evil.com/checkout.stripe.com/x` and `checkout.stripe.com.evil.com`.
|
|
4669
|
+
*/
|
|
4670
|
+
function isCheckoutUrl(href, base) {
|
|
4671
|
+
try {
|
|
4672
|
+
const host = new URL(href, base !== null && base !== void 0 ? base : (typeof window !== 'undefined' ? window.location.href : undefined)).hostname.toLowerCase();
|
|
4673
|
+
return CHECKOUT_HOSTS.has(host);
|
|
4674
|
+
}
|
|
4675
|
+
catch (_a) {
|
|
4676
|
+
return false;
|
|
4677
|
+
}
|
|
4678
|
+
}
|
|
4679
|
+
class StripeSessionWatcher {
|
|
4680
|
+
constructor(options = {}) {
|
|
4681
|
+
var _a, _b;
|
|
4682
|
+
this.seen = new Set();
|
|
4683
|
+
this.disposers = [];
|
|
4684
|
+
this.started = false;
|
|
4685
|
+
this.maxSessions = (_a = options.maxSessions) !== null && _a !== void 0 ? _a : 5;
|
|
4686
|
+
this.maxScanBytes = (_b = options.maxScanBytes) !== null && _b !== void 0 ? _b : MAX_SCAN_BYTES;
|
|
4687
|
+
}
|
|
4688
|
+
start(sink) {
|
|
4689
|
+
if (this.started || typeof window === 'undefined')
|
|
4690
|
+
return;
|
|
4691
|
+
this.started = true;
|
|
4692
|
+
this.sink = sink;
|
|
4693
|
+
this.hookFetch();
|
|
4694
|
+
this.hookXhr();
|
|
4695
|
+
this.hookClicks();
|
|
4696
|
+
this.hookWindowOpen();
|
|
4697
|
+
}
|
|
4698
|
+
stop() {
|
|
4699
|
+
// Restore in reverse so a later wrapper never re-installs an earlier one.
|
|
4700
|
+
for (const dispose of this.disposers.reverse()) {
|
|
4701
|
+
try {
|
|
4702
|
+
dispose();
|
|
4703
|
+
}
|
|
4704
|
+
catch (_a) {
|
|
4705
|
+
/* idempotent */
|
|
4706
|
+
}
|
|
4707
|
+
}
|
|
4708
|
+
this.disposers = [];
|
|
4709
|
+
this.started = false;
|
|
4710
|
+
this.sink = undefined;
|
|
4711
|
+
}
|
|
4712
|
+
/** Report a session id at most once, and never more than maxSessions per page. */
|
|
4713
|
+
emit(sessionId) {
|
|
4714
|
+
var _a;
|
|
4715
|
+
if (!sessionId || this.seen.has(sessionId))
|
|
4716
|
+
return;
|
|
4717
|
+
if (this.seen.size >= this.maxSessions)
|
|
4718
|
+
return;
|
|
4719
|
+
this.seen.add(sessionId);
|
|
4720
|
+
try {
|
|
4721
|
+
(_a = this.sink) === null || _a === void 0 ? void 0 : _a.call(this, sessionId);
|
|
4722
|
+
}
|
|
4723
|
+
catch (_b) {
|
|
4724
|
+
// A throwing sink must never surface inside the merchant's fetch chain.
|
|
4725
|
+
}
|
|
4726
|
+
}
|
|
4727
|
+
/**
|
|
4728
|
+
* Scan a Response WITHOUT disturbing the merchant's own read. `clone()` must
|
|
4729
|
+
* happen synchronously, before the caller can consume the body; the read of
|
|
4730
|
+
* the clone is deliberately not awaited by the caller.
|
|
4731
|
+
*/
|
|
4732
|
+
scanResponse(response) {
|
|
4733
|
+
var _a, _b, _c, _d, _e, _f;
|
|
4734
|
+
try {
|
|
4735
|
+
const type = (_c = (_b = (_a = response.headers) === null || _a === void 0 ? void 0 : _a.get) === null || _b === void 0 ? void 0 : _b.call(_a, 'content-type')) !== null && _c !== void 0 ? _c : '';
|
|
4736
|
+
if (type && !SCANNABLE_TYPE_RE.test(type))
|
|
4737
|
+
return;
|
|
4738
|
+
const declared = Number((_f = (_e = (_d = response.headers) === null || _d === void 0 ? void 0 : _d.get) === null || _e === void 0 ? void 0 : _e.call(_d, 'content-length')) !== null && _f !== void 0 ? _f : NaN);
|
|
4739
|
+
if (Number.isFinite(declared) && declared > this.maxScanBytes)
|
|
4740
|
+
return;
|
|
4741
|
+
const clone = typeof response.clone === 'function' ? response.clone() : null;
|
|
4742
|
+
if (!clone)
|
|
4743
|
+
return;
|
|
4744
|
+
void clone
|
|
4745
|
+
.text()
|
|
4746
|
+
.then((body) => this.emit(extractSessionId(body.slice(0, this.maxScanBytes))))
|
|
4747
|
+
.catch(() => {
|
|
4748
|
+
/* body already disturbed / opaque response — nothing to do */
|
|
4749
|
+
});
|
|
4750
|
+
}
|
|
4751
|
+
catch (_g) {
|
|
4752
|
+
/* never let observation break the request */
|
|
4753
|
+
}
|
|
4754
|
+
}
|
|
4755
|
+
hookFetch() {
|
|
4756
|
+
if (typeof window.fetch !== 'function')
|
|
4757
|
+
return;
|
|
4758
|
+
const original = window.fetch;
|
|
4759
|
+
const self = this;
|
|
4760
|
+
const patched = function patchedFetch(...args) {
|
|
4761
|
+
const result = original.apply(this !== null && this !== void 0 ? this : window, args);
|
|
4762
|
+
try {
|
|
4763
|
+
// Attach passively: the merchant still gets the original promise, and a
|
|
4764
|
+
// rejection here is theirs to handle, not ours to observe twice.
|
|
4765
|
+
result.then((response) => self.scanResponse(response)).catch(() => { });
|
|
4766
|
+
}
|
|
4767
|
+
catch (_a) {
|
|
4768
|
+
/* ignore */
|
|
4769
|
+
}
|
|
4770
|
+
return result;
|
|
4771
|
+
};
|
|
4772
|
+
window.fetch = patched;
|
|
4773
|
+
this.disposers.push(() => {
|
|
4774
|
+
// Only restore if nobody wrapped us afterwards — clobbering a later
|
|
4775
|
+
// wrapper would silently disable whatever installed it.
|
|
4776
|
+
if (window.fetch === patched)
|
|
4777
|
+
window.fetch = original;
|
|
4778
|
+
});
|
|
4779
|
+
}
|
|
4780
|
+
hookXhr() {
|
|
4781
|
+
if (typeof XMLHttpRequest === 'undefined')
|
|
4782
|
+
return;
|
|
4783
|
+
const proto = XMLHttpRequest.prototype;
|
|
4784
|
+
const originalSend = proto.send;
|
|
4785
|
+
if (typeof originalSend !== 'function')
|
|
4786
|
+
return;
|
|
4787
|
+
const self = this;
|
|
4788
|
+
const patched = function patchedSend(...args) {
|
|
4789
|
+
try {
|
|
4790
|
+
this.addEventListener('load', () => {
|
|
4791
|
+
try {
|
|
4792
|
+
// responseText throws for blob/arraybuffer response types.
|
|
4793
|
+
if (this.responseType !== '' && this.responseType !== 'text')
|
|
4794
|
+
return;
|
|
4795
|
+
const body = this.responseText;
|
|
4796
|
+
if (!body || body.length > self.maxScanBytes)
|
|
4797
|
+
return;
|
|
4798
|
+
self.emit(extractSessionId(body));
|
|
4799
|
+
}
|
|
4800
|
+
catch (_a) {
|
|
4801
|
+
/* ignore */
|
|
4802
|
+
}
|
|
4803
|
+
});
|
|
4804
|
+
}
|
|
4805
|
+
catch (_a) {
|
|
4806
|
+
/* ignore */
|
|
4807
|
+
}
|
|
4808
|
+
return originalSend.apply(this, args);
|
|
4809
|
+
};
|
|
4810
|
+
proto.send = patched;
|
|
4811
|
+
this.disposers.push(() => {
|
|
4812
|
+
if (proto.send === patched)
|
|
4813
|
+
proto.send = originalSend;
|
|
4814
|
+
});
|
|
4815
|
+
}
|
|
4816
|
+
/**
|
|
4817
|
+
* Direct links to checkout.stripe.com. Capture phase so we still see the click
|
|
4818
|
+
* when the merchant's own handler calls stopPropagation().
|
|
4819
|
+
*/
|
|
4820
|
+
hookClicks() {
|
|
4821
|
+
if (typeof document === 'undefined')
|
|
4822
|
+
return;
|
|
4823
|
+
const onClick = (e) => {
|
|
4824
|
+
var _a, _b;
|
|
4825
|
+
try {
|
|
4826
|
+
const target = (_b = (_a = e.target) === null || _a === void 0 ? void 0 : _a.closest) === null || _b === void 0 ? void 0 : _b.call(_a, 'a[href]');
|
|
4827
|
+
if (!target || !target.href || !isCheckoutUrl(target.href))
|
|
4828
|
+
return;
|
|
4829
|
+
this.emit(extractSessionId(target.href));
|
|
4830
|
+
}
|
|
4831
|
+
catch (_c) {
|
|
4832
|
+
/* never block navigation */
|
|
4833
|
+
}
|
|
4834
|
+
};
|
|
4835
|
+
document.addEventListener('click', onClick, true);
|
|
4836
|
+
this.disposers.push(() => document.removeEventListener('click', onClick, true));
|
|
4837
|
+
}
|
|
4838
|
+
hookWindowOpen() {
|
|
4839
|
+
if (typeof window.open !== 'function')
|
|
4840
|
+
return;
|
|
4841
|
+
const original = window.open;
|
|
4842
|
+
const self = this;
|
|
4843
|
+
const patched = function patchedOpen(...args) {
|
|
4844
|
+
var _a;
|
|
4845
|
+
try {
|
|
4846
|
+
const url = args[0];
|
|
4847
|
+
const href = typeof url === 'string' ? url : (_a = url === null || url === void 0 ? void 0 : url.toString) === null || _a === void 0 ? void 0 : _a.call(url);
|
|
4848
|
+
if (href && isCheckoutUrl(href))
|
|
4849
|
+
self.emit(extractSessionId(href));
|
|
4850
|
+
}
|
|
4851
|
+
catch (_b) {
|
|
4852
|
+
/* ignore */
|
|
4853
|
+
}
|
|
4854
|
+
return original.apply(this !== null && this !== void 0 ? this : window, args);
|
|
4855
|
+
};
|
|
4856
|
+
window.open = patched;
|
|
4857
|
+
this.disposers.push(() => {
|
|
4858
|
+
if (window.open === patched)
|
|
4859
|
+
window.open = original;
|
|
4860
|
+
});
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4376
4864
|
/** Keys the remote config is allowed to fill on DatalyrConfig. */
|
|
4377
4865
|
const REMOTE_KEYS = [
|
|
4378
4866
|
'autoIdentify',
|
|
@@ -4380,6 +4868,7 @@ var Datalyr = (function (exports) {
|
|
|
4380
4868
|
'autoIdentifyAPI',
|
|
4381
4869
|
'autoIdentifyShopify',
|
|
4382
4870
|
'shopifyCartAttributes',
|
|
4871
|
+
'stripeCheckoutSessions',
|
|
4383
4872
|
'checkoutChampDomains',
|
|
4384
4873
|
'respectGlobalPrivacyControl',
|
|
4385
4874
|
'respectDoNotTrack',
|
|
@@ -4418,6 +4907,16 @@ var Datalyr = (function (exports) {
|
|
|
4418
4907
|
* Datalyr Web SDK
|
|
4419
4908
|
* Modern attribution tracking for web applications
|
|
4420
4909
|
*/
|
|
4910
|
+
/**
|
|
4911
|
+
* WEB-20. Fingerprint of the last identify() that actually emitted, so a host
|
|
4912
|
+
* app calling identify() on every route change does not re-emit an unchanged
|
|
4913
|
+
* identity. Cleared by reset(). Mirrors iOS `datalyr_last_identity_fingerprint`
|
|
4914
|
+
* and RN `@datalyr/last_identity_fingerprint`.
|
|
4915
|
+
*
|
|
4916
|
+
* Not PII: the stored value is a hash, and it is written through the same
|
|
4917
|
+
* `storage` wrapper as every other key (so it inherits the `dl_` prefix).
|
|
4918
|
+
*/
|
|
4919
|
+
const IDENTIFY_FINGERPRINT_KEY = 'dl_identify_fingerprint';
|
|
4421
4920
|
class Datalyr {
|
|
4422
4921
|
constructor() {
|
|
4423
4922
|
// Keys the caller passed to init() (before built-in defaults were merged).
|
|
@@ -4433,10 +4932,26 @@ var Datalyr = (function (exports) {
|
|
|
4433
4932
|
this.errors = [];
|
|
4434
4933
|
this.MAX_ERRORS = 50;
|
|
4435
4934
|
this.lastSpaPath = null; // dedups SPA pageviews (replaceState-on-mount double-fire)
|
|
4935
|
+
// Shopify loads Customer Privacy asynchronously. Keep the initial pageview
|
|
4936
|
+
// pending until initialization is complete and analytics consent is known,
|
|
4937
|
+
// then release it exactly once when consent allows tracking.
|
|
4938
|
+
this.initialPageViewReady = false;
|
|
4939
|
+
this.initialPageViewSent = false;
|
|
4436
4940
|
// FIXED (ISSUE-01): Async initialization promise to prevent race conditions
|
|
4437
4941
|
this.initializationPromise = null;
|
|
4438
4942
|
// Opt-out check moved to init() after cookies configured (Issue #14)
|
|
4439
4943
|
}
|
|
4944
|
+
/**
|
|
4945
|
+
* Return the workspace configured for this instance, or null before init().
|
|
4946
|
+
*
|
|
4947
|
+
* CDN bootstrap uses this public accessor instead of reaching into the
|
|
4948
|
+
* private runtime config when deciding whether an existing global belongs
|
|
4949
|
+
* to the requested workspace.
|
|
4950
|
+
*/
|
|
4951
|
+
getWorkspaceId() {
|
|
4952
|
+
var _a, _b;
|
|
4953
|
+
return (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.workspaceId) !== null && _b !== void 0 ? _b : null;
|
|
4954
|
+
}
|
|
4440
4955
|
/**
|
|
4441
4956
|
* Initialize the SDK
|
|
4442
4957
|
*/
|
|
@@ -4580,6 +5095,11 @@ var Datalyr = (function (exports) {
|
|
|
4580
5095
|
const deviceId = this.identity.getAnonymousId();
|
|
4581
5096
|
yield dataEncryption.initialize(this.config.workspaceId, deviceId);
|
|
4582
5097
|
this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
|
|
5098
|
+
// WEB-22: restore a PII user id that was persisted encrypted. Must run
|
|
5099
|
+
// here — it needs dataEncryption to be initialized, so it cannot happen
|
|
5100
|
+
// in the IdentityManager constructor. It never overwrites an id already
|
|
5101
|
+
// set by an explicit identify() earlier in this page load.
|
|
5102
|
+
yield this.identity.hydrateEncryptedUserId();
|
|
4583
5103
|
this.log('Encryption initialized, user properties loaded');
|
|
4584
5104
|
}
|
|
4585
5105
|
catch (encErr) {
|
|
@@ -4669,14 +5189,20 @@ var Datalyr = (function (exports) {
|
|
|
4669
5189
|
// Setup auto-identify callback
|
|
4670
5190
|
this.autoIdentify.initialize((email, source) => {
|
|
4671
5191
|
this.log(`Auto-identified user: ${email} from ${source}`);
|
|
4672
|
-
//
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
//
|
|
4679
|
-
|
|
5192
|
+
// WEB-21: ONE event, not two.
|
|
5193
|
+
//
|
|
5194
|
+
// This used to emit `$auto_identify` and then call identify(),
|
|
5195
|
+
// which emits `$identify` — two events ~1ms apart carrying the same
|
|
5196
|
+
// email. Production over 7 days on workspace f6260736 showed a
|
|
5197
|
+
// perfect 379 / 379 / 379 split ($identify / $auto_identify /
|
|
5198
|
+
// visitors): every auto-identification was booked twice.
|
|
5199
|
+
//
|
|
5200
|
+
// Nothing consumed `$auto_identify`: grepped across the whole
|
|
5201
|
+
// platform (app/, lib/, all Cloudflare workers, all Tinybird pipes)
|
|
5202
|
+
// — zero readers, while prod carried 6,197 of them in 30 days.
|
|
5203
|
+
// The only information it added over `$identify` was WHICH detector
|
|
5204
|
+
// found the email, so that is preserved as a trait instead.
|
|
5205
|
+
this.identify(email, { email, auto_identify_source: source });
|
|
4680
5206
|
});
|
|
4681
5207
|
}
|
|
4682
5208
|
// Stamp attribution signals into the Shopify cart (OPT-IN, default off).
|
|
@@ -4707,10 +5233,22 @@ var Datalyr = (function (exports) {
|
|
|
4707
5233
|
if (this.config.stripePaymentLinks !== false && this.shouldTrack()) {
|
|
4708
5234
|
this.syncStripePaymentLinks((_b = this.config.stripeLinkDomains) !== null && _b !== void 0 ? _b : []);
|
|
4709
5235
|
}
|
|
4710
|
-
//
|
|
4711
|
-
|
|
4712
|
-
|
|
5236
|
+
// Checkout Session capture (DEFAULT ON). The decorator above cannot help
|
|
5237
|
+
// when the merchant's BACKEND creates the session — checkout.stripe.com
|
|
5238
|
+
// ignores client_reference_id post-creation — which is how most SaaS
|
|
5239
|
+
// checkouts work, and why payers arrive at our webhook with no visitor
|
|
5240
|
+
// (measured 0.5% and 13.3% visitor coverage on the two Stripe tenants).
|
|
5241
|
+
// The session id still has to reach the browser, so we observe it and
|
|
5242
|
+
// let the server join on session.id. Same shouldTrack() gate as the
|
|
5243
|
+
// decorator: an opted-out visitor's id is never paired with a checkout.
|
|
5244
|
+
if (this.config.stripeCheckoutSessions !== false && this.shouldTrack()) {
|
|
5245
|
+
this.startStripeSessionCapture();
|
|
4713
5246
|
}
|
|
5247
|
+
// Track the initial page view after encryption is ready. On Shopify the
|
|
5248
|
+
// Customer Privacy API can still be loading, so retain one pending
|
|
5249
|
+
// pageview and release it from onShopifyConsentChanged() once allowed.
|
|
5250
|
+
this.initialPageViewReady = true;
|
|
5251
|
+
this.trackInitialPageViewOnce();
|
|
4714
5252
|
this.log('Async initialization complete');
|
|
4715
5253
|
}
|
|
4716
5254
|
catch (error) {
|
|
@@ -4843,6 +5381,50 @@ var Datalyr = (function (exports) {
|
|
|
4843
5381
|
/**
|
|
4844
5382
|
* Identify a user
|
|
4845
5383
|
*/
|
|
5384
|
+
/**
|
|
5385
|
+
* Stable fingerprint of an identity for redundant-emit suppression (WEB-20).
|
|
5386
|
+
*
|
|
5387
|
+
* Keys are sorted and each value is JSON-serialized, so `{a,b}` and `{b,a}`
|
|
5388
|
+
* collapse to one identity while a genuinely changed nested trait does not.
|
|
5389
|
+
* (The mobile SDKs use `String(value)`, which flattens every object to
|
|
5390
|
+
* `[object Object]` and can swallow a real change; web traits are plain JSON,
|
|
5391
|
+
* so it can afford to be exact.)
|
|
5392
|
+
*
|
|
5393
|
+
* Hashed rather than stored raw so the key does not become another copy of the
|
|
5394
|
+
* user's traits at rest. Being honest about the strength: a 32-bit
|
|
5395
|
+
* non-cryptographic digest sitting beside a readable `dl_dl_anonymous_id` is
|
|
5396
|
+
* NOT protection against a determined reader — it defeats casual inspection
|
|
5397
|
+
* and bulk scraping, nothing more. The PII that matters is handled properly
|
|
5398
|
+
* (encrypted `dl_user_id_pii`, encrypted `dl_user_traits`). 32 bits also means
|
|
5399
|
+
* a ~1-in-4.3e9 chance per identity that a genuine change is suppressed;
|
|
5400
|
+
* acceptable for change detection, which is all this is.
|
|
5401
|
+
*/
|
|
5402
|
+
identityFingerprint(userId, traits = {}) {
|
|
5403
|
+
const stableTraits = Object.keys(traits || {})
|
|
5404
|
+
.sort()
|
|
5405
|
+
.map((key) => {
|
|
5406
|
+
let value;
|
|
5407
|
+
try {
|
|
5408
|
+
value = JSON.stringify(traits[key]);
|
|
5409
|
+
}
|
|
5410
|
+
catch (_a) {
|
|
5411
|
+
// Circular/unserializable trait — fall back to a coarse marker rather
|
|
5412
|
+
// than throwing inside identify().
|
|
5413
|
+
value = '[unserializable]';
|
|
5414
|
+
}
|
|
5415
|
+
return `${key}=${value}`;
|
|
5416
|
+
})
|
|
5417
|
+
.join('&');
|
|
5418
|
+
const input = `${this.identity.getAnonymousId()}|${userId}|${stableTraits}`;
|
|
5419
|
+
// 32-bit FNV-1a. Not cryptographic — this only needs to detect change, and
|
|
5420
|
+
// it must be synchronous (crypto.subtle is async and identify() is not).
|
|
5421
|
+
let hash = 0x811c9dc5;
|
|
5422
|
+
for (let i = 0; i < input.length; i++) {
|
|
5423
|
+
hash ^= input.charCodeAt(i);
|
|
5424
|
+
hash = Math.imul(hash, 0x01000193);
|
|
5425
|
+
}
|
|
5426
|
+
return (hash >>> 0).toString(16);
|
|
5427
|
+
}
|
|
4846
5428
|
identify(userId, traits = {}) {
|
|
4847
5429
|
if (!this.initialized) {
|
|
4848
5430
|
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
@@ -4882,8 +5464,31 @@ var Datalyr = (function (exports) {
|
|
|
4882
5464
|
// If encryption fails, user traits are only stored in memory (this.userProperties)
|
|
4883
5465
|
// and will be lost on page reload - but PII is NOT exposed in localStorage
|
|
4884
5466
|
});
|
|
4885
|
-
//
|
|
4886
|
-
|
|
5467
|
+
// WEB-20: redundant-identify suppression, matching iOS 2.1.11 / RN 1.7.16.
|
|
5468
|
+
//
|
|
5469
|
+
// identify() is host-app-driven and SPA routers commonly call it in a
|
|
5470
|
+
// route effect, so an unchanged identity was re-emitted on every
|
|
5471
|
+
// navigation. The mobile SDKs got this in 2.1.10/1.7.15; web did not.
|
|
5472
|
+
//
|
|
5473
|
+
// Fingerprint = anonymousId | userId | sorted traits. The anonymous id is
|
|
5474
|
+
// included so a reset()/rotation always re-emits and identity links are
|
|
5475
|
+
// never lost — only exact repeats are skipped.
|
|
5476
|
+
//
|
|
5477
|
+
// Only the EVENT is suppressed. `this.identity.identify()` above has
|
|
5478
|
+
// already persisted `dl_user_id`, traits are still merged and encrypted,
|
|
5479
|
+
// and the plugin handlers below still run on every call — a plugin's
|
|
5480
|
+
// `identify` hook is a customer extension point whose semantics we cannot
|
|
5481
|
+
// assume, so its contract ("called when identify() is called") is
|
|
5482
|
+
// deliberately left intact.
|
|
5483
|
+
const identityFingerprint = this.identityFingerprint(userId, traits);
|
|
5484
|
+
const isRedundantIdentify = storage.getString(IDENTIFY_FINGERPRINT_KEY, null) === identityFingerprint;
|
|
5485
|
+
if (isRedundantIdentify) {
|
|
5486
|
+
this.log('Skipping redundant identify (unchanged identity):', userId);
|
|
5487
|
+
}
|
|
5488
|
+
else {
|
|
5489
|
+
storage.set(IDENTIFY_FINGERPRINT_KEY, identityFingerprint);
|
|
5490
|
+
this.track('$identify', Object.assign(Object.assign({}, identityLink), { traits }));
|
|
5491
|
+
}
|
|
4887
5492
|
// Call plugin handlers
|
|
4888
5493
|
if (this.config.plugins) {
|
|
4889
5494
|
for (const plugin of this.config.plugins) {
|
|
@@ -4903,6 +5508,18 @@ var Datalyr = (function (exports) {
|
|
|
4903
5508
|
this.trackError(error, { userId });
|
|
4904
5509
|
}
|
|
4905
5510
|
}
|
|
5511
|
+
/**
|
|
5512
|
+
* WEB-27: queue health, including events the SDK gave up on.
|
|
5513
|
+
*
|
|
5514
|
+
* `getStats()` existed on EventQueue but was unreachable — `queue` is private
|
|
5515
|
+
* and nothing exposed it, so the observability fix could not actually be
|
|
5516
|
+
* observed. A non-zero `droppedEvents` means data was lost.
|
|
5517
|
+
*/
|
|
5518
|
+
getQueueStats() {
|
|
5519
|
+
if (!this.queue)
|
|
5520
|
+
return null;
|
|
5521
|
+
return this.queue.getStats();
|
|
5522
|
+
}
|
|
4906
5523
|
/**
|
|
4907
5524
|
* Track a page view
|
|
4908
5525
|
*/
|
|
@@ -5020,6 +5637,9 @@ var Datalyr = (function (exports) {
|
|
|
5020
5637
|
// values to the next user's events (cross-user contamination on shared devices).
|
|
5021
5638
|
this.superProperties = {};
|
|
5022
5639
|
storage.remove('dl_user_traits');
|
|
5640
|
+
// WEB-20: drop the identify fingerprint, so a logout→login (or a different
|
|
5641
|
+
// user on this browser) always re-emits $identify and the link is rebuilt.
|
|
5642
|
+
storage.remove(IDENTIFY_FINGERPRINT_KEY);
|
|
5023
5643
|
// Clear the auto-identify guard so a different user on the same browser is
|
|
5024
5644
|
// re-captured (it short-circuits while dl_auto_identified_email is present), and so
|
|
5025
5645
|
// the prior user's email isn't left at rest after logout.
|
|
@@ -5167,16 +5787,21 @@ var Datalyr = (function (exports) {
|
|
|
5167
5787
|
* (same convention as pixels/auto-identify).
|
|
5168
5788
|
*/
|
|
5169
5789
|
disposeMarketingLinkDecorators() {
|
|
5170
|
-
var _a, _b;
|
|
5790
|
+
var _a, _b, _c;
|
|
5171
5791
|
try {
|
|
5172
5792
|
(_a = this.stripeLinksDisposer) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
5173
5793
|
}
|
|
5174
|
-
catch ( /* best-effort */
|
|
5794
|
+
catch ( /* best-effort */_d) { /* best-effort */ }
|
|
5175
5795
|
this.stripeLinksDisposer = undefined;
|
|
5176
5796
|
try {
|
|
5177
|
-
(_b = this.
|
|
5797
|
+
(_b = this.stripeSessionWatcher) === null || _b === void 0 ? void 0 : _b.stop();
|
|
5178
5798
|
}
|
|
5179
|
-
catch ( /* best-effort */
|
|
5799
|
+
catch ( /* best-effort */_e) { /* best-effort */ }
|
|
5800
|
+
this.stripeSessionWatcher = undefined;
|
|
5801
|
+
try {
|
|
5802
|
+
(_c = this.outboundDisposer) === null || _c === void 0 ? void 0 : _c.call(this);
|
|
5803
|
+
}
|
|
5804
|
+
catch ( /* best-effort */_f) { /* best-effort */ }
|
|
5180
5805
|
this.outboundDisposer = undefined;
|
|
5181
5806
|
}
|
|
5182
5807
|
optOut() {
|
|
@@ -5205,8 +5830,22 @@ var Datalyr = (function (exports) {
|
|
|
5205
5830
|
// Purge PII at rest.
|
|
5206
5831
|
this.userProperties = {};
|
|
5207
5832
|
storage.remove('dl_user_traits');
|
|
5833
|
+
// WEB-26: remove the PLAINTEXT id as well. optOut() purged only the
|
|
5834
|
+
// encrypted copy, so an opted-out visitor whose email was written by an
|
|
5835
|
+
// older build kept it at rest — the one state where that is least
|
|
5836
|
+
// defensible.
|
|
5837
|
+
storage.remove('dl_user_id');
|
|
5838
|
+
storage.remove('dl_user_id_pii');
|
|
5839
|
+
storage.remove(IDENTIFY_FINGERPRINT_KEY);
|
|
5208
5840
|
storage.remove('dl_auto_identified_email');
|
|
5209
5841
|
storage.remove('dl_journey');
|
|
5842
|
+
// WEB-25: first/last touch hold redacted-but-full landing URLs, referrers and
|
|
5843
|
+
// click ids for up to 90 days. reset() has always cleared them; optOut() did
|
|
5844
|
+
// not — so an opted-out visitor kept marketing attribution at rest, which is
|
|
5845
|
+
// the one state where it is least defensible. Same rationale as dl_journey
|
|
5846
|
+
// directly above.
|
|
5847
|
+
storage.remove('dl_first_touch');
|
|
5848
|
+
storage.remove('dl_last_touch');
|
|
5210
5849
|
this.log('User opted out');
|
|
5211
5850
|
}
|
|
5212
5851
|
/**
|
|
@@ -5269,8 +5908,16 @@ var Datalyr = (function (exports) {
|
|
|
5269
5908
|
this.autoIdentify = undefined;
|
|
5270
5909
|
this.userProperties = {};
|
|
5271
5910
|
storage.remove('dl_user_traits');
|
|
5911
|
+
// WEB-26: see optOut() — the plaintext id must go too.
|
|
5912
|
+
storage.remove('dl_user_id');
|
|
5913
|
+
storage.remove('dl_user_id_pii');
|
|
5914
|
+
storage.remove(IDENTIFY_FINGERPRINT_KEY);
|
|
5272
5915
|
storage.remove('dl_auto_identified_email');
|
|
5273
5916
|
storage.remove('dl_journey');
|
|
5917
|
+
// WEB-25: see optOut() — withdrawn analytics consent must not leave 90-day
|
|
5918
|
+
// marketing attribution at rest either.
|
|
5919
|
+
storage.remove('dl_first_touch');
|
|
5920
|
+
storage.remove('dl_last_touch');
|
|
5274
5921
|
}
|
|
5275
5922
|
else {
|
|
5276
5923
|
// TR-15 (P3): grant → persist the in-memory anon id now (see onShopifyConsentChanged).
|
|
@@ -5524,6 +6171,39 @@ var Datalyr = (function (exports) {
|
|
|
5524
6171
|
* changes. window.open(paymentLink) is not covered (use getVisitorId()
|
|
5525
6172
|
* manually); iframe/shadow-DOM links are unreachable from document.
|
|
5526
6173
|
*/
|
|
6174
|
+
/**
|
|
6175
|
+
* Observe Stripe Checkout Session ids and report each one once.
|
|
6176
|
+
*
|
|
6177
|
+
* The event is what carries the pairing: the server stores
|
|
6178
|
+
* (stripe_session_id -> visitor_id) and, when checkout.session.completed
|
|
6179
|
+
* arrives without a client_reference_id, joins on session.id and feeds the
|
|
6180
|
+
* SAME cacheCustomerVisitor path the Payment Link flow already uses — so
|
|
6181
|
+
* every later invoice for that customer inherits the visitor too.
|
|
6182
|
+
*
|
|
6183
|
+
* Consent is re-checked at emit time, not just at init: a visitor can
|
|
6184
|
+
* withdraw between page load and checkout, and this pairing is exactly the
|
|
6185
|
+
* kind of identity link that must stop when they do.
|
|
6186
|
+
*/
|
|
6187
|
+
startStripeSessionCapture() {
|
|
6188
|
+
if (this.stripeSessionWatcher)
|
|
6189
|
+
return;
|
|
6190
|
+
const watcher = new StripeSessionWatcher();
|
|
6191
|
+
this.stripeSessionWatcher = watcher;
|
|
6192
|
+
watcher.start((stripeSessionId) => {
|
|
6193
|
+
var _a;
|
|
6194
|
+
if (!this.shouldTrack())
|
|
6195
|
+
return;
|
|
6196
|
+
this.track('$stripe_checkout_session', { stripe_session_id: stripeSessionId });
|
|
6197
|
+
// The very next thing this page does is navigate to Stripe, so flush now
|
|
6198
|
+
// rather than let the batch timer lose the pairing to the unload.
|
|
6199
|
+
try {
|
|
6200
|
+
(_a = this.queue) === null || _a === void 0 ? void 0 : _a.flush();
|
|
6201
|
+
}
|
|
6202
|
+
catch (_b) {
|
|
6203
|
+
/* best-effort — never block the checkout */
|
|
6204
|
+
}
|
|
6205
|
+
});
|
|
6206
|
+
}
|
|
5527
6207
|
syncStripePaymentLinks(extraDomains) {
|
|
5528
6208
|
if (typeof window === "undefined" || typeof document === "undefined")
|
|
5529
6209
|
return;
|
|
@@ -5750,7 +6430,7 @@ var Datalyr = (function (exports) {
|
|
|
5750
6430
|
// drift from the package again (it sat at 1.7.4 across the 1.7.5 release). The
|
|
5751
6431
|
// build guard (scripts/check-bundle.js, run by build:check) still verifies the
|
|
5752
6432
|
// deployable bundles carry the package.json version. (FSR-103)
|
|
5753
|
-
sdk_version: '1.7.
|
|
6433
|
+
sdk_version: '1.7.8',
|
|
5754
6434
|
sdk_name: 'datalyr-web-sdk',
|
|
5755
6435
|
// A3-25: versioned-envelope stamp. Every SDK emits schema_version so the ingest/contract
|
|
5756
6436
|
// layer can key on ONE canonical envelope version (snake_case fields, event_name/event_data
|
|
@@ -5943,6 +6623,8 @@ var Datalyr = (function (exports) {
|
|
|
5943
6623
|
// visitor_id that vanishes on the next page load. Idempotent.
|
|
5944
6624
|
if (allowed)
|
|
5945
6625
|
this.identity.enablePersistence();
|
|
6626
|
+
if (allowed)
|
|
6627
|
+
this.trackInitialPageViewOnce();
|
|
5946
6628
|
if (!allowed) {
|
|
5947
6629
|
// Mirror setConsent() withdrawal: purge buffered events so events captured
|
|
5948
6630
|
// before the decline can't drain if consent is later re-granted.
|
|
@@ -5974,6 +6656,15 @@ var Datalyr = (function (exports) {
|
|
|
5974
6656
|
}
|
|
5975
6657
|
this.log('Shopify consent collected — analytics allowed:', allowed, '— marketing blocked:', marketingBlocked);
|
|
5976
6658
|
}
|
|
6659
|
+
/** Release the automatic landing pageview once, after init and consent. */
|
|
6660
|
+
trackInitialPageViewOnce() {
|
|
6661
|
+
if (!this.initialPageViewReady || this.initialPageViewSent)
|
|
6662
|
+
return;
|
|
6663
|
+
if (!this.config.trackPageViews || !this.initialized || !this.shouldTrack())
|
|
6664
|
+
return;
|
|
6665
|
+
this.initialPageViewSent = true;
|
|
6666
|
+
this.page();
|
|
6667
|
+
}
|
|
5977
6668
|
/**
|
|
5978
6669
|
* Setup SPA tracking
|
|
5979
6670
|
* Fixed Issue #15: Store original methods for cleanup
|
|
@@ -6168,6 +6859,10 @@ var Datalyr = (function (exports) {
|
|
|
6168
6859
|
this.stripeLinksDisposer();
|
|
6169
6860
|
this.stripeLinksDisposer = undefined;
|
|
6170
6861
|
}
|
|
6862
|
+
if (this.stripeSessionWatcher) {
|
|
6863
|
+
this.stripeSessionWatcher.stop();
|
|
6864
|
+
this.stripeSessionWatcher = undefined;
|
|
6865
|
+
}
|
|
6171
6866
|
// Clean up queue
|
|
6172
6867
|
if (this.queue) {
|
|
6173
6868
|
this.queue.destroy();
|
|
@@ -6193,6 +6888,8 @@ var Datalyr = (function (exports) {
|
|
|
6193
6888
|
this.container = undefined;
|
|
6194
6889
|
this.autoIdentify = undefined;
|
|
6195
6890
|
this.lastSpaPath = null;
|
|
6891
|
+
this.initialPageViewReady = false;
|
|
6892
|
+
this.initialPageViewSent = false;
|
|
6196
6893
|
// Clear any remaining data
|
|
6197
6894
|
this.superProperties = {};
|
|
6198
6895
|
this.userProperties = {};
|
|
@@ -6201,19 +6898,27 @@ var Datalyr = (function (exports) {
|
|
|
6201
6898
|
this.log('SDK destroyed');
|
|
6202
6899
|
}
|
|
6203
6900
|
}
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6901
|
+
/**
|
|
6902
|
+
* Create an independent SDK instance without reading or mutating window.
|
|
6903
|
+
*
|
|
6904
|
+
* The CDN bootstrap uses this when an existing global is configured for a
|
|
6905
|
+
* different workspace. Package consumers can also opt into multiple explicit
|
|
6906
|
+
* instances without changing the default singleton contract below.
|
|
6907
|
+
*/
|
|
6908
|
+
function createDatalyrInstance() {
|
|
6909
|
+
return new Datalyr();
|
|
6910
|
+
}
|
|
6911
|
+
// Create the default singleton. TR-23: reuse an existing window.datalyr (a
|
|
6912
|
+
// prior tag's instance or a package singleton) so npm/ESM/CJS consumers retain
|
|
6913
|
+
// the established one-global behavior. Workspace conflict resolution belongs
|
|
6914
|
+
// to the CDN bootstrap, which has the authoritative script-tag configuration.
|
|
6915
|
+
const datalyr = (typeof window !== 'undefined' && window.datalyr) || createDatalyrInstance();
|
|
6212
6916
|
// Expose global API
|
|
6213
6917
|
if (typeof window !== 'undefined') {
|
|
6214
6918
|
window.datalyr = datalyr;
|
|
6215
6919
|
}
|
|
6216
6920
|
|
|
6921
|
+
exports.createDatalyrInstance = createDatalyrInstance;
|
|
6217
6922
|
exports.datalyr = datalyr;
|
|
6218
6923
|
exports.default = datalyr;
|
|
6219
6924
|
|