UNPKG

@thrivestack/analytics-browser

Version:

ThriveStack Analytics Platform - Comprehensive web analytics tracking with privacy-first approach

1,326 lines (1,321 loc) 50 kB
'use strict'; var FingerprintJS = require('@fingerprintjs/fingerprintjs'); /** * ThriveStack Analytics Platform * Comprehensive web analytics tracking with privacy-first approach * @version 2.0.1 */ class ThriveStack { /** * Initialize ThriveStack with configuration options */ constructor(options) { // Handle string API key for backward compatibility if (typeof options === 'string') { options = { apiKey: options, }; } // Core settings this.apiKey = options.apiKey; this.apiEndpoint = options.apiEndpoint || "https://api.app.thrivestack.ai/api"; this.respectDoNotTrack = options.respectDoNotTrack !== false; this.trackClicks = options.trackClicks !== false; this.trackForms = options.trackForms === true; this.enableConsent = options.enableConsent === true; this.source = options.source || ''; // Geo IP service URL - defaults to free ipinfo.io service this.geoIpServiceUrl = 'https://ipinfo.io/json'; // IP and location information storage this.ipAddress = null; this.locationInfo = null; // Event batching this.eventQueue = []; this.queueTimer = null; this.batchSize = options.batchSize || 10; this.batchInterval = options.batchInterval || 2000; // Analytics state this.interactionHistory = []; this.maxHistoryLength = 20; // Consent settings (default to functional only) this.consentCategories = { functional: true, // Always needed analytics: options.defaultConsent === true, marketing: options.defaultConsent === true, }; // Load userId and groupId from cookies if available this.userId = this.getUserIdFromCookie() || ''; this.groupId = this.getGroupIdFromCookie() || ''; // Device ID management this.deviceId = null; this.deviceIdReady = false; this.fpPromise = null; // Session configuration this.sessionTimeout = options.sessionTimeout || 30 * 60 * 1000; // 30 minutes this.debounceDelay = options.debounceDelay || 2000; // 2 seconds this.sessionUpdateTimer = null; this.lastClickTime = null; this.debugMode = false; this._hierarchyCache = new WeakMap(); // Initialize device ID (check cookie first, then FingerprintJS if needed) this.initializeDeviceId(); // Fetch IP and location data on initialization this.fetchIpAndLocationInfo(); // Setup session tracking this.setupSessionTracking(); // Initialize automatically if tracking is allowed if (this.shouldTrack()) { this.autoCapturePageVisit(); if (this.trackClicks) { this.autoCaptureClickEvents(); } if (this.trackForms) { this.autoCaptureFormEvents(); } } } /** * Initialize device ID with proper fallback logic */ async initializeDeviceId() { try { // First, check if we already have a device ID in cookie const existingDeviceId = this.getDeviceIdFromCookie(); if (existingDeviceId) { // Use existing device ID from cookie this.deviceId = existingDeviceId; this.deviceIdReady = true; console.debug('Using existing device ID from cookie:', this.deviceId); // Process any queued events now that device ID is ready this.processQueueIfReady(); return; } // If no existing device ID, try to generate one with FingerprintJS console.debug('No existing device ID found, initializing FingerprintJS...'); await this.initFingerprintJS(); } catch (error) { console.warn('Failed to initialize device ID:', error instanceof Error ? error.message : 'Unknown error'); // Fallback to random device ID this.deviceId = this.generateRandomDeviceId(); this.deviceIdReady = true; this.setDeviceIdCookie(this.deviceId); console.debug('Using fallback random device ID:', this.deviceId); // Process any queued events now that device ID is ready this.processQueueIfReady(); } } /** * Initialize FingerprintJS */ async initFingerprintJS() { try { // Load FingerprintJS const fp = await FingerprintJS.load(); // Get and store the visitor identifier const result = await fp.get(); // Store the visitor identifier as our device ID this.deviceId = result.visitorId; this.deviceIdReady = true; // Store in cookie for persistence this.setDeviceIdCookie(this.deviceId); console.debug('FingerprintJS initialized with device ID:', this.deviceId); // Process any queued events now that device ID is ready this.processQueueIfReady(); } catch (error) { console.warn('Failed to initialize FingerprintJS:', error instanceof Error ? error.message : 'Unknown error'); // Fallback to random ID method this.deviceId = this.generateRandomDeviceId(); this.deviceIdReady = true; this.setDeviceIdCookie(this.deviceId); console.debug('Using fallback random device ID:', this.deviceId); // Process any queued events now that device ID is ready this.processQueueIfReady(); throw error; // Re-throw to let caller handle } } /** * Generate a random device ID as fallback */ generateRandomDeviceId() { return ('device_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)); } /** * Set device ID in cookie */ setDeviceIdCookie(deviceId) { if (!deviceId) return; const cookieName = 'thrivestack_device_id'; // Set cookie with a 2 year expiration (in seconds) const expiryDate = new Date(); expiryDate.setTime(expiryDate.getTime() + 730 * 24 * 60 * 60 * 1000); // 730 days // Set secure and SameSite attributes for better security const cookieValue = `${cookieName}=${deviceId};expires=${expiryDate.toUTCString()};path=/;SameSite=Lax`; try { document.cookie = cookieValue; } catch (e) { console.warn('Could not store device ID in cookie:', e); } } /** * Fetch IP address and location information with cookie caching */ async fetchIpAndLocationInfo() { try { // First, try to get cached data from cookie const cachedData = this.getLocationInfoFromCookie(); if (cachedData) { // Use cached data this.ipAddress = cachedData.ip || null; this.locationInfo = { city: cachedData.city || null, region: cachedData.region || null, country: cachedData.country || null, postal: cachedData.postal || null, loc: cachedData.loc || null, timezone: cachedData.timezone || null, }; console.debug('Using cached IP and location info from cookie'); return; } // If no cached data, fetch from API console.debug('No cached location data found, fetching from API...'); const response = await fetch(this.geoIpServiceUrl); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } const data = await response.json(); // Store IP and location data this.ipAddress = data.ip || null; this.locationInfo = { city: data.city || null, region: data.region || null, country: data.country || null, postal: data.postal || null, loc: data.loc || null, // Format: "lat,long" timezone: data.timezone || null, }; // Cache the data in cookie for future use this.setLocationInfoCookie(data); } catch (error) { console.warn('Failed to fetch IP and location info:', error instanceof Error ? error.message : 'Unknown error'); // Set fallback values this.ipAddress = null; this.locationInfo = null; } } /** * Set location info in cookie with Base64 encoding */ setLocationInfoCookie(locationData) { if (!locationData) return; const cookieName = 'thrivestack_location_info'; try { // Encode data as Base64 const encodedData = btoa(JSON.stringify(locationData)); // Set cookie with 24 hour expiration const expiryDate = new Date(); expiryDate.setTime(expiryDate.getTime() + 24 * 60 * 60 * 1000); // 24 hours const cookieValue = `${cookieName}=${encodedData};expires=${expiryDate.toUTCString()};path=/;SameSite=Lax`; document.cookie = cookieValue; console.debug('Location info cached in cookie'); } catch (e) { console.warn('Could not store location info in cookie:', e); } } /** * Get location info from cookie with Base64 decoding */ getLocationInfoFromCookie() { const cookieName = 'thrivestack_location_info'; try { const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.indexOf(cookieName + '=') === 0) { const encodedValue = cookie.substring(cookieName.length + 1); // Decode Base64 and parse JSON const decodedValue = atob(encodedValue); const locationData = JSON.parse(decodedValue); return locationData; } } } catch (e) { console.warn('Could not read location info from cookie:', e); // If cookie is corrupted, remove it this.removeLocationInfoCookie(); } return null; } /** * Remove location info cookie (used when cookie is corrupted) */ removeLocationInfoCookie() { const cookieName = 'thrivestack_location_info'; try { document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;SameSite=Lax`; } catch (e) { console.warn('Could not remove location info cookie:', e); } } /** * Initialize ThriveStack and start tracking */ async init(userId = '', source = '') { try { if (userId) { this.setUserId(userId); } if (source) { this.source = source; } } catch (error) { console.error('Failed to initialize ThriveStack:', error); } } /** * Check if tracking is allowed based on DNT and consent settings */ shouldTrack() { // Check Do Not Track setting if enabled if (this.respectDoNotTrack && (navigator.doNotTrack === '1' || navigator.doNotTrack === 'yes' || window.doNotTrack === '1')) { console.warn('User has enabled Do Not Track. Tracking is disabled.'); return false; } // Always allow functional tracking return true; } /** * Check if specific tracking category is allowed */ isTrackingAllowed(category) { if (!this.shouldTrack()) return false; if (this.enableConsent) { return this.consentCategories[category] === true; } return true; } /** * Update consent settings for a tracking category */ setConsent(category, hasConsent) { if (this.consentCategories.hasOwnProperty(category)) { this.consentCategories[category] = hasConsent; } } /** * Set user ID for tracking */ setUserId(userId) { this.userId = userId; this.setUserIdCookie(userId); } /** * Set groupId in instance and cookie */ setGroupId(groupId) { this.groupId = groupId; this.setGroupIdCookie(groupId); } /** * Set source for tracking */ setSource(source) { this.source = source; } /** * Set userId in cookie */ setUserIdCookie(userId) { if (!userId) return; const cookieName = 'thrivestack_user_id'; // Set cookie with a 1 year expiration (in seconds) const expiryDate = new Date(); expiryDate.setTime(expiryDate.getTime() + 365 * 24 * 60 * 60 * 1000); // 365 days // Set secure and SameSite attributes for better security const cookieValue = `${cookieName}=${encodeURIComponent(userId)};expires=${expiryDate.toUTCString()};path=/;SameSite=Lax`; try { document.cookie = cookieValue; } catch (e) { console.warn('Could not store user ID in cookie:', e); } } /** * Get userId from cookie */ getUserIdFromCookie() { const cookieName = 'thrivestack_user_id'; // Get value from cookies const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.indexOf(cookieName + '=') === 0) { const value = cookie.substring(cookieName.length + 1); return decodeURIComponent(value); } } return null; } /** * Set groupId in cookie */ setGroupIdCookie(groupId) { if (!groupId) return; const cookieName = 'thrivestack_group_id'; // Set cookie with a 1 year expiration (in seconds) const expiryDate = new Date(); expiryDate.setTime(expiryDate.getTime() + 365 * 24 * 60 * 60 * 1000); // 365 days // Set secure and SameSite attributes for better security const cookieValue = `${cookieName}=${encodeURIComponent(groupId)};expires=${expiryDate.toUTCString()};path=/;SameSite=Lax`; try { document.cookie = cookieValue; } catch (e) { console.warn('Could not store group ID in cookie:', e); } } /** * Get groupId from cookie */ getGroupIdFromCookie() { const cookieName = 'thrivestack_group_id'; // Get value from cookies const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.indexOf(cookieName + '=') === 0) { const value = cookie.substring(cookieName.length + 1); return decodeURIComponent(value); } } return null; } /** * Queue an event for batched sending */ queueEvent(events) { // Handle both single events and arrays if (!Array.isArray(events)) { events = [events]; } // Add events to queue this.eventQueue.push(...events); // Only process queue if device ID is ready if (this.deviceIdReady) { this.processQueueIfReady(); } else { console.debug('Device ID not ready, keeping events in queue'); } } /** * Process queue only if device ID is ready */ processQueueIfReady() { if (!this.deviceIdReady || this.eventQueue.length === 0) { return; } // Process queue if we've reached the batch size if (this.eventQueue.length >= this.batchSize) { this.processQueue(); } else if (!this.queueTimer) { // Start timer to process queue after delay this.queueTimer = setTimeout(() => this.processQueue(), this.batchInterval); } } /** * Process and send queued events */ processQueue() { if (this.eventQueue.length === 0 || !this.deviceIdReady) return; const events = [...this.eventQueue]; const updatedEvents = events.map((event) => ({ ...event, context: { ...event.context, device_id: this.deviceId, }, })); this.eventQueue = []; if (this.queueTimer) { clearTimeout(this.queueTimer); this.queueTimer = null; } this.track(updatedEvents).catch((error) => { console.error('Failed to send batch events:', error); // Add events back to front of queue for retry this.eventQueue.unshift(...events); }); } /** * Track events by sending to ThriveStack API */ async track(events) { if (!this.apiKey) { throw Error('Initialize the ThriveStack instance before sending telemetry data.'); } // Clean events of PII before sending const cleanedEvents = events.map((event) => this.cleanPIIFromEventData(event)); // Add retry logic for network errors let retries = 3; while (retries > 0) { try { const response = await fetch(`${this.apiEndpoint}/track`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': `${this.apiKey}`, }, body: JSON.stringify(cleanedEvents), }); if (!response.ok) { throw Error(`HTTP error ${response.status}: ${await response.text()}`); } const data = await response.json(); return data; } catch (error) { retries--; if (retries === 0) { console.error('Failed to send telemetry after multiple attempts:', error instanceof Error ? error.message : 'Unknown error'); throw error; } // Wait before retrying (exponential backoff) await new Promise((resolve) => setTimeout(resolve, 1000 * (3 - retries))); } } } /** * Send user identification data */ async identify(data) { if (!this.apiKey) { throw Error('Initialize the ThriveStack instance before sending telemetry data.'); } try { let userId = ''; if (Array.isArray(data) && data.length > 0) { const lastElement = data[data.length - 1]; userId = lastElement.user_id || ''; } else { // Keep original logic for non-array data const userData = data; userId = userData.user_id || ''; } // Set userId in instance and cookie if (userId) { this.setUserId(userId); } // Send data to API const response = await fetch(`${this.apiEndpoint}/identify`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': `${this.apiKey}`, }, body: JSON.stringify(data), }); if (!response.ok) { throw Error(`HTTP error ${response.status}: ${await response.text()}`); } const result = await response.json(); return result; } catch (error) { console.error('Failed to send identification data:', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Send group data */ async group(data) { if (!this.apiKey) { throw Error('Initialize the ThriveStack instance before sending telemetry data.'); } try { // Extract groupId from data let groupId = ''; if (Array.isArray(data) && data.length > 0) { const lastElement = data[data.length - 1]; groupId = lastElement.group_id || ''; } else { // Keep original logic for non-array data const groupData = data; groupId = groupData.group_id || ''; } // Store groupId in instance and cookie if (groupId) { this.setGroupId(groupId); } // Send data to API const response = await fetch(`${this.apiEndpoint}/group`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': `${this.apiKey}`, }, body: JSON.stringify(data), }); if (!response.ok) { throw Error(`HTTP error ${response.status}: ${await response.text()}`); } const result = await response.json(); return result; } catch (error) { console.error('Failed to send group data:', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Get UTM parameters from URL */ getUtmParameters() { const urlParams = new URLSearchParams(window.location.search); return { utm_campaign: urlParams.get('utm_campaign') || null, utm_medium: urlParams.get('utm_medium') || null, utm_source: urlParams.get('utm_source') || null, utm_term: urlParams.get('utm_term') || null, utm_content: urlParams.get('utm_content') || null, }; } /** * Get device ID - now returns the actual device ID or null if not ready */ getDeviceId() { if (this.deviceIdReady && this.deviceId) { return this.deviceId; } return null; } /** * Get device ID from cookie */ getDeviceIdFromCookie() { const cookieName = 'thrivestack_device_id'; // Try to get existing device ID from cookies const cookies = document.cookie.split(';'); // Look for our specific cookie for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.indexOf(cookieName + '=') === 0) { return cookie.substring(cookieName.length + 1); } } return null; } /** * Get session ID from cookie */ getSessionId() { const sessionCookieName = 'thrivestack_session'; try { // Try to get existing session from cookies const cookies = document.cookie.split(';'); let sessionCookieValue = null; // Look for our specific cookie for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.indexOf(sessionCookieName + '=') === 0) { sessionCookieValue = cookie.substring(sessionCookieName.length + 1); break; } } if (sessionCookieValue) { // Try to parse as new format (Base64 JSON) try { const sessionData = JSON.parse(atob(sessionCookieValue)); // Validate structure if (sessionData.sessionId && sessionData.lastActivity) { const lastActivity = new Date(sessionData.lastActivity); const now = new Date(); const timeSinceLastActivity = now.getTime() - lastActivity.getTime(); // Check if session is still valid (within timeout) if (timeSinceLastActivity < this.sessionTimeout) { return sessionData.sessionId; } else { // Session expired, create new one console.debug('Session expired, creating new session'); return this.createNewSession(); } } else { // Invalid structure, create new session return this.createNewSession(); } } catch (parseError) { // Not Base64 JSON, assume old format (plain session ID) console.debug('Migrating old session format to new format'); return this.migrateOldSession(sessionCookieValue); } } else { // No existing session, create new one return this.createNewSession(); } } catch (error) { console.warn('Error getting session ID:', error); return this.createNewSession(); } } /** * Create a new session */ createNewSession() { const sessionId = 'session_' + Math.random().toString(36).substring(2, 15); const now = new Date().toISOString(); const sessionData = { sessionId: sessionId, startTime: now, lastActivity: now, }; this.setSessionCookie(sessionData); return sessionId; } /** * Migrate old session format */ migrateOldSession(oldSessionId) { const now = new Date().toISOString(); const sessionData = { sessionId: oldSessionId, startTime: now, // We don't know the original start time lastActivity: now, }; this.setSessionCookie(sessionData); return oldSessionId; } /** * Set session cookie with new format */ setSessionCookie(sessionData) { const sessionCookieName = 'thrivestack_session'; try { const encodedData = btoa(JSON.stringify(sessionData)); const cookieValue = `${sessionCookieName}=${encodedData};path=/;SameSite=Lax`; document.cookie = cookieValue; } catch (error) { console.warn('Could not store session in cookie:', error); } } /** * Update session activity with debouncing */ updateSessionActivity() { // Clear existing timer if (this.sessionUpdateTimer) { clearTimeout(this.sessionUpdateTimer); } // Set new debounced timer this.sessionUpdateTimer = setTimeout(() => { this.updateSessionActivityImmediate(); }, this.debounceDelay); } /** * Immediate session activity update */ updateSessionActivityImmediate() { const sessionCookieName = 'thrivestack_session'; try { // Get current session data const cookies = document.cookie.split(';'); let sessionCookieValue = null; for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.indexOf(sessionCookieName + '=') === 0) { sessionCookieValue = cookie.substring(sessionCookieName.length + 1); break; } } if (sessionCookieValue) { try { const sessionData = JSON.parse(atob(sessionCookieValue)); // Update last activity sessionData.lastActivity = new Date().toISOString(); // Save updated session this.setSessionCookie(sessionData); } catch (parseError) { // If parsing fails, create new session this.createNewSession(); } } else { // No session exists, create new one this.createNewSession(); } } catch (error) { console.warn('Could not update session activity:', error); } } /** * Setup session tracking event listeners */ setupSessionTracking() { // Let only capture functions handle session activity updates // This prevents interference with session expiry validation } /** * Check if element has ThriveStack event class */ hasThriveStackEventClass(element) { if (!element || !element.className) return false; // Handle both string and DOMTokenList className const classList = typeof element.className === 'string' ? element.className.split(/\s+/) : Array.from(element.classList); return classList.some((className) => className.includes('thrivestack-event')); } /** * Capture page visit event */ async capturePageVisit() { if (!this.isTrackingAllowed('functional')) { return; } // Get device ID - return early if not ready const deviceId = this.getDeviceId(); if (!deviceId) { console.debug('Device ID not ready, page visit will be queued'); } // Get UTM parameters if marketing consent is given const utmParams = this.isTrackingAllowed('marketing') ? this.getUtmParameters() : {}; // Validate session first, then update activity const sessionId = this.getSessionId(); this.updateSessionActivity(); // Get user and group IDs (from instance or cookies) const currentUserId = this.userId || this.getUserIdFromCookie() || ''; const currentGroupId = this.groupId || this.getGroupIdFromCookie() || ''; // Build event with IP and location info const events = [ { event_name: 'page_visit', properties: { page_title: document.title, page_url: window.location.href, page_path: window.location.pathname, page_referrer: document.referrer || null, language: navigator.language || null, ip_address: this.ipAddress, city: this.locationInfo?.city || null, region: this.locationInfo?.region || null, country: this.locationInfo?.country || null, postal: this.locationInfo?.postal || null, loc: this.locationInfo?.loc || null, // Format: "lat,long" timezone: this.locationInfo?.timezone || null, ...utmParams, }, user_id: currentUserId, context: { group_id: currentGroupId, device_id: deviceId, // This will be null if not ready session_id: sessionId, source: this.source, // Add source to context }, timestamp: new Date().toISOString(), }, ]; // Queue event instead of sending immediately this.queueEvent(events); // Add to interaction history this.addToInteractionHistory('page_visit', events[0].properties); } /** * Add event to interaction history */ addToInteractionHistory(type, details) { // Add timestamp and sequence number const interaction = { type: type, details: details, timestamp: new Date().toISOString(), sequence: this.interactionHistory.length + 1, }; // Add to history this.interactionHistory.push(interaction); // Trim if necessary if (this.interactionHistory.length > this.maxHistoryLength) { this.interactionHistory.shift(); } } /** * Capture element click event */ captureClickEvent(event) { // Return early if click tracking not allowed if (!this.isTrackingAllowed('analytics')) { return; } const target = event.target; // NEW: Check if element has thrivestack-event class if (!this.hasThriveStackEventClass(target)) { console.debug("Element does not have 'thrivestack-event' class, skipping click tracking"); return; } // Throttle if too many clicks const now = Date.now(); if (this.lastClickTime && now - this.lastClickTime < 300) { return; } this.lastClickTime = now; const position = target.getBoundingClientRect(); // Get device ID - return early if not ready const deviceId = this.getDeviceId(); if (!deviceId) { console.debug('Device ID not ready, click event will be queued'); } // Get UTM parameters if marketing consent is given const utmParams = this.isTrackingAllowed('marketing') ? this.getUtmParameters() : {}; // Validate session first, then update activity const sessionId = this.getSessionId(); this.updateSessionActivity(); // Get user and group IDs (from instance or cookies) const currentUserId = this.userId || this.getUserIdFromCookie() || ''; const currentGroupId = this.groupId || this.getGroupIdFromCookie() || ''; const events = [ { event_name: 'element_click', properties: { page_title: document.title, page_url: window.location.href, page_path: window.location.pathname, element_text: target.textContent?.trim() || null, element_tag: target.tagName || null, element_id: target.id || null, element_href: target.getAttribute('href') || null, element_aria_label: target.getAttribute('aria-label') || null, element_class: target.className || null, element_position_left: position.left || null, element_position_top: position.top || null, viewport_height: window.innerHeight, viewport_width: window.innerWidth, page_referrer: document.referrer || null, language: navigator.language || null, ip_address: this.ipAddress, city: this.locationInfo?.city || null, region: this.locationInfo?.region || null, country: this.locationInfo?.country || null, postal: this.locationInfo?.postal || null, loc: this.locationInfo?.loc || null, // Format: "lat,long" timezone: this.locationInfo?.timezone || null, ...utmParams, }, user_id: currentUserId, context: { group_id: currentGroupId, device_id: deviceId, // This will be null if not ready session_id: sessionId, source: this.source, // Add source to context }, timestamp: new Date().toISOString(), }, ]; // Queue event instead of sending immediately this.queueEvent(events); // Add to interaction history this.addToInteractionHistory('element_click', events[0].properties); } /** * Capture form events (submission, abandonment) */ captureFormEvent(event, type) { // Return early if form tracking not allowed if (!this.isTrackingAllowed('analytics')) { return; } const form = event.target; // Get device ID - return early if not ready const deviceId = this.getDeviceId(); if (!deviceId) { console.debug('Device ID not ready, form event will be queued'); } // Validate session first, then update activity const sessionId = this.getSessionId(); this.updateSessionActivity(); const currentUserId = this.userId || this.getUserIdFromCookie() || ''; const currentGroupId = this.groupId || this.getGroupIdFromCookie() || ''; // Calculate form completion percentage const formData = form._trackingData || { filledFields: new Set(), }; const totalFields = Array.from(form.elements).filter((e) => !['submit', 'button', 'reset'].includes(e.type)).length; const completionPercent = Math.round((formData.filledFields.size / Math.max(totalFields, 1)) * 100); const events = [ { event_name: `form_${type}`, properties: { page_title: document.title, page_url: window.location.href, form_id: form.id || null, form_name: form.name || null, form_action: form.action || null, form_fields: totalFields, form_completion: completionPercent, interaction_time: formData.startTime ? Date.now() - formData.startTime : null, }, user_id: currentUserId, context: { group_id: currentGroupId, device_id: deviceId, // This will be null if not ready session_id: sessionId, source: this.source, // Add source to context }, timestamp: new Date().toISOString(), }, ]; // Queue event instead of sending immediately this.queueEvent(events); // Add to interaction history this.addToInteractionHistory(`form_${type}`, events[0].properties); } /** * Set up automatic page visit tracking */ autoCapturePageVisit() { // Track initial page load window.addEventListener('load', () => this.capturePageVisit()); // Track navigation events window.addEventListener('popstate', () => this.capturePageVisit()); // Track history API calls for SPA support const originalPushState = history.pushState; history.pushState = (...args) => { originalPushState.apply(history, args); this.capturePageVisit(); }; const originalReplaceState = history.replaceState; history.replaceState = (...args) => { originalReplaceState.apply(history, args); this.capturePageVisit(); }; } /** * Set up automatic click event tracking */ autoCaptureClickEvents() { document.addEventListener('click', (event) => this.captureClickEvent(event)); } /** * Set up automatic form event tracking */ autoCaptureFormEvents() { // Track form submissions document.addEventListener('submit', (event) => { this.captureFormEvent(event, 'submit'); }); // Track form field interactions document.addEventListener('input', (event) => { const target = event.target; if (target.form) { const form = target.form; if (!form._trackingData) { form._trackingData = { startTime: Date.now(), filledFields: new Set(), }; } // Track field completion const field = event.target; if (field.value.trim() !== '') { form._trackingData.filledFields.add(field.name || field.id); } else { form._trackingData.filledFields.delete(field.name || field.id); } } }); // Track form abandonment document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { // Find forms with tracking data document.querySelectorAll('form').forEach((form) => { if (form._trackingData && form._trackingData.filledFields.size > 0) { // Create a synthetic event with the form as target const event = { target: form, }; this.captureFormEvent(event, 'abandoned'); } }); } }); } /** * Get element hierarchy for DOM traversal * @param {Element} element - DOM element * @returns {string} Element hierarchy path */ getElementHierarchy(element) { // Use a cache to improve performance if (!this._hierarchyCache) { this._hierarchyCache = new WeakMap(); } // Return cached result if available if (this._hierarchyCache.has(element)) { return this._hierarchyCache.get(element) || ''; } let path = []; let currentElement = element; // Build path from element to document root while (currentElement && currentElement !== document) { let tagName = currentElement.tagName; let idSelector = currentElement.id ? `#${currentElement.id}` : ''; let classSelector = currentElement.className && typeof currentElement.className === 'string' ? `.${currentElement.className.trim().split(/\s+/).join('.')}` : ''; path.unshift(`${tagName}${idSelector}${classSelector}`); currentElement = currentElement.parentElement; } const result = path.join(' > '); // Cache the result this._hierarchyCache.set(element, result); return result; } /** * Get CSS selector for element * @param {Element} element - DOM element * @returns {string} CSS selector */ getElementSelector(element) { let idSelector = element.id ? `#${element.id}` : ''; let classSelector = element.className && typeof element.className === 'string' ? `.${element.className.trim().split(/\s+/).join('.')}` : ''; return `${element.tagName}${idSelector}${classSelector}`; } /** * Automatically detect and clean PII from event data */ cleanPIIFromEventData(eventData) { // Deep clone to avoid modifying original const cleanedData = JSON.parse(JSON.stringify(eventData)); return cleanedData; } /** * Set user information and optionally make identify API call */ async setUser(userId, emailId, properties = {}) { if (!userId) { console.warn('setUser: userId is required'); return null; } // Check if we need to make API call const currentUserId = this.getUserIdFromCookie(); const shouldMakeApiCall = !currentUserId || currentUserId !== userId; // Always update local state and cookie this.setUserId(userId); if (shouldMakeApiCall) { try { // Prepare identify payload const identifyData = [ { user_id: userId, traits: { user_email: emailId, user_name: emailId, ...properties, }, timestamp: new Date().toISOString(), }, ]; console.debug('Making identify API call for user:', userId); const result = await this.identify(identifyData); console.debug('Identify API call successful'); return result; } catch (error) { console.error('Failed to make identify API call:', error); throw error; } } else { console.debug('Skipping identify API call - user already set in cookie:', userId); return null; } } /** * Set group information and optionally make group API call */ async setGroup(groupId, groupDomain, groupName, properties = {}) { if (!groupId) { console.warn('setGroup: groupId is required'); return null; } // Check if we need to make API call const currentGroupId = this.getGroupIdFromCookie(); const shouldMakeApiCall = !currentGroupId || currentGroupId !== groupId; // Always update local state and cookie this.setGroupId(groupId); if (shouldMakeApiCall) { try { // Prepare group payload const groupData = [ { group_id: groupId, user_id: this.userId || this.getUserIdFromCookie() || undefined, traits: { group_type: 'Account', account_domain: groupDomain, account_name: groupName, ...properties, }, timestamp: new Date().toISOString(), }, ]; console.debug('Making group API call for group:', groupId); const result = await this.group(groupData); console.debug('Group API call successful'); return result; } catch (error) { console.error('Failed to make group API call:', error); throw error; } } else { console.debug('Skipping group API call - group already set in cookie:', groupId); return null; } } /** * Enable debug mode for troubleshooting */ enableDebugMode() { this.debugMode = true; // Override track method to log events const originalTrack = this.track.bind(this); this.track = async function (events) { console.group('ThriveStack Debug: Sending Events'); console.log('Events:', JSON.parse(JSON.stringify(events))); console.groupEnd(); return originalTrack(events); }; console.log('ThriveStack debug mode enabled'); } } // Singleton instance let thrivestackInstance = null; /** * Initialize ThriveStack analytics * @param apiKey - Your ThriveStack API key * @param source - Optional source identifier * @param options - Optional configuration options */ async function init(apiKey, source, options) { if (thrivestackInstance) { console.warn('ThriveStack already initialized'); return; } const config = { apiKey, source: source || '', ...options, }; thrivestackInstance = new ThriveStack(config); await thrivestackInstance.init(); } /** * Set user information * @param email - User's email address * @param userId - User's unique identifier * @param properties - Optional user properties */ async function setUser(email, userId, properties = {}) { if (!thrivestackInstance) { throw new Error('ThriveStack not initialized. Call init() first.'); } return await thrivestackInstance.setUser(userId, email, properties); } /** * Set group information * @param groupId - Group's unique identifier * @param groupDomain - Group's domain * @param groupName - Group's name * @param properties - Optional group properties */ async function setGroup(groupId, groupDomain, groupName, properties = {}) { if (!thrivestackInstance) { throw new Error('ThriveStack not initialized. Call init() first.'); } return await thrivestackInstance.setGroup(groupId, groupDomain, groupName, properties); } /** * Track custom events * @param eventName - Name of the event * @param properties - Optional event properties */ async function track(eventName, properties = {}) { if (!thrivestackInstance) { throw new Error('ThriveStack not initialized. Call init() first.'); } // Use the instance's internal tracking mechanism // This will automatically handle context, batching, and device ID const event = { event_name: eventName, properties, context: { device_id: thrivestackInstance.getDeviceId(), group_id: '', session_id: '', source: '', }, timestamp: new Date().toISOString(), }; // Use the instance's track method which handles all the processing return await thrivestackInstance.track([event]); } /** * Get the ThriveStack instance (for advanced usage) */ function getInstance() { return thrivestackInstance; } /** * Check if ThriveStack is initialized */ function isInitialized() { return thrivestackInstance !== null; } exports.getInstance = getInstance; exports.init = init; exports.isInitialized = isInitialized; exports.setGroup = setGroup; exports.setUser = setUser; exports.track = track; //# sourceMappingURL=index.production.js.map