@datalyr/web
Version:
Datalyr Web SDK - Modern attribution tracking for web applications
1,424 lines (1,412 loc) • 99.4 kB
JavaScript
/**
* @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