UNPKG

@thrivestack/analytics-browser

Version:

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

1,236 lines (1,235 loc) 83.3 kB
/** * ThriveStack Analytics Platform * Comprehensive web analytics tracking with privacy-first approach * @version 2.0.1 */ class ThriveStack { /** * Check if visitor_generated event should be sent */ shouldSendVisitorGeneratedEvent(currentSessionId) { // Always send for first time (no previous session tracked) if (this.lastSessionId === null) { return true; } // Send if session ID has changed (new session with existing device) if (this.lastSessionId !== currentSessionId) { return true; } // Don't send if same session (page refresh, navigation within session) return false; } /** * Check if visitor_generated should be sent for new session with existing device */ shouldSendVisitorGeneratedForNewSession() { // Check if we have a device ID in cookie (existing device) const deviceIdFromCookie = this.getDeviceIdFromCookie(); if (!deviceIdFromCookie) { return false; // No device ID means new visitor, handled elsewhere } // Check if visitor_generated was already sent for this device const visitorGeneratedSent = this.getVisitorGeneratedSentFromCookie(); if (visitorGeneratedSent) { return false; // Already sent for this device } // Check if we have a last session ID const lastSessionId = this.getLastSessionIdFromCookie(); if (!lastSessionId) { return true; // No previous session tracked, send visitor_generated } // Check if current session is different from last session const currentSessionId = this.getSessionId(); if (lastSessionId !== currentSessionId) { return true; // New session detected, send visitor_generated } return false; // Same session, don't send } /** * Send dedicated fingerprint event with location data */ sendFingerprintEvent(deviceId, fingerprintData, totalGenerationTime) { try { // Extract location data from fingerprint data or use existing location info const locationData = this.locationInfo || {}; // Prepare visitor_generated event payload with ClickHouse-compatible properties const eventPayload = { // Location data for ClickHouse materialized view ip_address: fingerprintData?.ip_address || this.ipAddress || '', country: locationData.country_code || '', region: locationData.region || '', city: locationData.city || '', }; // Create ThriveStackEvent object with ClickHouse-compatible context const fingerprintEvent = { event_name: 'visitor_generated', properties: eventPayload, user_id: this.userId || this.getUserIdFromCookie() || '', context: { session_id: this.getSessionId(), device_id: deviceId, group_id: this.groupId || this.getGroupIdFromCookie() || '', source: this.source }, timestamp: new Date().toISOString(), }; // Queue the event for sending this.queueEvent(fingerprintEvent); // Mark as sent and update session tracking this.visitorGeneratedSent = true; this.lastSessionId = this.getSessionId(); // Save to cookies this.setVisitorGeneratedSentCookie(true); this.setLastSessionIdCookie(this.lastSessionId); // Fingerprint event sent successfully } catch (error) { // Failed to send fingerprint event } } /** * Send fingerprint event for cached device ID (new session mapping) */ sendFingerprintEventFromCache(deviceId) { try { // Get cached location data if available const cachedLocationData = this.getLocationInfoFromCookie(); // Prepare visitor_generated event payload for cached fingerprint with ClickHouse-compatible properties const eventPayload = { // Location data for ClickHouse materialized view ip_address: cachedLocationData?.ip || this.ipAddress || '', country: cachedLocationData?.country_code || this.locationInfo?.country_code || '', region: cachedLocationData?.region || this.locationInfo?.region || '', city: cachedLocationData?.city || this.locationInfo?.city || '', }; // Create ThriveStackEvent object with ClickHouse-compatible context const fingerprintEvent = { event_name: 'visitor_generated', properties: eventPayload, user_id: this.userId || this.getUserIdFromCookie() || '', context: { session_id: this.getSessionId(), device_id: deviceId, group_id: this.groupId || this.getGroupIdFromCookie() || '', source: this.source }, timestamp: new Date().toISOString(), }; // Queue the event for sending this.queueEvent(fingerprintEvent); // Mark as sent and update session tracking this.visitorGeneratedSent = true; this.lastSessionId = this.getSessionId(); // Save to cookies this.setVisitorGeneratedSentCookie(true); this.setLastSessionIdCookie(this.lastSessionId); // Cached fingerprint event sent for new session mapping } catch (error) { // Failed to send cached fingerprint event } } /** * 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://azure.dev.app.thrivestack.ai/api"; 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; // 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; this.locationReady = false; this.firstPageVisitCaptured = false; // Session configuration this.sessionTimeout = options.sessionTimeout || 30 * 60 * 1000; // 30 minutes this.debounceDelay = options.debounceDelay ?? 200; // 200ms debounce this.sessionUpdateTimer = null; this.lastClickTime = null; this.debugMode = options.debug || false; this.enableDeveloperLogs = options.enableDeveloperLogs || false; this._hierarchyCache = new WeakMap(); // Subscription status management this.subscriptionStatus = null; this.subscriptionCheckPromise = null; this.isTrackingEnabled = true; // Default to enabled until we check // Capture first page visit immediately this.capturePageVisit().catch((error) => { // Failed to capture first page visit }); // Visitor generation tracking - load from cookies this.visitorGeneratedSent = this.getVisitorGeneratedSentFromCookie(); this.lastSessionId = this.getLastSessionIdFromCookie(); // Initialize device ID: check cookie first, generate if needed this.initializeDeviceId(); // Set location as ready with null values initially this.locationReady = true; this.ipAddress = null; this.locationInfo = null; // Start subscription check in parallel (non-blocking) this.checkSubscriptionStatus().catch((error) => { console.warn('Failed to check subscription status, proceeding with tracking:', error); }); // Initialize automatically if tracking is allowed if (this.shouldTrack()) { // Don't capture page visit immediately - wait for deviceId and location to be ready // this.autoCapturePageVisit(); // This will be called after both deviceId and location are ready if (this.trackClicks) { this.autoCaptureClickEvents(); } if (this.trackForms) { this.autoCaptureFormEvents(); } } } /** * Check subscription status with 24-hour caching */ async checkSubscriptionStatus() { try { // Check if we already have this promise running if (this.subscriptionCheckPromise) { return this.subscriptionCheckPromise; } // Check cache first const cachedStatus = this.getSubscriptionStatusFromCache(); if (cachedStatus !== null) { this.subscriptionStatus = cachedStatus.status; this.isTrackingEnabled = !['cancelled', 'suspended'].includes(cachedStatus.status); // Using cached subscription status return Promise.resolve(this.isTrackingEnabled); } // Create the promise for API call this.subscriptionCheckPromise = this.fetchSubscriptionStatus(); const result = await this.subscriptionCheckPromise; // Clear the promise once completed this.subscriptionCheckPromise = null; return result; } catch (error) { // Failed to check subscription status, default to enabled this.isTrackingEnabled = true; this.subscriptionCheckPromise = null; return true; } } /** * Fetch subscription status from API */ async fetchSubscriptionStatus() { try { const response = await fetch(`${this.apiEndpoint}/subscriptionStatus`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'x-api-key': `${this.apiKey}`, }, }); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } const data = await response.json(); this.subscriptionStatus = data.status; // Cache the result for 24 hours this.setSubscriptionStatusCache(data.status); // Determine if tracking should be enabled this.isTrackingEnabled = !['cancelled', 'suspended'].includes(data.status); // Fetched subscription status return this.isTrackingEnabled; } catch (error) { // Failed to fetch subscription status, default to enabled this.isTrackingEnabled = true; return true; } } /** * Get subscription status from cache */ getSubscriptionStatusFromCache() { try { const cacheKey = 'thrivestack_subscription_status'; const cached = localStorage.getItem(cacheKey); if (!cached) { return null; } const cacheData = JSON.parse(cached); const now = Date.now(); const cacheAge = now - cacheData.timestamp; const twentyFourHours = 24 * 60 * 60 * 1000; // 24 hours in milliseconds // Check if cache is still valid (less than 24 hours old) if (cacheAge < twentyFourHours) { return cacheData; } else { // Cache expired, remove it localStorage.removeItem(cacheKey); return null; } } catch (error) { // Failed to read subscription status from cache return null; } } /** * Set subscription status in cache with 24-hour expiration */ setSubscriptionStatusCache(status) { try { const cacheKey = 'thrivestack_subscription_status'; const cacheData = { status: status, timestamp: Date.now(), }; localStorage.setItem(cacheKey, JSON.stringify(cacheData)); // Subscription status cached } catch (error) { // Failed to cache subscription status } } /** * Generate fingerprint using IP address + User Agent hash (Plausible-style approach) * This method provides fast and privacy-friendly device identification */ generateSimpleFingerprint() { this.startSimpleFingerprintGeneration(); } /** * Generate fingerprint in background and send visitor_id_generated event when ready */ async generateFingerprintInBackground() { try { // Start fingerprint generation immediately await this.generateSimpleFingerprintHash(); } catch (error) { // Background fingerprint generation failed } } /** * Initialize device ID: Check cookie first, generate if needed */ async initializeDeviceId() { try { // Get current session ID to check if it's a new session const currentSessionId = this.getSessionId(); // First, check if we already have a valid device ID in cookie const existingDeviceId = this.getDeviceIdFromCookie(); if (existingDeviceId && existingDeviceId.trim() !== '') { this.deviceId = existingDeviceId.trim(); this.deviceIdReady = true; // Using existing device ID from cookie // Check if this is a new session with existing device ID const shouldSendVisitorGenerated = this.shouldSendVisitorGeneratedEvent(currentSessionId); if (shouldSendVisitorGenerated) { // Send visitor_generated event for new session mapping (even with cached fingerprint) this.sendFingerprintEventFromCache(this.deviceId); this.visitorGeneratedSent = true; } this.lastSessionId = currentSessionId; return; } // No existing device ID found, start IP fetching to generate one this.generateFingerprintInBackground(); } catch (error) { // Critical: Device ID initialization failed // Fallback to session ID if critical error this.deviceId = this.getSessionId(); this.deviceIdReady = true; } } /** * Start simple fingerprint generation immediately as TOP PRIORITY (background process) */ startSimpleFingerprintGeneration() { // Start simple fingerprint generation in background this.generateSimpleFingerprintHash().catch((error) => { // Handle the failure silently }); } /** * Generate simple fingerprint using IP address + User Agent hash (Plausible-style) */ async generateSimpleFingerprintHash() { try { // Record timing const initStartTime = performance.now(); // Get IP address with timing const ipStartTime = performance.now(); const ipResult = await this.fetchIPAddress(); const ipEndTime = performance.now(); const ipFetchTime = ipEndTime - ipStartTime; // Get user agent const userAgent = navigator.userAgent; // Store location data in cookie and update instance variables if available if (ipResult.locationData) { this.storeLocationDataInCookie(ipResult.locationData); // Update instance location variables to avoid duplicate API calls this.ipAddress = ipResult.locationData.ip; this.locationInfo = { city: ipResult.locationData.city || null, region: ipResult.locationData.region || null, country: ipResult.locationData.country || null, country_code: ipResult.locationData.country_code || null, postal: ipResult.locationData.postal || null, loc: ipResult.locationData.latitude && ipResult.locationData.longitude ? `${ipResult.locationData.latitude},${ipResult.locationData.longitude}` : null, timezone: ipResult.locationData.timezone || null, }; this.locationReady = true; // Location data used for both fingerprint and location info } // Create fingerprint data object const fingerprintData = { ip_address: ipResult.ip, ip_api_used: ipResult.apiUsed, ip_fetch_time_ms: Math.round(ipFetchTime * 100) / 100, user_agent: userAgent, location_data_available: !!ipResult.locationData, location_source: ipResult.locationData?.source_api || null, timestamp: Date.now(), }; // Generate hash from IP + User Agent using MurmurHash3 const fingerprintString = `${ipResult.ip}${userAgent}`; const finalDeviceId = this.createMurmurHash(fingerprintString); // Calculate total generation time const totalGenerationTime = performance.now() - initStartTime; // Store the previous device ID for logging const previousDeviceId = this.deviceId; // Set the final device ID this.deviceId = finalDeviceId; this.deviceIdReady = true; this.setDeviceIdCookie(finalDeviceId); // Send visitor_generated event (separate from logging) this.sendFingerprintEvent(finalDeviceId, fingerprintData, totalGenerationTime); } catch (error) { // If fingerprint generation failed and didn't provide location data, // initialize location data separately as fallback if (!this.locationReady) { // Fingerprint generation failed, initializing location data separately this.initializeLocationData(); } // Don't throw error - let caller handle the failure gracefully throw error; } } /** * Fetch IP address with fallback APIs for high availability */ async fetchIPAddress() { // List of IP APIs in order of preference (unlimited > high limit > low limit) const ipApis = [ { url: 'https://ipwho.is/?output=json', extractIp: (data) => data.ip, extractLocation: (data) => this.extractIPWHOLocationData(data), name: 'IPWHO', hasLocationData: true, }, { url: 'https://ipinfo.io/json', extractIp: (data) => data.ip, extractLocation: (data) => this.extractIPInfoLocationData(data), name: 'IPinfo Lite', hasLocationData: true, }, { url: 'https://api.ipify.org/?format=json', extractIp: (data) => data.ip, extractLocation: () => null, name: 'ipify', hasLocationData: false, }, ]; for (const api of ipApis) { try { const response = await fetch(api.url, { method: 'GET', headers: { Accept: 'application/json', }, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); const ip = api.extractIp(data); if (!ip || typeof ip !== 'string') { throw new Error(`Invalid IP address response from ${api.name}`); } // Extract location data if available const locationData = api.hasLocationData ? api.extractLocation(data) : null; return { ip: ip.trim(), apiUsed: api.name, locationData, fullResponse: data, }; } catch (error) { console.warn(`Failed to fetch IP from ${api.name}:`, error); // Continue to next API } } throw new Error('Failed to fetch IP address from all available APIs'); } /** * Extract location data from IPWHO API response */ extractIPWHOLocationData(data) { try { return { ip: data.ip, country: data.country, country_code: data.country_code, region: data.region, region_code: data.region_code, city: data.city, postal: data.postal, latitude: data.latitude, longitude: data.longitude, timezone: data.timezone?.id || data.timezone, timezone_offset: data.timezone?.utc, isp: data.connection?.isp, org: data.connection?.org, asn: data.connection?.asn, continent: data.continent, continent_code: data.continent_code, source_api: 'IPWHO', timestamp: Date.now(), }; } catch (error) { console.warn('Failed to extract IPWHO location data:', error); return null; } } /** * Extract location data from IPinfo API response */ extractIPInfoLocationData(data) { try { // Parse loc field (latitude,longitude) const [latitude, longitude] = data.loc ? data.loc.split(',').map(Number) : [null, null]; return { ip: data.ip, country: null, // IPinfo doesn't provide full country name in free tier country_code: data.country, region: data.region, region_code: null, city: data.city, postal: data.postal, latitude, longitude, timezone: data.timezone, timezone_offset: null, isp: null, org: data.org, asn: null, continent: null, continent_code: null, source_api: 'IPinfo Lite', timestamp: Date.now(), }; } catch (error) { console.warn('Failed to extract IPinfo location data:', error); return null; } } /** * Store location data from IP API in cookie */ storeLocationDataInCookie(locationData) { try { // Use the existing setLocationInfoCookie method this.setLocationInfoCookie(locationData); // Location data stored in cookie } catch (error) { // Failed to store location data in cookie } } /** * Create hash from input string using MurmurHash3 */ createMurmurHash(input) { return this.murmurHash3(input, 0x9747b28c).toString(16).padStart(8, '0'); } /** * MurmurHash3 implementation for consistent, fast hashing * @param str - Input string to hash * @param seed - Hash seed (default: 0x9747b28c) * @returns 32-bit hash value */ murmurHash3(str, seed = 0x9747b28c) { let h1 = seed; let h1b; let k1; const remainder = str.length & 3; // str.length % 4 const bytes = str.length - remainder; let i = 0; const c1 = 0xcc9e2d51; const c2 = 0x1b873593; while (i < bytes) { k1 = (str.charCodeAt(i) & 0xff) | ((str.charCodeAt(++i) & 0xff) << 8) | ((str.charCodeAt(++i) & 0xff) << 16) | ((str.charCodeAt(++i) & 0xff) << 24); ++i; k1 = ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; k1 = (k1 << 15) | (k1 >>> 17); k1 = ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; h1 ^= k1; h1 = (h1 << 13) | (h1 >>> 19); h1b = ((h1 & 0xffff) * 5 + ((((h1 >>> 16) * 5) & 0xffff) << 16)) & 0xffffffff; h1 = (h1b & 0xffff) + 0x6b64 + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16); } k1 = 0; switch (remainder) { case 3: k1 ^= (str.charCodeAt(i + 2) & 0xff) << 16; case 2: k1 ^= (str.charCodeAt(i + 1) & 0xff) << 8; case 1: k1 ^= str.charCodeAt(i) & 0xff; k1 = ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; k1 = (k1 << 15) | (k1 >>> 17); k1 = ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; h1 ^= k1; } h1 ^= str.length; h1 ^= h1 >>> 16; h1 = ((h1 & 0xffff) * 0x85ebca6b + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff; h1 ^= h1 >>> 13; h1 = ((h1 & 0xffff) * 0xc2b2ae35 + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16)) & 0xffffffff; h1 ^= h1 >>> 16; return h1 >>> 0; } /** * 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) { // Could not store device ID in cookie } } /** * Initialize location data - uses fingerprint data if available, otherwise fetches separately */ async initializeLocationData() { try { // First check if we have cached data 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, }; // Using cached IP and location info from cookie this.locationReady = true; return; } // If no cache, wait a bit for fingerprint generation to complete and provide location data // No cached location data, waiting for fingerprint generation to provide location data // Wait up to 2 seconds for fingerprint generation to complete let attempts = 0; const maxAttempts = 20; // 20 * 100ms = 2 seconds const checkFingerprintLocation = () => { if (this.locationReady && this.ipAddress && this.locationInfo) { // Location data provided by fingerprint generation return; } attempts++; if (attempts < maxAttempts) { setTimeout(checkFingerprintLocation, 100); } else { // Fallback: fetch location data separately if fingerprint didn't provide it // Fingerprint generation did not provide location data, fetching separately this.fetchIpAndLocationInfo(); } }; setTimeout(checkFingerprintLocation, 100); } catch (error) { // Error in location data initialization this.locationReady = true; } } /** * Fetch IP address and location information with cookie caching * NOTE: This is now only used as a fallback when fingerprint generation doesn't provide location data */ async fetchIpAndLocationInfo() { try { // Check if location data was already set by fingerprint generation if (this.locationReady && this.ipAddress && this.locationInfo) { // Location data already available from fingerprint generation, skipping duplicate API call return; } // 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, }; // Using cached IP and location info from cookie this.locationReady = true; return; } // If no cached data and not set by fingerprint, fetch from fallback API // No cached location data found, fetching from fallback 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); this.locationReady = true; } catch (error) { // Failed to fetch IP and location info // Set fallback values this.ipAddress = null; this.locationInfo = null; this.locationReady = true; // Mark as ready even with fallback values } } /** * 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 2 year expiration (same as device ID cookie) const expiryDate = new Date(); expiryDate.setTime(expiryDate.getTime() + 730 * 24 * 60 * 60 * 1000); // 730 days const cookieValue = `${cookieName}=${encodedData};expires=${expiryDate.toUTCString()};path=/;SameSite=Lax`; document.cookie = cookieValue; // Location info cached in cookie } catch (e) { // Could not store location info in cookie } } /** * 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) { // Could not read location info from cookie // 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) { // Could not remove location info cookie } } /** * Initialize ThriveStack and start tracking */ async init(userId = '', source = '') { try { if (userId) { this.setUserId(userId); } if (source) { this.source = source; } } catch (error) { // Failed to initialize ThriveStack } } /** * Check if tracking is allowed based on consent settings */ shouldTrack() { // Always allow functional tracking 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) { // Could not store user ID in cookie } } /** * 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) { // Could not store group ID in cookie } } /** * 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; } /** * Get visitor generated sent status from cookie */ getVisitorGeneratedSentFromCookie() { const cookieName = 'thrivestack_visitor_generated_sent'; 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 value === 'true'; } } return false; } /** * Set visitor generated sent status in cookie */ setVisitorGeneratedSentCookie(sent) { const cookieName = 'thrivestack_visitor_generated_sent'; const expiryDate = new Date(); expiryDate.setTime(expiryDate.getTime() + 365 * 24 * 60 * 60 * 1000); // 1 year const cookieValue = `${cookieName}=${sent};expires=${expiryDate.toUTCString()};path=/;SameSite=Lax`; try { document.cookie = cookieValue; } catch (e) { // Could not store visitor generated sent status in cookie } } /** * Get last session ID from cookie */ getLastSessionIdFromCookie() { const cookieName = 'thrivestack_last_session_id'; 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 last session ID in cookie */ setLastSessionIdCookie(sessionId) { const cookieName = 'thrivestack_last_session_id'; const expiryDate = new Date(); expiryDate.setTime(expiryDate.getTime() + 365 * 24 * 60 * 60 * 1000); // 1 year const cookieValue = `${cookieName}=${encodeURIComponent(sessionId)};expires=${expiryDate.toUTCString()};path=/;SameSite=Lax`; try { document.cookie = cookieValue; } catch (e) { // Could not store last session ID in cookie } } /** * Queue an event for batched sending */ queueEvent(events) { // Handle both single events and arrays if (!Array.isArray(events)) { events = [events]; } // Send immediately; device_id will be finalized at API send time in track() this.track(events).catch((error) => { console.error('Failed to send events:', error); }); } /** * Track events by sending to ThriveStack API */ async track(events) { if (!this.apiKey) { throw Error('Initialize the ThriveStack instance before sending telemetry data.'); } // Check if tracking is enabled based on subscription status // Allow visitor_generated events even when tracking is disabled const allowedEvents = events.filter(event => this.isTrackingEnabled || event.event_name === 'visitor_generated'); if (allowedEvents.length === 0) { console.debug('Tracking disabled due to subscription status, skipping events'); return { success: false, reason: 'tracking_disabled' }; } // Use only allowed events events = allowedEvents; // Finalize device_id at send time (use device ID from cookie if available, else session id) const sessionIdForSend = this.getSessionId(); const deviceIdFromCookie = this.getDeviceIdFromCookie(); const effectiveDeviceIdForSend = deviceIdFromCookie || sessionIdForSend; const eventsWithDevice = events.map((event) => ({ ...event, context: { ...(event.context || {}), device_id: event.context && event.context.device_id ? event.context.device_id : effectiveDeviceIdForSend, session_id: event.context && event.context.session_id ? event.context.session_id : sessionIdForSend, }, // Normalize timestamp format for ClickHouse DateTime64(3) compatibility timestamp: new Date().toISOString(), })); // Clean events of PII before sending const cleanedEvents = eventsWithDevice.map((event) => this.cleanPIIFromEventData(event)); // Add retry logic for network errors 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()}`); } // Handle both 200 and 204 responses if (response.status === 204) { // 204 No Content - success but no response body return { success: true, status: 'no_content' }; } else { // 200 OK - parse JSON response const data = await response.json(); return data; } } catch (error) { // Failed to send telemetry throw error; } } /** * Send user identification data */ async identify(data) { if (!this.apiKey) { throw Error('Initialize the ThriveStack instance before sending telemetry data.'); } // Check if tracking is enabled based on subscription status if (!this.isTrackingEnabled) { console.debug('Tracking disabled due to subscription status, skipping events'); return { success: false, reason: 'tracking_disabled' }; } 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); } // Add context data to identify requests const sessionId = this.getSessionId(); const effectiveDeviceId = this.deviceId || this.getDeviceIdFromCookie(); const currentGroupId = this.groupId || this.getGroupIdFromCookie() || ''; // Process data to ensure context is included const dataWithContext = Array.isArray(data) ? data.map(item => ({ ...item, context: { ...(item.context || {}), device_id: (item.context && item.context.device_id) || effectiveDeviceId, session_id: (item.context && item.context.session_id) || sessionId, group_id: (item.context && item.context.group_id) || currentGroupId, source: (item.context && item.context.source) || this.source } })) : { ...data, context: { ...(data.context || {}), device_id: (data.context && data.context.device_id) || effectiveDeviceId, session_id: (data.context && data.context.session_id) || sessionId, group_id: (data.context && data.context.group_id) || currentGroupId, source: (data.context && data.context.source) || this.source } }; // 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(dataWithContext), }); 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.'); } // Check if tracking is enabled based on subscription status if (!this.isTrackingEnabled) { console.debug('Tracking disabled due to subscription status, skipping events'); return { success: false, reason: 'tracking_disabled' }; } 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); } // Add context data to group requests const sessionId = this.getSessionId(); const effectiveDeviceId = this.deviceId || this.getDeviceIdFromCookie(); const currentGroupId = this.groupId || this.getGroupIdFromCookie() || ''; // Process data to ensure context is included const dataWithContext = Array.isArray(data) ? data.map(item => ({ ...item, context: { ...(item.context || {}), device_id: (item.context && item.context.device_id) || effectiveDeviceId, session_id: (item.context && item.context.session_id) || sessionId, group_id: (item.context && item.context.group_id) || currentGroupId, source: (item.context && item.context.source) || this.source } })) : { ...data, context: { ...(data.context || {}), device_id: (data.context && data.context.device_id) || effectiveDeviceId, session_id: (data.context && data.context.session_id) || sessionId, group_id: (data.context && data.context.group_id) || currentGroupId, source: (data.context && data.context.source) || this.source } }; // Send data to API const response = await fetch(`${this.apiEndpoint}/group`, { method: 'POST', headers: { 'Content-Type': 'application/json',