@datalyr/web 1.7.9 → 1.7.12

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.7.9
2
+ * @datalyr/web v1.7.12
3
3
  * Datalyr Web SDK - Modern attribution tracking for web applications
4
4
  * (c) 2026 Datalyr Inc.
5
5
  * Released under the MIT License
@@ -815,7 +815,11 @@ const REDACTED_URL_PARAMS = new Set([
815
815
  'token', 'access_token', 'refresh_token', 'id_token', 'auth', 'authorization',
816
816
  'password', 'pass', 'pwd', 'secret', 'api_key', 'apikey', 'key',
817
817
  'session', 'session_id', 'sid', 'email', 'e', 'phone', 'tel',
818
- 'signature', 'sig', 'otp', 'reset', 'hash'
818
+ 'signature', 'sig', 'otp', 'reset', 'hash',
819
+ // Klaviyo profile IDs are deterministic customer identifiers. Capture them
820
+ // through the dedicated identity envelope, but never retain them in URLs,
821
+ // referrers, first/last-touch records, or generic event properties.
822
+ 'dl_kprofile_id'
819
823
  ]);
820
824
  // BATCH-2(e): `code` is CONDITIONAL, not in the always-redact set above. An OAuth
821
825
  // authorization code (account-takeover-grade if it leaks to ad platforms) ALWAYS travels
@@ -1760,7 +1764,8 @@ class SessionManager {
1760
1764
  class AttributionManager {
1761
1765
  constructor(options = {}) {
1762
1766
  this.queryParamsCache = null;
1763
- this.UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
1767
+ this.pendingKlaviyoProfileId = null;
1768
+ this.UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id'];
1764
1769
  // Updated to match dl.js - includes ALL ad platform click IDs
1765
1770
  this.CLICK_IDS = [
1766
1771
  'fbclid', // Facebook/Meta
@@ -1805,7 +1810,11 @@ class AttributionManager {
1805
1810
  'source', // Generic source (non-UTM)
1806
1811
  'campaign', // Generic campaign (non-UTM)
1807
1812
  'medium', // Generic medium (non-UTM)
1808
- 'gad_source' // Google Ads source parameter
1813
+ 'gad_source', // Google Ads source parameter
1814
+ // Datalyr-owned Klaviyo parameters. These live beside merchant UTMs so
1815
+ // account-level setup never replaces customer values.
1816
+ 'dl_ksource',
1817
+ 'dl_kmessage_id'
1809
1818
  ];
1810
1819
  // TR-03: ad-cookie fields (Meta/Google Ads/TikTok/Snap) that are MARKETING-scoped. When
1811
1820
  // marketing consent is declined these are neither synthesized/written nor shipped. Google
@@ -1815,6 +1824,69 @@ class AttributionManager {
1815
1824
  // Merge default tracked params with user-provided ones
1816
1825
  this.trackedParams = [...this.DEFAULT_TRACKED_PARAMS, ...(options.trackedParams || [])];
1817
1826
  this.marketingAllowedFn = options.marketingAllowed;
1827
+ this.replaceVisibleUrl = options.replaceVisibleUrl;
1828
+ }
1829
+ validKlaviyoProfileId(value) {
1830
+ const id = typeof value === 'string' ? value.trim() : '';
1831
+ if (!id || id.length > 512 || /[\u0000-\u001f\u007f]/.test(id))
1832
+ return null;
1833
+ return id;
1834
+ }
1835
+ /** Capture the dedicated Klaviyo identity parameter before URL redaction.
1836
+ * It deliberately never enters the Attribution object or touch storage. */
1837
+ captureKlaviyoProfileBinding(params) {
1838
+ if (!Object.prototype.hasOwnProperty.call(params, 'dl_kprofile_id'))
1839
+ return;
1840
+ const profileId = this.validKlaviyoProfileId(params.dl_kprofile_id);
1841
+ delete params.dl_kprofile_id;
1842
+ if (profileId && this.isMarketingAllowed())
1843
+ this.pendingKlaviyoProfileId = profileId;
1844
+ try {
1845
+ const url = new URL(window.location.href);
1846
+ if (url.searchParams.has('dl_kprofile_id')) {
1847
+ url.searchParams.delete('dl_kprofile_id');
1848
+ const clean = `${url.pathname}${url.search}${url.hash}`;
1849
+ if (this.replaceVisibleUrl)
1850
+ this.replaceVisibleUrl(clean);
1851
+ else
1852
+ window.history.replaceState(window.history.state, '', clean);
1853
+ }
1854
+ }
1855
+ catch (_a) {
1856
+ // Capture still succeeds when a synthetic test/browser URL cannot parse.
1857
+ }
1858
+ }
1859
+ /** Called once encryption is ready. The short TTL only bridges async init or
1860
+ * a reload before the landing event is queued; it is not a profile cache. */
1861
+ hydrateKlaviyoProfileBinding() {
1862
+ return __awaiter(this, void 0, void 0, function* () {
1863
+ const now = Date.now();
1864
+ if (!this.pendingKlaviyoProfileId) {
1865
+ const stored = yield storage.getEncrypted(AttributionManager.KLAVIYO_BINDING_KEY, null);
1866
+ const restored = this.validKlaviyoProfileId(stored === null || stored === void 0 ? void 0 : stored.profileId);
1867
+ if (restored && Number(stored === null || stored === void 0 ? void 0 : stored.expiresAt) > now && this.isMarketingAllowed()) {
1868
+ this.pendingKlaviyoProfileId = restored;
1869
+ }
1870
+ else {
1871
+ storage.remove(AttributionManager.KLAVIYO_BINDING_KEY);
1872
+ }
1873
+ }
1874
+ if (this.pendingKlaviyoProfileId) {
1875
+ yield storage.setEncrypted(AttributionManager.KLAVIYO_BINDING_KEY, {
1876
+ profileId: this.pendingKlaviyoProfileId,
1877
+ expiresAt: now + AttributionManager.KLAVIYO_BINDING_TTL_MS,
1878
+ });
1879
+ }
1880
+ });
1881
+ }
1882
+ /** One-shot binding for the next queued event. */
1883
+ consumeKlaviyoProfileBinding() {
1884
+ if (!this.isMarketingAllowed())
1885
+ this.pendingKlaviyoProfileId = null;
1886
+ const profileId = this.pendingKlaviyoProfileId;
1887
+ this.pendingKlaviyoProfileId = null;
1888
+ storage.remove(AttributionManager.KLAVIYO_BINDING_KEY);
1889
+ return profileId;
1818
1890
  }
1819
1891
  // TR-03: default (no predicate / no signal) = allowed → byte-identical to prior behavior.
1820
1892
  isMarketingAllowed() {
@@ -1837,6 +1909,7 @@ class AttributionManager {
1837
1909
  if (!this.queryParamsCache) {
1838
1910
  this.queryParamsCache = params;
1839
1911
  }
1912
+ this.captureKlaviyoProfileBinding(params);
1840
1913
  const attribution = {
1841
1914
  timestamp: Date.now()
1842
1915
  };
@@ -1851,8 +1924,13 @@ class AttributionManager {
1851
1924
  const value = params[utm];
1852
1925
  if (value) {
1853
1926
  attribution[utm] = value; // canonical: utm_source, utm_campaign, ...
1854
- const key = utm.replace('utm_', '');
1855
- attribution[key] = value; // alias: source, campaign, ...
1927
+ // The five original UTM dimensions keep their legacy stripped aliases.
1928
+ // `utm_id` is intentionally canonical-only: writing it to a generic `id`
1929
+ // property would collide with event/customer identifiers downstream.
1930
+ if (utm !== 'utm_id') {
1931
+ const key = utm.replace('utm_', '');
1932
+ attribution[key] = value; // alias: source, campaign, ...
1933
+ }
1856
1934
  }
1857
1935
  }
1858
1936
  // Capture click IDs. The first present (by CLICK_IDS priority) is the primary
@@ -1889,6 +1967,12 @@ class AttributionManager {
1889
1967
  attribution[param] = value;
1890
1968
  }
1891
1969
  }
1970
+ // The namespaced source is Datalyr's routing authority. Keep any
1971
+ // merchant-owned utm_source intact while still classifying this visit as
1972
+ // Klaviyo everywhere source-level attribution is displayed.
1973
+ if (attribution.dl_ksource === 'klaviyo') {
1974
+ attribution.source = 'klaviyo';
1975
+ }
1892
1976
  // Capture referrer. 9.A.4: redact secret/PII query-param values (reset tokens,
1893
1977
  // OAuth codes, emails) — this attribution object is stamped onto every event AND
1894
1978
  // persisted into dl_first_touch/dl_last_touch, so it must be clean at capture.
@@ -2152,6 +2236,21 @@ class AttributionManager {
2152
2236
  current = Object.assign(Object.assign({}, fallback), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
2153
2237
  }
2154
2238
  }
2239
+ // A real signal WITHOUT its own link tag must not erase the stored one. The
2240
+ // creator/bio-link journey is click-link-today, return-from-instagram-next-week:
2241
+ // that organic revisit scores hasRealAttribution (external referrer) with no
2242
+ // `lyr`, and before this carry-forward storeLastTouch below replaced the tagged
2243
+ // touch wholesale — every later event (and the Track page's conversion count,
2244
+ // which keys on the event's own lyr) silently lost the link. Semantics = "last
2245
+ // non-empty lyr within the window", the same answer the server's resolveLyr
2246
+ // argMax gives webhook conversions; a NEW ?lyr= still wins because current.lyr
2247
+ // is already set. source/medium/campaign still update — lyr is an independent
2248
+ // dimension (which link), not a channel claim.
2249
+ if (hasRealAttribution && !current.lyr) {
2250
+ const storedLyr = (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.lyr) || (firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.lyr);
2251
+ if (storedLyr)
2252
+ current.lyr = storedLyr;
2253
+ }
2155
2254
  // Capture advertising cookies automatically
2156
2255
  const adCookies = this.captureAdCookies();
2157
2256
  // Only (re)write first/last touch when this pageview carried a REAL signal — never
@@ -2328,6 +2427,8 @@ class AttributionManager {
2328
2427
  }
2329
2428
  }
2330
2429
  }
2430
+ AttributionManager.KLAVIYO_BINDING_KEY = 'dl_klaviyo_profile_binding';
2431
+ AttributionManager.KLAVIYO_BINDING_TTL_MS = 10 * 60 * 1000;
2331
2432
 
2332
2433
  /**
2333
2434
  * Event Queue and Batching Module
@@ -4988,6 +5089,7 @@ class Datalyr {
4988
5089
  this.initialized = false;
4989
5090
  this.errors = [];
4990
5091
  this.MAX_ERRORS = 50;
5092
+ this.shopifyConsentUnresolvable = false; // loadFeatures failed or never yielded customerPrivacy → stop fail-closing
4991
5093
  this.lastSpaPath = null; // dedups SPA pageviews (replaceState-on-mount double-fire)
4992
5094
  // Shopify loads Customer Privacy asynchronously. Keep the initial pageview
4993
5095
  // pending until initialization is complete and analytics consent is known,
@@ -5067,7 +5169,15 @@ class Datalyr {
5067
5169
  // TR-03: gate ad-signal synthesis + shipping on LIVE marketing consent. Returns true by
5068
5170
  // default (no consent signal) so behavior is unchanged for the common case; false only on
5069
5171
  // an explicit decline (Shopify marketing:false / setConsent marketing|sale=false).
5070
- marketingAllowed: () => this.consentAllowsMarketing()
5172
+ marketingAllowed: () => this.consentAllowsMarketing(),
5173
+ // Bypass our SPA wrapper when removing the one-time profile parameter,
5174
+ // otherwise the privacy cleanup itself would manufacture a pageview.
5175
+ replaceVisibleUrl: (url) => {
5176
+ var _a;
5177
+ const replace = (_a = this.originalReplaceState) !== null && _a !== void 0 ? _a : history.replaceState;
5178
+ replace.call(history, history.state, '', url);
5179
+ this.lastSpaPath = window.location.pathname + window.location.search + window.location.hash;
5180
+ },
5071
5181
  });
5072
5182
  this.queue = new EventQueue(this.config);
5073
5183
  this.fingerprint = new FingerprintCollector({
@@ -5157,6 +5267,7 @@ class Datalyr {
5157
5267
  // in the IdentityManager constructor. It never overwrites an id already
5158
5268
  // set by an explicit identify() earlier in this page load.
5159
5269
  yield this.identity.hydrateEncryptedUserId();
5270
+ yield this.attribution.hydrateKlaviyoProfileBinding();
5160
5271
  this.log('Encryption initialized, user properties loaded');
5161
5272
  }
5162
5273
  catch (encErr) {
@@ -5420,6 +5531,14 @@ class Datalyr {
5420
5531
  referrerParams.set('utm_content', lastTouch.content);
5421
5532
  if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.term)
5422
5533
  referrerParams.set('utm_term', lastTouch.term);
5534
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.utm_id)
5535
+ referrerParams.set('utm_id', lastTouch.utm_id);
5536
+ // The trackable-link tag rides the referrer too — the edge worker's own
5537
+ // server-side appendPlayReferrer packs it, and ingest parses it back out
5538
+ // of install_referrer_url. Without it, interstitial-mode Android installs
5539
+ // lose the deterministic link tag that a bare 302 would have carried.
5540
+ if (lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.lyr)
5541
+ referrerParams.set('lyr', lastTouch.lyr);
5423
5542
  try {
5424
5543
  const url = new URL(options.appStoreUrl);
5425
5544
  url.searchParams.set('referrer', referrerParams.toString());
@@ -6420,6 +6539,16 @@ class Datalyr {
6420
6539
  // Add attribution data (caller wins on collisions)
6421
6540
  const attributionData = this.attribution.getAttributionData();
6422
6541
  assignMissing(eventData, attributionData);
6542
+ // A Klaviyo landing URL may carry the profile ID as a dedicated custom
6543
+ // parameter. Emit it once through the deterministic external-id envelope;
6544
+ // never copy it into generic attribution or user-visible URL fields.
6545
+ const klaviyoProfileId = this.attribution.consumeKlaviyoProfileBinding();
6546
+ if (klaviyoProfileId) {
6547
+ const existingExternalIds = eventData.external_ids;
6548
+ eventData.external_ids = Object.assign(Object.assign({}, (existingExternalIds && typeof existingExternalIds === 'object' && !Array.isArray(existingExternalIds)
6549
+ ? existingExternalIds
6550
+ : {})), { klaviyo: klaviyoProfileId });
6551
+ }
6423
6552
  // Add session metrics (SDK-authoritative — session_id et al. must not be overridden)
6424
6553
  const sessionMetrics = this.session.getMetrics();
6425
6554
  Object.assign(eventData, sessionMetrics);
@@ -6487,7 +6616,7 @@ class Datalyr {
6487
6616
  // drift from the package again (it sat at 1.7.4 across the 1.7.5 release). The
6488
6617
  // build guard (scripts/check-bundle.js, run by build:check) still verifies the
6489
6618
  // deployable bundles carry the package.json version. (FSR-103)
6490
- sdk_version: '1.7.9',
6619
+ sdk_version: '1.7.12',
6491
6620
  sdk_name: 'datalyr-web-sdk',
6492
6621
  // A3-25: versioned-envelope stamp. Every SDK emits schema_version so the ingest/contract
6493
6622
  // layer can key on ONE canonical envelope version (snake_case fields, event_name/event_data
@@ -6576,6 +6705,27 @@ class Datalyr {
6576
6705
  return false;
6577
6706
  }
6578
6707
  }
6708
+ /**
6709
+ * TR-15 refinement: fail closed ONLY while the Customer Privacy API can still
6710
+ * arrive (Shopify.loadFeatures exists and hasn't failed). A storefront with no
6711
+ * loadFeatures (headless installs, `platform:'shopify'` on a non-Shopify page)
6712
+ * has NO path to a consent answer — blocking there is not a pre-load window,
6713
+ * it is zero events forever, silently. No signal possible → null (fail open);
6714
+ * false is reserved for an explicit decline from the API.
6715
+ */
6716
+ shopifyConsentPendingBlock() {
6717
+ var _a;
6718
+ if (!this.isShopifyStorefront())
6719
+ return null;
6720
+ if (this.shopifyConsentUnresolvable)
6721
+ return null;
6722
+ try {
6723
+ return typeof ((_a = window.Shopify) === null || _a === void 0 ? void 0 : _a.loadFeatures) === 'function' ? false : null;
6724
+ }
6725
+ catch (_b) {
6726
+ return null;
6727
+ }
6728
+ }
6579
6729
  /**
6580
6730
  * Shopify's analytics-consent signal: true/false when the Customer Privacy API is
6581
6731
  * present and answers, null when there's no signal (API absent / unexpected shape)
@@ -6585,7 +6735,7 @@ class Datalyr {
6585
6735
  try {
6586
6736
  const cp = this.getShopifyCustomerPrivacy();
6587
6737
  if (!cp)
6588
- return this.isShopifyStorefront() ? false : null;
6738
+ return this.shopifyConsentPendingBlock();
6589
6739
  if (typeof cp.analyticsProcessingAllowed === 'function') {
6590
6740
  const allowed = cp.analyticsProcessingAllowed();
6591
6741
  return typeof allowed === 'boolean' ? allowed : null;
@@ -6595,10 +6745,10 @@ class Datalyr {
6595
6745
  const allowed = cp.userCanBeTracked();
6596
6746
  return typeof allowed === 'boolean' ? allowed : null;
6597
6747
  }
6598
- return this.isShopifyStorefront() ? false : null;
6748
+ return this.shopifyConsentPendingBlock();
6599
6749
  }
6600
6750
  catch (_a) {
6601
- return this.isShopifyStorefront() ? false : null;
6751
+ return this.shopifyConsentPendingBlock();
6602
6752
  }
6603
6753
  }
6604
6754
  /**
@@ -6610,15 +6760,15 @@ class Datalyr {
6610
6760
  try {
6611
6761
  const cp = this.getShopifyCustomerPrivacy();
6612
6762
  if (!cp || typeof cp.marketingAllowed !== 'function') {
6613
- return this.isShopifyStorefront() ? false : null;
6763
+ return this.shopifyConsentPendingBlock();
6614
6764
  }
6615
6765
  const allowed = cp.marketingAllowed();
6616
6766
  return typeof allowed === 'boolean'
6617
6767
  ? allowed
6618
- : (this.isShopifyStorefront() ? false : null);
6768
+ : (this.shopifyConsentPendingBlock());
6619
6769
  }
6620
6770
  catch (_a) {
6621
- return this.isShopifyStorefront() ? false : null;
6771
+ return this.shopifyConsentPendingBlock();
6622
6772
  }
6623
6773
  }
6624
6774
  /**
@@ -6654,18 +6804,31 @@ class Datalyr {
6654
6804
  // banner interaction. Guarded — a missing/changed loadFeatures must never break tracking.
6655
6805
  const shopify = window.Shopify;
6656
6806
  if (shopify && typeof shopify.loadFeatures === 'function') {
6657
- shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }], (error) => {
6658
- if (error) {
6659
- this.log('Shopify loadFeatures(consent-tracking-api) failed:', error);
6660
- return;
6661
- }
6662
- try {
6663
- this.onShopifyConsentChanged();
6664
- }
6665
- catch (e) {
6666
- this.log('Shopify post-load consent eval failed:', e);
6667
- }
6668
- });
6807
+ // One retry before declaring the API unreachable: a TRANSIENT loadFeatures
6808
+ // failure must not fail-open the whole session for a previously-declined
6809
+ // shopper. Only after the retry (or a success that still yields no
6810
+ // customerPrivacy) is the pre-load window declared over — then the
6811
+ // fail-closed gate releases and consent re-evaluates (a decline gates).
6812
+ const attempt = (retriesLeft) => {
6813
+ shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }], (error) => {
6814
+ if (error && retriesLeft > 0) {
6815
+ this.log('Shopify loadFeatures(consent-tracking-api) failed, retrying:', error);
6816
+ setTimeout(() => attempt(retriesLeft - 1), 1000);
6817
+ return;
6818
+ }
6819
+ if (error)
6820
+ this.log('Shopify loadFeatures(consent-tracking-api) failed:', error);
6821
+ if (error || !this.getShopifyCustomerPrivacy())
6822
+ this.shopifyConsentUnresolvable = true;
6823
+ try {
6824
+ this.onShopifyConsentChanged();
6825
+ }
6826
+ catch (e) {
6827
+ this.log('Shopify post-load consent eval failed:', e);
6828
+ }
6829
+ });
6830
+ };
6831
+ attempt(1);
6669
6832
  }
6670
6833
  }
6671
6834
  catch (error) {