UNPKG

@datalyr/web

Version:

Datalyr Web SDK - Modern attribution tracking for web applications

1,424 lines (1,412 loc) 99.4 kB
/** * @datalyr/web v1.1.0 * Datalyr Web SDK - Modern attribution tracking for web applications * (c) 2025 Datalyr Inc. * Released under the MIT License */ 'use strict'; /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; /** * Storage Module * Safe storage wrapper with fallbacks for Safari private mode */ class SafeStorage { constructor(storage) { this.memory = new Map(); this.prefix = '__dl_'; // Test if storage is available try { const testKey = '__dl_test__' + Math.random(); storage.setItem(testKey, '1'); storage.removeItem(testKey); this.storage = storage; } catch (_a) { // Storage not available (Safari private mode, etc.) this.storage = null; console.warn('[Datalyr] Storage not available, using memory fallback'); } } get(key, defaultValue = null) { const fullKey = this.prefix + key; try { if (this.storage) { const value = this.storage.getItem(fullKey); if (value === null) return defaultValue; // Try to parse JSON try { return JSON.parse(value); } catch (_a) { return value; } } else { const value = this.memory.get(fullKey); if (value === undefined) return defaultValue; try { return JSON.parse(value); } catch (_b) { return value; } } } catch (_c) { return defaultValue; } } set(key, value) { const fullKey = this.prefix + key; const stringValue = typeof value === 'string' ? value : JSON.stringify(value); try { if (this.storage) { this.storage.setItem(fullKey, stringValue); return true; } else { this.memory.set(fullKey, stringValue); return true; } } catch (e) { // Quota exceeded or other error console.warn('[Datalyr] Failed to store:', key, e); // Try memory fallback this.memory.set(fullKey, stringValue); return false; } } remove(key) { const fullKey = this.prefix + key; try { if (this.storage) { this.storage.removeItem(fullKey); return true; } else { this.memory.delete(fullKey); return true; } } catch (_a) { return false; } } keys() { try { if (this.storage) { const keys = []; for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key && key.startsWith(this.prefix)) { keys.push(key.slice(this.prefix.length)); } } return keys; } else { return Array.from(this.memory.keys()) .filter(k => k.startsWith(this.prefix)) .map(k => k.slice(this.prefix.length)); } } catch (_a) { return []; } } } // Cookie operations class CookieStorage { constructor(options = {}) { this.domain = options.domain || 'auto'; this.maxAge = options.maxAge || 365; this.sameSite = options.sameSite || 'Lax'; this.secure = options.secure || 'auto'; } get(name) { var _a; const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) { const rawValue = ((_a = parts.pop()) === null || _a === void 0 ? void 0 : _a.split(';').shift()) || null; if (rawValue) { try { return decodeURIComponent(rawValue); } catch (_b) { // Return raw value if decoding fails (backwards compatibility) return rawValue; } } } return null; } set(name, value, days) { try { const maxAge = (days || this.maxAge) * 86400; // Convert to seconds const secure = this.secure === 'auto' ? location.protocol === 'https:' : this.secure; let domain = ''; if (this.domain === 'auto') { // Auto-detect domain for cross-subdomain tracking domain = this.getAutoDomain(); } else if (this.domain) { domain = `;domain=${this.domain}`; } const cookie = [ `${name}=${encodeURIComponent(value)}`, `max-age=${maxAge}`, 'path=/', `SameSite=${this.sameSite}`, secure ? 'Secure' : '', domain ].filter(Boolean).join(';'); document.cookie = cookie; return true; } catch (e) { console.warn('[Datalyr] Failed to set cookie:', name, e); return false; } } remove(name) { try { // Try to remove with various domain settings const domains = ['', location.hostname]; // Add parent domain variations const parts = location.hostname.split('.'); if (parts.length > 2) { domains.push(`.${parts.slice(-2).join('.')}`); domains.push(`.${location.hostname}`); } domains.forEach(domain => { const domainStr = domain ? `;domain=${domain}` : ''; document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/${domainStr}`; }); return true; } catch (_a) { return false; } } /** * Auto-detect the best domain for cross-subdomain tracking */ getAutoDomain() { const hostname = location.hostname; // Don't set domain for localhost or IP addresses if (hostname === 'localhost' || /^[\d.]+$/.test(hostname) || /^\[[\d:]+\]$/.test(hostname)) { return ''; } // Try setting cookie at different domain levels to find the highest allowed const parts = hostname.split('.'); // Start from the root domain and work up for (let i = parts.length - 2; i >= 0; i--) { const testDomain = '.' + parts.slice(i).join('.'); const testName = '__dl_test_' + Math.random(); // Try to set a test cookie document.cookie = `${testName}=1;domain=${testDomain};path=/`; // Check if it was set successfully if (document.cookie.indexOf(testName) !== -1) { // Remove test cookie document.cookie = `${testName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;domain=${testDomain};path=/`; return `;domain=${testDomain}`; } } // If nothing worked, don't set domain (will default to current subdomain) return ''; } } // Export singleton instances for storage const storage = new SafeStorage(window.localStorage); new SafeStorage(window.sessionStorage); // Default cookie instance for backwards compatibility const cookies = new CookieStorage(); /** * Utility Functions */ /** * Generate UUID v4 */ function generateUUID() { // Use crypto.randomUUID if available if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } // Fallback implementation return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * Get all URL query parameters */ function getAllQueryParams(search = window.location.search) { const params = {}; try { if ('URLSearchParams' in window) { const searchParams = new URLSearchParams(search); searchParams.forEach((value, key) => { params[key] = value; }); return params; } } catch (_a) { } // Manual fallback const query = (search || '').replace(/^\?/, '').split('&'); for (const part of query) { const [key, value = ''] = part.split('='); try { const decodedKey = decodeURIComponent((key || '').replace(/\+/g, ' ')); const decodedValue = decodeURIComponent((value || '').replace(/\+/g, ' ')); if (decodedKey) { params[decodedKey] = decodedValue; } } catch (_b) { // Ignore bad encoding } } return params; } /** * Sanitize event data (remove sensitive keys, DOM elements, functions) */ function sanitizeEventData(data, maxDepth = 5, currentDepth = 0) { if (currentDepth >= maxDepth) return '[Max depth reached]'; if (data === null || data === undefined) return data; // Remove DOM elements and functions if ((typeof Element !== 'undefined' && data instanceof Element) || (typeof Document !== 'undefined' && data instanceof Document) || typeof data === 'function') { return '[Removed]'; } // Handle arrays if (Array.isArray(data)) { return data.map(item => sanitizeEventData(item, maxDepth, currentDepth + 1)); } // Handle objects if (typeof data === 'object') { const sanitized = {}; const sensitiveKeys = /pass|pwd|token|secret|auth|bearer|session|cookie|signature|api[-_]?key|private[-_]?key|access[-_]?token|refresh[-_]?token/i; for (const key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { // Skip sensitive keys if (sensitiveKeys.test(key)) { continue; } // Sanitize value recursively sanitized[key] = sanitizeEventData(data[key], maxDepth, currentDepth + 1); } } return sanitized; } // Handle strings if (typeof data === 'string') { // Truncate very long strings if (data.length > 1000) { return data.slice(0, 1000) + '...[truncated]'; } // Remove potential JWT tokens or API keys if (data.match(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/) || // JWT data.match(/^[a-f0-9]{32,}$/i)) { // Hex tokens return '[Redacted]'; } return data; } return data; } /** * Deep merge objects */ function deepMerge(target, ...sources) { if (!sources.length) return target; const source = sources.shift(); if (isObject(target) && isObject(source)) { for (const key in source) { if (isObject(source[key])) { if (!target[key]) Object.assign(target, { [key]: {} }); deepMerge(target[key], source[key]); } else { Object.assign(target, { [key]: source[key] }); } } } return deepMerge(target, ...sources); } function isObject(item) { return item && typeof item === 'object' && !Array.isArray(item); } /** * Calculate retry delay with exponential backoff and jitter */ function calculateRetryDelay(attempt, baseDelay = 1000) { const maxDelay = 30000; // 30 seconds max const jitter = Math.random() * 0.1; // 10% jitter return Math.min(baseDelay * Math.pow(2, attempt) * (1 + jitter), maxDelay); } /** * Check if browser Do Not Track is enabled */ function isDoNotTrackEnabled() { return navigator.doNotTrack === '1' || window.doNotTrack === '1' || navigator.doNotTrack === 'yes'; } /** * Check if Global Privacy Control is enabled */ function isGlobalPrivacyControlEnabled() { return navigator.globalPrivacyControl === true || window.globalPrivacyControl === true; } /** * Get root domain for cross-subdomain tracking */ function getRootDomain() { const hostname = window.location.hostname; // Handle localhost and IP addresses if (hostname === 'localhost' || hostname.match(/^[0-9]{1,3}\./) || // IPv4 hostname.match(/^\[?[0-9a-fA-F:]+\]?$/)) { // IPv6 return hostname; } // Get root domain (last two parts: example.com) const parts = hostname.split('.'); if (parts.length >= 2) { // Handle .co.uk, .com.au, etc const tld = parts[parts.length - 1]; const sld = parts[parts.length - 2]; // Common two-part TLDs const twoPartTlds = ['co.uk', 'com.au', 'co.nz', 'co.jp', 'co.in', 'co.za']; const lastTwo = `${sld}.${tld}`; if (twoPartTlds.includes(lastTwo) && parts.length >= 3) { return '.' + parts.slice(-3).join('.'); } return '.' + parts.slice(-2).join('.'); } return hostname; } /** * Get referrer data */ function getReferrerData() { const referrer = document.referrer; if (!referrer) return {}; try { const url = new URL(referrer); return { referrer, referrer_host: url.hostname, referrer_path: url.pathname, referrer_search: url.search, referrer_source: detectReferrerSource(url.hostname) }; } catch (_a) { return { referrer }; } } /** * Detect referrer source */ function detectReferrerSource(hostname) { const sources = { google: ['google.com', 'google.'], facebook: ['facebook.com', 'fb.com'], twitter: ['twitter.com', 't.co', 'x.com'], linkedin: ['linkedin.com', 'lnkd.in'], instagram: ['instagram.com'], youtube: ['youtube.com', 'youtu.be'], tiktok: ['tiktok.com'], reddit: ['reddit.com'], pinterest: ['pinterest.com'], bing: ['bing.com'], yahoo: ['yahoo.com'], duckduckgo: ['duckduckgo.com'], baidu: ['baidu.com'] }; for (const [source, domains] of Object.entries(sources)) { if (domains.some(domain => hostname.includes(domain))) { return source; } } return 'other'; } /** * Identity Management Module * Handles anonymous_id, user_id, and identity resolution */ class IdentityManager { constructor() { this.userId = null; this.sessionId = null; this.anonymousId = this.getOrCreateAnonymousId(); this.userId = this.getStoredUserId(); } /** * Get or create anonymous ID (device/browser identifier) */ getOrCreateAnonymousId() { // 1. Check root domain cookie first (works across subdomains) let anonymousId = cookies.get('__dl_visitor_id'); if (anonymousId) { // Found in cookie - sync to localStorage storage.set('dl_anonymous_id', anonymousId); return anonymousId; } // 2. Check localStorage (fallback for cookie issues) anonymousId = storage.get('dl_anonymous_id'); if (anonymousId) { // Found in localStorage - set root domain cookie this.setRootDomainCookie('__dl_visitor_id', anonymousId); return anonymousId; } // 3. Generate new ID anonymousId = `anon_${generateUUID()}`; // 4. Store in both cookie (primary) and localStorage (backup) this.setRootDomainCookie('__dl_visitor_id', anonymousId); storage.set('dl_anonymous_id', anonymousId); return anonymousId; } /** * Set a root domain cookie for cross-subdomain tracking */ setRootDomainCookie(name, value) { try { const rootDomain = getRootDomain(); const secure = location.protocol === 'https:' ? '; Secure' : ''; const encodedValue = encodeURIComponent(value); // Set cookie with root domain, 1 year expiry document.cookie = `${name}=${encodedValue}; domain=${rootDomain}; path=/; max-age=31536000; SameSite=Lax${secure}`; // Verify cookie was set successfully (cookies.get already decodes) const verifyValue = cookies.get(name); if (verifyValue === value) { console.log(`[Datalyr] Set root domain cookie: ${name} on domain: ${rootDomain}`); } else { // Fallback: try without domain (current subdomain only) document.cookie = `${name}=${encodedValue}; path=/; max-age=31536000; SameSite=Lax${secure}`; console.log(`[Datalyr] Set cookie without domain (fallback): ${name}`); } } catch (e) { console.error('[Datalyr] Error setting root domain cookie:', e); // Still try to set without domain as fallback try { const secure = location.protocol === 'https:' ? '; Secure' : ''; const encodedValue = encodeURIComponent(value); document.cookie = `${name}=${encodedValue}; path=/; max-age=31536000; SameSite=Lax${secure}`; } catch (fallbackError) { console.error('[Datalyr] Failed to set cookie even without domain:', fallbackError); } } } /** * Get stored user ID from previous session */ getStoredUserId() { return storage.get('dl_user_id'); } /** * Get the anonymous ID */ getAnonymousId() { return this.anonymousId; } /** * Get the user ID (if identified) */ getUserId() { return this.userId; } /** * Get the distinct ID (primary identifier) * Returns user_id if identified, otherwise anonymous_id */ getDistinctId() { return this.userId || this.anonymousId; } /** * Get canonical ID (alias for distinct_id) */ getCanonicalId() { return this.getDistinctId(); } /** * Set the session ID */ setSessionId(sessionId) { this.sessionId = sessionId; } /** * Get the session ID */ getSessionId() { return this.sessionId; } /** * Identify a user * Links anonymous_id to user_id */ identify(userId, traits = {}) { if (!userId) { console.warn('[Datalyr] identify() called without userId'); return {}; } const previousUserId = this.userId; this.userId = userId; // Persist for future sessions storage.set('dl_user_id', userId); // Return identity link data (will be sent as $identify event) return { anonymous_id: this.anonymousId, user_id: userId, previous_id: previousUserId, traits: traits, identified_at: new Date().toISOString(), resolution_method: 'identify_call' }; } /** * Alias one ID to another */ alias(userId, previousId) { const aliasData = { userId, previousId: previousId || this.anonymousId, aliased_at: new Date().toISOString() }; // Update current user ID if aliasing to current anonymous ID if (!previousId || previousId === this.anonymousId) { this.userId = userId; storage.set('dl_user_id', userId); } return aliasData; } /** * Reset the current user (on logout) * Clears user_id but keeps anonymous_id */ reset() { this.userId = null; storage.remove('dl_user_id'); storage.remove('dl_user_traits'); // Generate new anonymous ID for privacy this.anonymousId = `anon_${generateUUID()}`; storage.set('dl_anonymous_id', this.anonymousId); // Update root domain cookie with new ID this.setRootDomainCookie('__dl_visitor_id', this.anonymousId); } /** * Get all identity fields for event payload */ getIdentityFields() { return { // Modern fields distinct_id: this.getDistinctId(), anonymous_id: this.anonymousId, user_id: this.userId, // Legacy compatibility visitor_id: this.anonymousId, visitorId: this.anonymousId, canonical_id: this.getCanonicalId(), // Session session_id: this.sessionId, sessionId: this.sessionId, // Identity resolution resolution_method: 'browser_sdk', resolution_confidence: 1.0 }; } } /** * Session Management Module */ class SessionManager { constructor(timeout = 30 * 60 * 1000) { this.sessionId = null; this.sessionData = null; this.lastActivity = Date.now(); this.SESSION_KEY = 'dl_session_data'; this.activityCheckInterval = null; this.activityListeners = []; this.sessionTimeout = timeout; this.initSession(); this.setupActivityMonitor(); } /** * Initialize or restore session */ initSession() { const storedSession = storage.get(this.SESSION_KEY); const now = Date.now(); if (storedSession && this.isSessionValid(storedSession, now)) { // Restore existing session this.sessionData = storedSession; this.sessionId = storedSession.id; this.lastActivity = now; } else { // Create new session this.createNewSession(); } } /** * Check if session is still valid */ isSessionValid(session, now) { const timeSinceActivity = now - session.lastActivity; return timeSinceActivity < this.sessionTimeout && session.isActive; } /** * Create a new session */ createNewSession() { const now = Date.now(); this.sessionId = `sess_${generateUUID()}`; this.sessionData = { id: this.sessionId, startTime: now, lastActivity: now, pageViews: 0, events: 0, duration: 0, isActive: true }; this.incrementSessionCount(); this.saveSession(); return this.sessionId; } /** * Get current session ID */ getSessionId() { if (!this.sessionId || !this.isSessionActive()) { this.createNewSession(); } return this.sessionId; } /** * Get session data */ getSessionData() { return this.sessionData; } /** * Update session activity */ updateActivity(eventType) { const now = Date.now(); // Check if we need a new session if (!this.sessionData || !this.isSessionValid(this.sessionData, now)) { this.createNewSession(); return; } this.lastActivity = now; this.sessionData.lastActivity = now; this.sessionData.duration = now - this.sessionData.startTime; // Update counters if (eventType === 'pageview' || eventType === 'page_view') { this.sessionData.pageViews++; } this.sessionData.events++; this.saveSession(); } /** * Check if session is active */ isSessionActive() { if (!this.sessionData) return false; const now = Date.now(); return this.isSessionValid(this.sessionData, now); } /** * End the current session */ endSession() { if (this.sessionData) { this.sessionData.isActive = false; this.saveSession(); } this.sessionId = null; this.sessionData = null; } /** * Save session to storage */ saveSession() { if (this.sessionData) { storage.set(this.SESSION_KEY, this.sessionData); } } /** * Get session timeout */ getTimeout() { return this.sessionTimeout; } /** * Set session timeout */ setTimeout(timeout) { this.sessionTimeout = timeout; } /** * Store session attribution */ storeAttribution(attribution) { const key = `dl_session_${this.sessionId}_attribution`; storage.set(key, Object.assign(Object.assign({}, attribution), { sessionId: this.sessionId, timestamp: Date.now() })); } /** * Get session attribution */ getAttribution() { if (!this.sessionId) return null; const key = `dl_session_${this.sessionId}_attribution`; return storage.get(key); } /** * Get session metrics */ getMetrics() { if (!this.sessionData) return {}; return { session_id: this.sessionId, session_duration: this.sessionData.duration, session_page_views: this.sessionData.pageViews, session_events: this.sessionData.events, session_start: this.sessionData.startTime, time_since_session_start: Date.now() - this.sessionData.startTime }; } /** * Setup activity monitor for automatic session timeout */ setupActivityMonitor() { // Monitor user activity const activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart']; const handleActivity = () => { const now = Date.now(); if (this.sessionData && now - this.lastActivity > 1000) { // Debounce 1 second this.updateActivity(); } }; activityEvents.forEach(event => { window.addEventListener(event, handleActivity, { passive: true, capture: true }); this.activityListeners.push({ event, handler: handleActivity }); }); // Check for session timeout periodically this.activityCheckInterval = setInterval(() => { if (this.sessionData && !this.isSessionActive()) { this.createNewSession(); } }, 60000); // Check every minute } /** * Cleanup listeners and timers */ destroy() { // Remove activity listeners this.activityListeners.forEach(({ event, handler }) => { window.removeEventListener(event, handler); }); this.activityListeners = []; // Clear interval if (this.activityCheckInterval) { clearInterval(this.activityCheckInterval); this.activityCheckInterval = null; } } /** * Get session number (count of sessions) */ getSessionNumber() { const count = storage.get('dl_session_count', 0); return count + 1; } /** * Increment session count */ incrementSessionCount() { const count = storage.get('dl_session_count', 0); storage.set('dl_session_count', count + 1); } } /** * Attribution Tracking Module * Handles UTM parameters, click IDs, and customer journey */ class AttributionManager { constructor(options = {}) { this.UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; // Updated to match dl.js - includes ALL ad platform click IDs this.CLICK_IDS = [ 'fbclid', // Facebook/Meta 'gclid', // Google Ads 'gbraid', // Google Ads (iOS) 'wbraid', // Google Ads (web) 'ttclid', // TikTok 'msclkid', // Microsoft/Bing 'twclid', // Twitter/X 'li_fat_id', // LinkedIn 'sclid', // Snapchat 'dclid', // Google Display/DoubleClick 'epik', // Pinterest 'rdt_cid', // Reddit 'obclid', // Outbrain 'irclid', // Impact Radius 'ko_click_id' // Klaviyo ]; // Default tracked params matching dl.js this.DEFAULT_TRACKED_PARAMS = [ 'lyr', // Datalyr partner tracking 'ref', // Generic referral 'source', // Generic source (non-UTM) 'campaign', // Generic campaign (non-UTM) 'medium', // Generic medium (non-UTM) 'gad_source' // Google Ads source parameter ]; this.attributionWindow = options.attributionWindow || 30 * 24 * 60 * 60 * 1000; // 30 days // Merge default tracked params with user-provided ones this.trackedParams = [...this.DEFAULT_TRACKED_PARAMS, ...(options.trackedParams || [])]; } /** * Capture current attribution from URL */ captureAttribution() { const params = getAllQueryParams(); const attribution = { timestamp: Date.now() }; // Capture UTM parameters for (const utm of this.UTM_PARAMS) { const value = params[utm]; if (value) { const key = utm.replace('utm_', ''); attribution[key] = value; } } // Capture click IDs for (const clickId of this.CLICK_IDS) { const value = params[clickId]; if (value) { attribution.clickId = value; attribution.clickIdType = clickId; break; // Use first found click ID } } // Capture custom tracked parameters for (const param of this.trackedParams) { const value = params[param]; if (value) { attribution[param] = value; } } // Capture referrer if (document.referrer) { attribution.referrer = document.referrer; attribution.referrerHost = this.extractHostname(document.referrer); } // Capture landing page attribution.landingPage = window.location.href; attribution.landingPath = window.location.pathname; // Determine source if not explicitly set if (!attribution.source) { attribution.source = this.determineSource(attribution); } // Determine medium if not explicitly set if (!attribution.medium) { attribution.medium = this.determineMedium(attribution); } return attribution; } /** * Store first touch attribution */ storeFirstTouch(attribution) { const existing = storage.get('dl_first_touch'); if (!existing) { storage.set('dl_first_touch', Object.assign(Object.assign({}, attribution), { timestamp: Date.now() })); } } /** * Get first touch attribution */ getFirstTouch() { return storage.get('dl_first_touch'); } /** * Store last touch attribution */ storeLastTouch(attribution) { storage.set('dl_last_touch', Object.assign(Object.assign({}, attribution), { timestamp: Date.now() })); } /** * Get last touch attribution */ getLastTouch() { return storage.get('dl_last_touch'); } /** * Add touchpoint to customer journey */ addTouchpoint(sessionId, attribution) { const journey = this.getJourney(); const touchpoint = { timestamp: Date.now(), sessionId, source: attribution.source || undefined, medium: attribution.medium || undefined, campaign: attribution.campaign || undefined }; journey.push(touchpoint); // Keep last 30 touchpoints if (journey.length > 30) { journey.shift(); } storage.set('dl_journey', journey); } /** * Get customer journey */ getJourney() { return storage.get('dl_journey', []); } /** * Capture advertising platform cookies */ captureAdCookies() { const adCookies = {}; // Facebook/Meta cookies adCookies._fbp = cookies.get('_fbp'); adCookies._fbc = cookies.get('_fbc'); // Google Ads cookies adCookies._gcl_aw = cookies.get('_gcl_aw'); adCookies._gcl_dc = cookies.get('_gcl_dc'); adCookies._gcl_gb = cookies.get('_gcl_gb'); adCookies._gcl_ha = cookies.get('_gcl_ha'); adCookies._gac = cookies.get('_gac'); // Google Analytics cookies adCookies._ga = cookies.get('_ga'); adCookies._gid = cookies.get('_gid'); // TikTok cookies adCookies._ttp = cookies.get('_ttp'); adCookies._ttc = cookies.get('_ttc'); // Generate _fbp if missing (Facebook browser ID) if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) { const timestamp = Date.now(); const randomId = Math.random().toString(36).substring(2, 15); adCookies._fbp = `fb.1.${timestamp}.${randomId}`; // Optionally set the cookie for future use cookies.set('_fbp', adCookies._fbp, 90); } // Generate _fbc if we have fbclid but no _fbc const fbclid = this.getCurrentFbclid(); if (fbclid && !adCookies._fbc) { const timestamp = Math.floor(Date.now() / 1000); adCookies._fbc = `fb.1.${timestamp}.${fbclid}`; // Optionally set the cookie for future use cookies.set('_fbc', adCookies._fbc, 90); } // Filter out null values for cleaner data return Object.fromEntries(Object.entries(adCookies).filter(([_, value]) => value !== null)); } /** * Check if we have a specific click ID in current params */ hasClickId(clickIdType) { const params = getAllQueryParams(); return !!params[clickIdType]; } /** * Get current fbclid from URL if present */ getCurrentFbclid() { const params = getAllQueryParams(); return params.fbclid || null; } /** * Get attribution data for event */ getAttributionData() { const firstTouch = this.getFirstTouch(); const lastTouch = this.getLastTouch(); const journey = this.getJourney(); const current = this.captureAttribution(); // Capture advertising cookies automatically const adCookies = this.captureAdCookies(); // Update first/last touch if needed if (!firstTouch && Object.keys(current).length > 1) { this.storeFirstTouch(current); } if (Object.keys(current).length > 1) { this.storeLastTouch(current); } return Object.assign(Object.assign(Object.assign({}, current), adCookies), { // First touch (with snake_case aliases) first_touch_source: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.source, first_touch_medium: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.medium, first_touch_campaign: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.campaign, first_touch_timestamp: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp, firstTouchSource: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.source, firstTouchMedium: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.medium, firstTouchCampaign: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.campaign, // Last touch (with snake_case aliases) last_touch_source: lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.source, last_touch_medium: lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.medium, last_touch_campaign: lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.campaign, last_touch_timestamp: lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.timestamp, lastTouchSource: lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.source, lastTouchMedium: lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.medium, lastTouchCampaign: lastTouch === null || lastTouch === void 0 ? void 0 : lastTouch.campaign, // Journey metrics touchpoint_count: journey.length, touchpointCount: journey.length, days_since_first_touch: (firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp) ? Math.floor((Date.now() - firstTouch.timestamp) / 86400000) : 0, daysSinceFirstTouch: (firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp) ? Math.floor((Date.now() - firstTouch.timestamp) / 86400000) : 0 }); } /** * Determine source from attribution data */ determineSource(attribution) { // If we have a click ID, determine source from that if (attribution.clickIdType) { const clickIdSources = { fbclid: 'facebook', gclid: 'google', ttclid: 'tiktok', msclkid: 'bing', twclid: 'twitter', li_fat_id: 'linkedin', sclid: 'snapchat', dclid: 'doubleclick', epik: 'pinterest' }; return clickIdSources[attribution.clickIdType] || 'paid'; } // Check referrer if (attribution.referrerHost) { const host = attribution.referrerHost.toLowerCase(); // Social sources if (host.includes('facebook.com') || host.includes('fb.com')) return 'facebook'; if (host.includes('twitter.com') || host.includes('t.co') || host.includes('x.com')) return 'twitter'; if (host.includes('linkedin.com') || host.includes('lnkd.in')) return 'linkedin'; if (host.includes('instagram.com')) return 'instagram'; if (host.includes('youtube.com') || host.includes('youtu.be')) return 'youtube'; if (host.includes('tiktok.com')) return 'tiktok'; if (host.includes('reddit.com')) return 'reddit'; if (host.includes('pinterest.com')) return 'pinterest'; // Search engines if (host.includes('google.')) return 'google'; if (host.includes('bing.com')) return 'bing'; if (host.includes('yahoo.com')) return 'yahoo'; if (host.includes('duckduckgo.com')) return 'duckduckgo'; if (host.includes('baidu.com')) return 'baidu'; return 'referral'; } return 'direct'; } /** * Determine medium from attribution data */ determineMedium(attribution) { // If we have a click ID, it's paid if (attribution.clickId) { return 'cpc'; // Cost per click } // Check source const source = attribution.source; if (!source || source === 'direct') { return 'none'; } // Social sources typically organic unless paid const socialSources = ['facebook', 'twitter', 'linkedin', 'instagram', 'youtube', 'tiktok', 'reddit', 'pinterest']; if (socialSources.includes(source)) { return 'social'; } // Search engines const searchSources = ['google', 'bing', 'yahoo', 'duckduckgo', 'baidu']; if (searchSources.includes(source)) { return 'organic'; } return 'referral'; } /** * Extract hostname from URL */ extractHostname(url) { try { return new URL(url).hostname; } catch (_a) { return ''; } } /** * Check if attribution has expired */ isAttributionExpired(attribution) { if (!attribution.timestamp) return true; return Date.now() - attribution.timestamp > this.attributionWindow; } /** * Clear expired attribution */ clearExpiredAttribution() { const firstTouch = this.getFirstTouch(); const lastTouch = this.getLastTouch(); if (firstTouch && this.isAttributionExpired(firstTouch)) { storage.remove('dl_first_touch'); } if (lastTouch && this.isAttributionExpired(lastTouch)) { storage.remove('dl_last_touch'); } } } /** * Event Queue and Batching Module */ // Default critical events that bypass batching const DEFAULT_CRITICAL_EVENTS = ['purchase', 'signup', 'subscribe', 'lead', 'conversion']; // Default high priority events that use faster batching const DEFAULT_HIGH_PRIORITY_EVENTS = ['add_to_cart', 'begin_checkout', 'view_item', 'search']; class EventQueue { constructor(config) { this.queue = []; this.offlineQueue = []; this.batchTimer = null; this.periodicFlushInterval = null; this.flushPromise = null; this.recentEventIds = new Set(); this.MAX_RECENT_EVENT_IDS = 1000; this.OFFLINE_QUEUE_KEY = 'dl_offline_queue'; this.config = { batchSize: config.batchSize || 10, flushInterval: config.flushInterval || 5000, maxRetries: config.maxRetries || 5, retryDelay: config.retryDelay || 1000, endpoint: config.endpoint || 'https://ingest.datalyr.com', fallbackEndpoints: config.fallbackEndpoints || [], workspaceId: config.workspaceId, debug: config.debug || false, criticalEvents: config.criticalEvents || DEFAULT_CRITICAL_EVENTS, highPriorityEvents: config.highPriorityEvents || DEFAULT_HIGH_PRIORITY_EVENTS, maxOfflineQueueSize: config.maxOfflineQueueSize || 100 }; this.networkStatus = { isOnline: navigator.onLine !== false, lastOfflineAt: null, lastOnlineAt: null }; this.loadOfflineQueue(); this.setupNetworkListeners(); this.startPeriodicFlush(); } /** * Add event to queue */ enqueue(event) { const eventName = event.eventName; // Check for duplicates (within 500ms window) if (this.isDuplicateEvent(event)) { this.log('Duplicate event suppressed:', eventName); return; } // Critical events bypass queue if (this.config.criticalEvents.includes(eventName)) { this.log('Critical event, sending immediately:', eventName); this.sendBatch([event]); return; } // Add to queue this.queue.push(event); this.log('Event queued:', eventName); // Check if we should flush if (this.shouldFlush(eventName)) { this.flush(); } } /** * Check if event is duplicate */ isDuplicateEvent(event) { const eventId = event.eventId; if (this.recentEventIds.has(eventId)) { return true; } this.recentEventIds.add(eventId); // Clean up old event IDs if (this.recentEventIds.size > this.MAX_RECENT_EVENT_IDS) { const toDelete = this.recentEventIds.size - this.MAX_RECENT_EVENT_IDS; const iterator = this.recentEventIds.values(); for (let i = 0; i < toDelete; i++) { const next = iterator.next(); if (!next.done) { this.recentEventIds.delete(next.value); } } } return false; } /** * Check if we should flush the queue */ shouldFlush(eventName) { // Check queue size if (this.queue.length >= this.config.batchSize) { return true; } // Check for high priority events if (eventName && this.config.highPriorityEvents.includes(eventName)) { // Use faster flush for high priority if (this.batchTimer) { clearTimeout(this.batchTimer); } this.batchTimer = setTimeout(() => this.flush(), 1000); return false; } // Set normal batch timer if not already set if (!this.batchTimer) { this.batchTimer = setTimeout(() => this.flush(), this.config.flushInterval); } return false; } /** * Flush the queue */ flush() { return __awaiter(this, void 0, void 0, function* () { // Prevent concurrent flushes if (this.flushPromise) { return this.flushPromise; } this.flushPromise = this._flush(); yield this.flushPromise; this.flushPromise = null; }); } /** * Internal flush implementation */ _flush() { return __awaiter(this, void 0, void 0, function* () { // Clear timer if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; } // Check if we have events if (this.queue.length === 0) { return; } // Check network status if (!this.networkStatus.isOnline) { this.log('Network offline, queuing events'); this.moveToOfflineQueue(); return; } // Get events to send const events = this.queue.splice(0, this.config.batchSize); try { yield this.sendBatch(events); } catch (error) { this.log('Failed to send batch:', error); // Move to offline queue for retry this.offlineQueue.push(...events); this.saveOfflineQueue(); } }); } /** * Send batch of events */ sendBatch(events_1) { return __awaiter(this, arguments, void 0, function* (events, retries = 0, endpointIndex = 0) { const batchPayload = { events, batchId: generateUUID(), timestamp: new Date().toISOString() }; // Get current endpoint (main or fallback) const endpoints = [this.config.endpoint, ...this.config.fallbackEndpoints]; const currentEndpoint = endpoints[endpointIndex] || this.config.endpoint; try { const response = yield fetch(currentEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Batch-Size': events.length.toString() }, body: JSON.stringify(batchPayload), keepalive: true }); if (!response.ok) { // Handle rate limiting if (response.status === 429) { const retryAfter = parseInt(response.headers.get('Retry-After') || '60'); this.log(`Rate limited, retrying after ${retryAfter}s`); setTimeout(() => { this.queue.unshift(...events); }, retryAfter * 1000); return; } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } this.log(`Batch sent successfully to ${currentEndpoint}: ${events.length} events`); } catch (error) { // Try next fallback endpoint if available if (endpointIndex < endpoints.length - 1) { this.log(`Failed on ${currentEndpoint}, trying fallback ${endpointIndex + 1}`); return this.sendBatch(events, 0, endpointIndex + 1); } // Retry with exponential backoff on current e