@thrivestack/analytics-browser
Version:
ThriveStack Analytics Platform - Comprehensive web analytics tracking with privacy-first approach
1,236 lines (1,235 loc) • 83.3 kB
JavaScript
/**
* 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',