@thrivestack/analytics-browser
Version:
ThriveStack Analytics Platform - Comprehensive web analytics tracking with privacy-first approach
2,077 lines • 87.9 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ThriveStack = {}));
})(this, (function (exports) { 'use strict';
/**
* 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://api.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',
'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 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));
// Set cookie with 1 year expiration (same as other tracking cookies)
const expiryDate = new Date();
expiryDate.setTime(expiryDate.getTime() + 365 * 24 * 60 * 60 * 1000); // 1 year
const cookieValue = `${sessionCookieName}=${encodedData};expires=${expiryDate.toUTCString()};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);
}
}
/**
* Check if element has ThriveStack event class
*/
hasThriveStackEventClass(element) {
if (!element || !element.className)
return undefined;
// Handle both string and DOMTokenList className
const classList = typeof element.className === 'string'
? element.className.split(/\s+/)
: Array.from(element.classList);
return classList.find((className) => className.includes('thrivestack-event'));
}
/**
* Capture page visit event
*/
async capturePageVisit() {
// Check if we need to send visitor_generated for new session
if (this.shouldSendVisitorGeneratedForNewSession()) {
const deviceIdFromCookie = this.getDeviceIdFromCookie();
if (deviceIdFromCookie) {
// Send visitor_generated event for new session with existing device
this.sendFingerprintEventFromCache(deviceIdFromCookie);
}
}
// Allow first page visit and visitor_generated events regardless of subscription status
// Only block subsequent page visits if subscription is disabled
if (this.firstPageVisitCaptured && !this.isTrackingEnabled) {
console.debug('Subsequent page visit tracking disabled due to subscription status');
return;
}
// Get UTM parameters if marketing consent is given
const utmParams = this.getUtmParameters();
// Validate session first, then update activity
const sessionId = this.getSessionId();
this.updateSessionActivity();
const effectiveDeviceId = this.getDeviceIdFromCookie() || sessionId;
// 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 || "",
language: navigator.language || "",
ip_address: this.ipAddress || "",
city: this.locationInfo?.city || "",
region: this.locationInfo?.region || "",
country: this.locationInfo?.country || "",
postal: this.locationInfo?.postal || "",
loc: this.locationInfo?.loc || "", // Format: "lat,long"
timezone: this.locationInfo?.timezone || "",
...utmParams,
},
user_id: currentUserId,
context: {
group_id: currentGroupId,
device_id: effectiveDeviceId,
session_id: sessionId,
source: this.source, // Add source to context
},
timestamp: new Date().toISOString(),
},
];
// Queue event instead of sending immediately
this.queueEvent(events);
// Mark first page visit as captured and update session tracking
this.firstPageVisitCaptured = true;
const currentSessionId = this.getSessionId();
this.lastSessionId = currentSessionId;
// Save to cookies
this.setLastSessionIdCookie(currentSessionId);
}
/**
* Capture element click event
*/
captureClickEvent(event) {
// Check if tracking is enabled based on subscription status
if (!this.isTrackingEnabled) {
console.debug('Click event tracking disabled due to subscription status');
return;
}
const target = event.target;
const thrviestackClass = this.hasThriveStackEventClass(target);
// NEW: Check if element has thrivestack-event class
if (!thrviestackClass) {
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 UTM parameters if marketing consent is given
const utmParams = this.getUtmParameters();
// Validate session first, then update activity
const sessionId = this.getSessionId();
this.updateSessionActivity();
const effectiveDeviceId = this.getDeviceIdFromCookie() || sessionId;
// 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,
activity: thrviestackClass.split(':')[1] || thrviestackClass || null, // Capture the class name
...utmParams,
},
user_id: currentUserId,
context: {
group_id: currentGroupId,
device_id: effectiveDeviceId,
session_id: sessionId,
source: this.source, // Add source to context
},
timestamp: new Date().toISOString(),
},
];
// Queue event instead of sending immediately
this.queueEvent(events);
}
/**
* Capture form events (submission, abandonment)
*/
captureFormEvent(event, type) {
// Check if tracking is enabled based on subscription status
if (!this.isTrackingEnabled) {
console.debug('Form event tracking disabled due to subscription status');
return;
}
const form = event.target;
// Validate session first, then update activity
const sessionId = this.getSessionId();
this.updateSessionActivity();
const effectiveDeviceId = this.getDeviceIdFromCookie() || sessionId;
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: effectiveDeviceId,
session_id: sessionId,
source: this.source, // Add source to context
},
timestamp: new Date().toISOString(),
},
];
// Queue event instead of sending immediately
this.queueEvent(events);
}
/**
* Set up automatic page visit tracking
*/
autoCapturePageVisit() {
// Check if tracking is enabled based on subscription status
if (!this.isTrackingEnabled) {
console.debug('Auto page visit tracking disabled due to subscription status');
return;
}
// Initial page visit will be captured when script loads, not on window.load
// 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();
};
}
/**
* Set up automatic click event tracking
*/
autoCaptureClickEvents() {
// Don't set up auto-tracking if subscription is disabled
if (!this.isTrackingEnabled) {
console.debug('Auto click tracking disabled due to subscription status');
return;
}
document.addEventListener('click', (event) => this.captureClickEvent(event));
}
/**
* Set up automatic form event tracking
*/
autoCaptureFormEvents() {
// Don't set up auto-tracking if subscription is disabled
if (!this.isTrackingEnabled) {
console.debug('Auto form tracking disabled due to subscription status');
return;
}
// 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');
}
});
}
});
}
/**
* 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,
context: {
device_id: '',
session_id: '',
group_id: '',
source: ''
},
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,
context: {
device_id: '',
session_id: '',
group_id: '',
source: ''
},
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;
}
}
/**
* Public method to report email abuse
* @param email - The email address to report for abuse
* @returns Promise<EmailAbuseResponse> - Response from the abuse API
*/
async reportEmailAbuse(email) {
try {
// Validate email parameter
if (!email || typeof email !== 'string' || email.trim() === '') {
throw new Error('Email address is required and must be a non-empty string');
}
const trimmedEmail = email.trim();
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmedEmail)) {
throw new Error('Invalid email address format');
}
// Email abuse report (developer logs disabled)
// Prepare the API endpoint
// Handle both '/api/track' and '/api' endpoints
let abuseApiEndpoint;
if (this.apiEndpoint.endsWith('/api/track')) {
abuseApiEndpoint = this.apiEndpoint.replace('/api/track', '/api/email-abuse');
}
else if (this.apiEndpoint.endsWith('/api')) {
abuseApiEndpoint = this.apiEndpoint + '/email-abuse';
}
else {
// Fallback: assume it's a base URL and append the path
abuseApiEndpoint =
this.apiEndpoint.replace(/\/$/, '') + '/api/email-abuse';
}
// Make the API call
const response = await fetch(abuseApiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
},
body: JSON.stringify({
email: trimmedEmail,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Email abuse API failed: ${response.status} ${response.statusText} - ${errorText}`);
}
const result = await response.json();
// Email abuse reported successfully (developer logs disabled)
// Interpret the response
const isAbuse = result.isAbuse === 1;
const reasons = result.reasonForAbuse;
let message = isAbuse
? `Email marked as abuse. Reasons: ${Object.entries(reasons)
.filter(([_, value]) => value === true)
.map(([key, _]) => key)
.join(', ') || 'General abuse detected'}`
: 'Email is not flagged as abuse';
return {
success: true,
message: message,
data: result,
};
}
catch (error) {
// Failed to report email abuse (developer logs disabled)
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
/**
* 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');
}
}
// Removed startLocationEnhancement - location fetching now handled by ThriveStack instance
// Removed capturePageVisitWithEnhancement - no longer needed with new blocking architecture
/**
* Auto-initialize ThriveStack when script loads via CDN
*/
(function () {
// Only run CDN initialization if this is loaded as a script tag, not as a module
if (typeof document === 'undefined' || typeof window === 'undefined') {
return; // Not in browser environment
}
// Check if this is loaded as a script tag (CDN usage) vs module import
const script = document.currentScript ||
Array.from(document.getElementsByTagName('script')).pop();
// If no script tag found, this is likely a module import - skip CDN initialization
if (!script) {
return;
}
// Extract script configuration
const apiKey = script?.getAttribute('data-api-key') || script?.getAttribute('api-key');
const source = script?.getAttribute('data-source') || script?.getAttribute('source');
if (!apiKey || !source) {
if (!apiKey) {
console.error('Missing required API key for ThriveStack initialization. Use data-api-key or api-key attribute.');
}
if (!source) {
console.error('Missing required source for ThriveStack initialization. Use data-source or source attribute.');
}
return;
}
// Create ThriveStack instance immediately - fingerprint generation starts in constructor
console.debug('Creating ThriveStack instance with fingerprint priority...');
const thriveStack = new ThriveStack({
apiKey: apiKey,
trackClicks: script?.getAttribute('data-track-clicks') !== 'false' &&
script?.getAttribute('track-clicks') !== 'false',
trackForms: script?.getAttribute('data-track-forms') === 'true' ||
script?.getAttribute('track-forms') === 'true',
enableDeveloperLogs: script?.getAttribute('data-enable-developer-logs') === 'true' ||
script?.getAttribute('enable-developer-logs') === 'true',
source: source,
});
// Initialize immediately
thriveStack.init().catch((error) => {
console.error('Failed to initialize ThriveStack:', error);
});
// Make instance available globally
window.thriveStack = thriveStack;
console.debug('ThriveStack initialization complete (fingerprint generation started)');
})();
// 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]);
}
/**
* Track multiple events using array type (ThriveStackEvent[])
* @param events - Array of ThriveStackEvent objects
*/
async function trackEvents(events) {
if (!thrivestackInstance) {
throw new Error('ThriveStack not initialized. Call init() first.');
}
return await thrivestackInstance.track(events);
}
/**
* 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;
exports.trackEvents = trackEvents;
}));
//# sourceMappingURL=thrivestack.js.map