UNPKG

thrivestack-analytics

Version:

ThriveStack Analytics - Privacy-first web analytics for Node.js and browsers

914 lines (913 loc) 36.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ThriveStack = void 0; const node_fetch_1 = __importDefault(require("node-fetch")); //import { v4 as uuidv4 } from 'uuid'; const os = __importStar(require("os")); const crypto = __importStar(require("crypto")); class ThriveStack { constructor(options) { // IP and location information storage this.ipAddress = null; this.locationInfo = null; // Event batching this.eventQueue = []; this.queueTimer = null; // Analytics state this.interactionHistory = []; this.maxHistoryLength = 20; // User and group management this.userId = ''; this.groupId = ''; // Device ID management this.deviceId = null; this.deviceIdReady = false; this.sessionUpdateTimer = null; // Debug mode this.debugMode = false; // Handle string API key for backward compatibility if (typeof options === 'string') { options = { apiKey: options }; } // Core settings this.apiKey = options.apiKey; this.apiEndpoint = options.apiEndpoint || "https://api.app.thrivestack.ai/api"; this.respectDoNotTrack = options.respectDoNotTrack !== false; this.trackClicks = options.trackClicks === true; 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 = options.geoIpServiceUrl || "https://ipinfo.io/json"; // Event batching this.batchSize = options.batchSize || 10; this.batchInterval = options.batchInterval || 2000; // Session configuration this.sessionTimeout = options.sessionTimeout || (30 * 60 * 1000); // 30 minutes this.debounceDelay = options.debounceDelay || 2000; // 2 seconds // Consent settings (default to functional only) this.consentCategories = { functional: true, // Always needed analytics: options.defaultConsent === true, marketing: options.defaultConsent === true }; // Initialize device ID this.initializeDeviceId(); // Fetch IP and location data on initialization this.fetchIpAndLocationInfo(); // Setup session tracking (browser only) this.setupSessionTracking(); // Initialize automatically if tracking is allowed and in browser if (this.shouldTrack() && typeof window !== 'undefined') { this.autoCapturePageVisit(); if (this.trackClicks) { this.autoCaptureClickEvents(); } if (this.trackForms) { this.autoCaptureFormEvents(); } } } /** * Initialize device ID with proper fallback logic */ initializeDeviceId() { return __awaiter(this, void 0, void 0, function* () { try { // Generate a unique device ID for Node.js environment this.deviceId = this.generateRandomDeviceId(); this.deviceIdReady = true; console.debug("Generated device ID:", this.deviceId); // Process any queued events now that device ID is ready this.processQueueIfReady(); } catch (error) { console.warn("Failed to initialize device ID:", error); // Fallback to random device ID this.deviceId = this.generateRandomDeviceId(); this.deviceIdReady = true; console.debug("Using fallback random device ID:", this.deviceId); // Process any queued events now that device ID is ready this.processQueueIfReady(); } }); } /** * Generate a random device ID as fallback */ generateRandomDeviceId() { return 'device_' + crypto.randomBytes(16).toString('hex'); } /** * Fetch IP address and location information */ fetchIpAndLocationInfo() { return __awaiter(this, void 0, void 0, function* () { try { console.debug("Fetching IP and location info from API..."); const response = yield (0, node_fetch_1.default)(this.geoIpServiceUrl); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } const data = yield 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, timezone: data.timezone || null }; console.debug("IP and location info fetched successfully"); } catch (error) { console.warn("Failed to fetch IP and location info:", error); // Set fallback values this.ipAddress = null; this.locationInfo = null; } }); } /** * Initialize ThriveStack and start tracking */ init() { return __awaiter(this, arguments, void 0, function* (userId = "", source = "") { try { if (userId) { this.setUserId(userId); } if (source) { this.source = source; } console.debug("ThriveStack initialized successfully"); } catch (error) { console.error("Failed to initialize ThriveStack:", error); throw error; } }); } /** * Check if tracking is allowed based on DNT and consent settings */ shouldTrack() { // Check Do Not Track setting if enabled and in browser if (this.respectDoNotTrack && typeof navigator !== 'undefined' && (navigator.doNotTrack === "1" || navigator.doNotTrack === "yes" || (typeof window !== 'undefined' && window.doNotTrack === "1"))) { console.warn("User has enabled Do Not Track. Tracking is disabled."); return false; } 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; } /** * Set group ID for tracking */ setGroupId(groupId) { this.groupId = groupId; } /** * Get userId from cookie (browser only) */ getUserIdFromCookie() { if (typeof document === 'undefined') return null; const cookieName = 'thrivestack_user_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; } /** * Get groupId from cookie (browser only) */ getGroupIdFromCookie() { if (typeof document === 'undefined') return null; const cookieName = 'thrivestack_group_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 source for tracking */ setSource(source) { this.source = source; } /** * 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() { return __awaiter(this, void 0, void 0, function* () { if (this.eventQueue.length === 0 || !this.deviceIdReady) return; const events = [...this.eventQueue]; const updatedEvents = events.map(event => (Object.assign(Object.assign({}, event), { context: Object.assign(Object.assign({}, event.context), { device_id: this.deviceId }) }))); this.eventQueue = []; if (this.queueTimer) { clearTimeout(this.queueTimer); this.queueTimer = null; } try { yield 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 */ track(events) { return __awaiter(this, void 0, void 0, function* () { if (!this.apiKey) { throw new 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 = yield (0, node_fetch_1.default)(`${this.apiEndpoint}/track`, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": `${this.apiKey}` }, body: JSON.stringify(cleanedEvents) }); if (!response.ok) { throw new Error(`HTTP error ${response.status}: ${yield response.text()}`); } const data = yield response.json(); return data; } catch (error) { retries--; if (retries === 0) { console.error("Failed to send telemetry after multiple attempts:", error); throw error; } // Wait before retrying (exponential backoff) yield new Promise(resolve => setTimeout(resolve, 1000 * (3 - retries))); } } }); } /** * Send user identification data */ identify(data) { return __awaiter(this, void 0, void 0, function* () { if (!this.apiKey) { throw new 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 { userId = data.user_id || ""; } // Set userId in instance if (userId) { this.setUserId(userId); } // Send data to API const response = yield (0, node_fetch_1.default)(`${this.apiEndpoint}/identify`, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": `${this.apiKey}` }, body: JSON.stringify(data) }); if (!response.ok) { throw new Error(`HTTP error ${response.status}: ${yield response.text()}`); } const result = yield response.json(); return result; } catch (error) { console.error("Failed to send identification data:", error); throw error; } }); } /** * Send group data */ group(data) { return __awaiter(this, void 0, void 0, function* () { if (!this.apiKey) { throw new 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 { groupId = data.group_id || ""; } // Store groupId in instance if (groupId) { this.setGroupId(groupId); } // Send data to API const response = yield (0, node_fetch_1.default)(`${this.apiEndpoint}/group`, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": `${this.apiKey}` }, body: JSON.stringify(data) }); if (!response.ok) { throw new Error(`HTTP error ${response.status}: ${yield response.text()}`); } const result = yield response.json(); return result; } catch (error) { console.error("Failed to send group data:", error); throw error; } }); } /** * Get device ID */ getDeviceId() { if (this.deviceIdReady && this.deviceId) { return this.deviceId; } return null; } /** * Get session ID */ getSessionId() { const sessionId = 'session_' + crypto.randomBytes(16).toString('hex'); return sessionId; } /** * 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() { // In Node.js, we don't persist sessions the same way as browsers // This is a simplified implementation console.debug("Session activity updated"); } /** * Capture page visit event (works in both Node.js and browser) */ capturePageVisit(pageInfo) { return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c, _d, _e, _f; 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"); } // Validate session first, then update activity const sessionId = this.getSessionId(); this.updateSessionActivity(); // Get user and group IDs const currentUserId = this.userId || ""; const currentGroupId = this.groupId || ""; // Get UTM parameters if marketing consent is given const utmParams = this.isTrackingAllowed('marketing') ? this.getUtmParameters() : {}; // Build event with IP and location info const events = [{ event_name: "page_visit", properties: Object.assign({ page_title: (pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.title) || (typeof document !== 'undefined' ? document.title : "Node.js Application"), page_url: (pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.url) || (typeof window !== 'undefined' ? window.location.href : "http://localhost"), page_path: (pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.path) || (typeof window !== 'undefined' ? window.location.pathname : "/"), page_referrer: (pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.referrer) || (typeof document !== 'undefined' ? document.referrer : null), language: typeof navigator !== 'undefined' ? navigator.language : "en-US", ip_address: this.ipAddress, city: ((_a = this.locationInfo) === null || _a === void 0 ? void 0 : _a.city) || null, region: ((_b = this.locationInfo) === null || _b === void 0 ? void 0 : _b.region) || null, country: ((_c = this.locationInfo) === null || _c === void 0 ? void 0 : _c.country) || null, postal: ((_d = this.locationInfo) === null || _d === void 0 ? void 0 : _d.postal) || null, loc: ((_e = this.locationInfo) === null || _e === void 0 ? void 0 : _e.loc) || null, timezone: ((_f = this.locationInfo) === null || _f === void 0 ? void 0 : _f.timezone) || null, platform: typeof os !== 'undefined' ? os.platform() : (typeof navigator !== 'undefined' ? navigator.platform : 'unknown'), arch: typeof os !== 'undefined' ? os.arch() : 'unknown', node_version: typeof process !== 'undefined' ? process.version : null }, utmParams), user_id: currentUserId, context: { group_id: currentGroupId, device_id: deviceId, session_id: sessionId, source: this.source }, timestamp: new Date().toISOString(), }]; // Queue event instead of sending immediately this.queueEvent(events); // Add to interaction history this.addToInteractionHistory('page_visit', events[0].properties); }); } /** * Capture custom event */ captureEvent(eventName_1) { return __awaiter(this, arguments, void 0, function* (eventName, properties = {}) { if (!this.isTrackingAllowed('analytics')) { return; } const deviceId = this.getDeviceId(); if (!deviceId) { console.debug("Device ID not ready, event will be queued"); } const sessionId = this.getSessionId(); this.updateSessionActivity(); const currentUserId = this.userId || ""; const currentGroupId = this.groupId || ""; const events = [{ event_name: eventName, properties: Object.assign(Object.assign({}, properties), { platform: os.platform(), arch: os.arch(), node_version: process.version }), user_id: currentUserId, context: { group_id: currentGroupId, device_id: deviceId, session_id: sessionId, source: this.source }, timestamp: new Date().toISOString(), }]; this.queueEvent(events); this.addToInteractionHistory(eventName, events[0].properties); }); } /** * Add event to interaction history */ addToInteractionHistory(type, details) { const interaction = { type: type, details: details, timestamp: new Date().toISOString(), sequence: this.interactionHistory.length + 1 }; this.interactionHistory.push(interaction); // Trim if necessary if (this.interactionHistory.length > this.maxHistoryLength) { this.interactionHistory.shift(); } } /** * 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 */ setUser(userId_1, emailId_1) { return __awaiter(this, arguments, void 0, function* (userId, emailId, properties = {}) { if (!userId) { console.warn("setUser: userId is required"); return null; } // Always update local state this.setUserId(userId); try { // Prepare identify payload const identifyData = [{ user_id: userId, traits: Object.assign({ user_email: emailId, user_name: emailId }, properties), timestamp: new Date().toISOString() }]; console.debug("Making identify API call for user:", userId); const result = yield this.identify(identifyData); console.debug("Identify API call successful"); return result; } catch (error) { console.error("Failed to make identify API call:", error); throw error; } }); } /** * Set group information and optionally make group API call */ setGroup(groupId_1, groupDomain_1, groupName_1) { return __awaiter(this, arguments, void 0, function* (groupId, groupDomain, groupName, properties = {}) { if (!groupId) { console.warn("setGroup: groupId is required"); return null; } // Always update local state this.setGroupId(groupId); try { // Prepare group payload const groupData = [{ group_id: groupId, user_id: this.userId, traits: Object.assign({ 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 = yield this.group(groupData); console.debug("Group API call successful"); return result; } catch (error) { console.error("Failed to make group API call:", error); throw error; } }); } /** * Enable debug mode for troubleshooting */ enableDebugMode() { this.debugMode = true; // Override track method to log events const originalTrack = this.track.bind(this); this.track = function (events) { return __awaiter(this, void 0, void 0, function* () { 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'); } /** * Get interaction history */ getInteractionHistory() { return [...this.interactionHistory]; } /** * Get current location info */ getLocationInfo() { return this.locationInfo; } /** * Get current IP address */ getIpAddress() { return this.ipAddress; } /** * Get UTM parameters from URL (browser only) */ getUtmParameters() { if (typeof window === 'undefined') { return {}; } 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 }; } /** * Auto-capture page visits (browser only) */ autoCapturePageVisit() { if (typeof window === 'undefined') return; // 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(); }; } /** * Auto-capture click events (browser only) */ autoCaptureClickEvents() { if (typeof document === 'undefined') return; document.addEventListener("click", (event) => this.captureClickEvent(event)); } /** * Capture click event (browser only) */ captureClickEvent(event) { var _a; if (typeof document === 'undefined' || !this.isTrackingAllowed('analytics')) { return; } const now = Date.now(); if (this.lastClickTime && now - this.lastClickTime < 300) { return; } this.lastClickTime = now; const target = event.target; const position = target.getBoundingClientRect(); const deviceId = this.getDeviceId(); if (!deviceId) { console.debug("Device ID not ready, click event will be queued"); } const utmParams = this.isTrackingAllowed('marketing') ? this.getUtmParameters() : {}; const sessionId = this.getSessionId(); this.updateSessionActivity(); const currentUserId = this.userId || this.getUserIdFromCookie() || ""; const currentGroupId = this.groupId || this.getGroupIdFromCookie() || ""; const events = [{ event_name: "element_click", properties: Object.assign({ page_title: document.title, page_url: window.location.href, element_text: ((_a = target.textContent) === null || _a === void 0 ? void 0 : _a.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_hierarchy: this.getElementHierarchy(target), element_position_left: position.left || null, element_position_top: position.top || null, element_selector: this.getElementSelector(target), viewport_height: window.innerHeight, viewport_width: window.innerWidth, referrer: document.referrer || null }, utmParams), user_id: currentUserId, context: { group_id: currentGroupId, device_id: deviceId, session_id: sessionId, source: this.source }, timestamp: new Date().toISOString(), }]; this.queueEvent(events); this.addToInteractionHistory('element_click', events[0].properties); } /** * Get element hierarchy for DOM traversal (browser only) */ getElementHierarchy(element) { if (!this._hierarchyCache) { this._hierarchyCache = new WeakMap(); } if (this._hierarchyCache.has(element)) { return this._hierarchyCache.get(element); } let path = []; let currentElement = element; while (currentElement && currentElement.parentElement) { 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(" > "); this._hierarchyCache.set(element, result); return result; } /** * Get CSS selector for element (browser only) */ 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}`; } /** * Auto-capture form events (browser only) */ autoCaptureFormEvents() { if (typeof document === 'undefined') return; // Track form submissions document.addEventListener('submit', event => { this.captureFormEvent(event, 'submit'); }); // Track form field interactions document.addEventListener('input', event => { if (event.target.form) { const form = event.target.form; if (!form._trackingData) { form._trackingData = { startTime: Date.now(), filledFields: new Set() }; } 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') { document.querySelectorAll('form').forEach(form => { if (form._trackingData && form._trackingData.filledFields.size > 0) { const event = { target: form }; this.captureFormEvent(event, 'abandoned'); } }); } }); } /** * Capture form events (browser only) */ captureFormEvent(event, type) { if (typeof document === 'undefined' || !this.isTrackingAllowed('analytics')) { return; } const form = event.target; const deviceId = this.getDeviceId(); if (!deviceId) { console.debug("Device ID not ready, form event will be queued"); } const sessionId = this.getSessionId(); this.updateSessionActivity(); const currentUserId = this.userId || this.getUserIdFromCookie() || ""; const currentGroupId = this.groupId || this.getGroupIdFromCookie() || ""; let 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, session_id: sessionId, source: this.source }, timestamp: new Date().toISOString(), }]; this.queueEvent(events); this.addToInteractionHistory(`form_${type}`, events[0].properties); } /** * Setup session tracking (browser only) */ setupSessionTracking() { // Session tracking is handled by capture functions } } exports.ThriveStack = ThriveStack;