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