UNPKG

@cruxstack/browser-sdk

Version:

A lightweight, privacy-focused JavaScript SDK for web analytics and event tracking. Built with TypeScript, featuring automatic event capture, event-time environment snapshots, intelligent queuing, and robust error handling.

1,410 lines (1,394 loc) 55.9 kB
let debugEnabled = false; function setDebugLog(enabled) { debugEnabled = !!enabled; } function debug(message, extra) { if (!debugEnabled) return; if (extra !== undefined) { console.log("Cruxstack:", message, extra); } else { console.log("Cruxstack:", message); } } function error(message, err) { if (err instanceof Error) { console.error("Cruxstack:", message, err); } else if (err !== undefined) { console.error("Cruxstack:", message, String(err)); } else { console.error("Cruxstack:", message); } } function formatErrorMessage(context, err) { const base = `Cruxstack: ${context}`; if (err instanceof Error) return `${base}: ${err.message}`; try { return `${base}: ${String(err)}`; } catch { return base; } } // Session configuration const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes of inactivity const MAX_SESSION_DURATION = 4 * 60 * 60 * 1000; // 4 hours maximum session const SESSION_STORAGE_KEY = 'cruxstack_session'; // Memory storage fallback class MemoryStorage { constructor() { this.data = new Map(); } getItem(key) { return this.data.get(key) || null; } setItem(key, value) { this.data.set(key, value); } removeItem(key) { this.data.delete(key); } } // Storage detection with fallbacks function getStorage() { // Try sessionStorage first try { sessionStorage.setItem('test', '1'); sessionStorage.removeItem('test'); return sessionStorage; } catch (e) { // Fallback to memory storage return new MemoryStorage(); } } class SessionManager { constructor(config) { this.config = config; this.storage = getStorage(); this.debugLog = config.debugLog || false; // Warn if using memory storage if (this.storage instanceof MemoryStorage && this.debugLog) { console.warn('Cruxstack: Using memory storage fallback. Sessions will not persist across page reloads.'); } } getSessionId() { const sessionData = this.getSessionData(); const now = Date.now(); if (!sessionData || this.shouldExpireSession(sessionData, now)) { return this.createNewSession(); } // Update last activity sessionData.lastActivity = now; this.saveSessionData(sessionData); return sessionData.id; } getUserId() { // Always use userId from config, never store it return this.config.userId; } resetSession() { this.storage.removeItem(SESSION_STORAGE_KEY); if (this.debugLog) { console.log('Cruxstack: Session reset successfully'); } } getSessionData() { try { const data = this.storage.getItem(SESSION_STORAGE_KEY); return data ? JSON.parse(data) : null; } catch (e) { if (this.debugLog) { console.error('Cruxstack: Error reading session data:', e); } return null; } } saveSessionData(sessionData) { try { this.storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessionData)); } catch (e) { if (this.debugLog) { console.error('Cruxstack: Error saving session data:', e); } } } shouldExpireSession(sessionData, now) { const sessionAge = now - sessionData.startTime; const timeSinceLastActivity = now - sessionData.lastActivity; // Expire if session is too old OR user has been inactive return sessionAge > MAX_SESSION_DURATION || timeSinceLastActivity > SESSION_DURATION; } createNewSession() { const now = Date.now(); const sessionData = { id: this.generateId(), startTime: now, lastActivity: now }; this.saveSessionData(sessionData); if (this.debugLog) { console.log('Cruxstack: New session created:', sessionData.id); } return sessionData.id; } generateId() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } } const BATCH_SIZE = 10; const QUEUE_KEY = 'cruxstack'; const MAX_QUEUE_SIZE = 1000; // Maximum events to store class EventQueue { constructor() { this.queue = []; this.loadFromStorage(); } add(event) { // Prevent queue from growing too large if (this.queue.length >= MAX_QUEUE_SIZE) { this.queue.shift(); // Remove oldest event } this.queue.push(event); this.saveToStorage(); } getBatch() { const batch = this.queue.splice(0, BATCH_SIZE); this.saveToStorage(); return batch; } remove(eventId) { this.queue = this.queue.filter(e => e.id !== eventId); this.saveToStorage(); } // Debug method to check queue status getQueueStatus() { return { length: this.queue.length, events: this.queue }; } // Debug method to clear queue clearQueue() { this.queue = []; this.saveToStorage(); } addMany(events) { for (const e of events) this.add(e); } saveToStorage() { try { // Only persist events that need to be sent const queueData = JSON.stringify(this.queue); localStorage.setItem(QUEUE_KEY, queueData); } catch (e) { console.error('EventQueue: Storage failed:', e); if (e instanceof DOMException && e.name === 'QuotaExceededError') { // Handle storage full error by rotating oldest events this.queue = this.queue.slice(-Math.floor(MAX_QUEUE_SIZE / 2)); this.saveToStorage(); } else { console.error('Queue storage failed', e); } } } loadFromStorage() { try { const stored = localStorage.getItem(QUEUE_KEY); this.queue = stored ? JSON.parse(stored) : []; } catch (e) { console.error('EventQueue: Load failed:', e); this.queue = []; } } } class ApiClient { constructor(clientId, customerId, customerName, debugLog = false) { this.ipAddress = null; this.ipFetchInFlight = null; this.endpoint = "https://api.cruxstack.com/api/v1/events"; this.debugLog = debugLog; setDebugLog(this.debugLog); this.clientId = clientId; this.customerId = customerId; this.customerName = customerName; // Eagerly resolve IP address client-side (best-effort) this.fetchIpAddress(); } // GET request method async get(path, params = {}, options = {}) { try { // Build URL with query parameters let url = `https://api.cruxstack.com/backend/v1/${path}`; if (Object.keys(params).length > 0) { const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { searchParams.append(key, String(value)); } }); url += `?${searchParams.toString()}`; } // Build headers const headers = { "Content-Type": "application/json", "x-client-id": this.clientId, ...options.customHeaders, }; // Add customerId to headers if available if (this.customerId) { headers["x-customer-id"] = this.customerId; } // Add userId to headers if provided if (options.userId) { headers["x-user-id"] = options.userId; } const requestOptions = { method: "POST", //need to change this to GET. headers, }; debug("GET Request", { url, headers }); const response = await fetch(url, requestOptions); return await this.handleResponse(response); } catch (error$1) { error("GET Request Failed", error$1); throw new Error(formatErrorMessage("GET request failed", error$1)); } } // POST request method async post(path, data = {}, options = {}) { try { const url = `${this.endpoint}/${path}`; // Build headers const headers = { "Content-Type": "application/json", "x-client-id": this.clientId, ...options.customHeaders, }; // Add customerId to headers if available if (this.customerId) { headers["x-customer-id"] = this.customerId; } // Add userId to headers if provided if (options.userId) { headers["x-user-id"] = options.userId; } const requestOptions = { method: "POST", headers, body: JSON.stringify(data), }; debug("POST Request", { url, data, headers }); const response = await fetch(url, requestOptions); return await this.handleResponse(response); } catch (error$1) { error("POST Request Failed", error$1); throw new Error(formatErrorMessage("POST request failed", error$1)); } } // PUT request method async put(path, data = {}, options = {}) { try { const url = `${this.endpoint}/${path}`; // Build headers const headers = { "Content-Type": "application/json", "x-client-id": this.clientId, ...options.customHeaders, }; // Add customerId to headers if available if (this.customerId) { headers["x-customer-id"] = this.customerId; } // Add userId to headers if provided if (options.userId) { headers["x-user-id"] = options.userId; } const requestOptions = { method: "PUT", headers, body: JSON.stringify(data), credentials: "include", }; debug("PUT Request", { url, data, headers }); const response = await fetch(url, requestOptions); return await this.handleResponse(response); } catch (error$1) { error("PUT Request Failed", error$1); throw new Error(formatErrorMessage("PUT request failed", error$1)); } } // DELETE request method async delete(path, options = {}) { try { const url = `${this.endpoint}/${path}`; // Build headers const headers = { "Content-Type": "application/json", "x-client-id": this.clientId, ...options.customHeaders, }; // Add customerId to headers if available if (this.customerId) { headers["x-customer-id"] = this.customerId; } // Add userId to headers if provided if (options.userId) { headers["x-user-id"] = options.userId; } const requestOptions = { method: "DELETE", headers, credentials: "include", }; debug("DELETE Request", { url, headers }); const response = await fetch(url, requestOptions); return await this.handleResponse(response); } catch (error$1) { error("DELETE Request Failed", error$1); throw new Error(formatErrorMessage("DELETE request failed", error$1)); } } // Handle API response with proper error handling async handleResponse(response) { // Success path if (response.ok) { // 204 No Content if (response.status === 204) { return undefined; } // Try to detect JSON by header const contentType = response.headers.get("content-type")?.toLowerCase() || ""; if (contentType.includes("application/json") || contentType.includes("json")) { try { const data = await response.json(); return data; } catch { // Fall back to text if body is not valid JSON const text = await response.text(); return text; } } // Non-JSON success bodies (e.g., plain "ok") const text = await response.text(); return text; } // Error path let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try { const maybeJson = await response.json(); if (maybeJson && (maybeJson.message || maybeJson.error)) { errorMessage = maybeJson.message || maybeJson.error; } } catch { try { const text = await response.text(); if (text) errorMessage = text; } catch { } } throw new Error(errorMessage); } // Specific method for user traits by customerId async getUserTraits(customerId) { return this.get(`users/traits?customerId=${customerId}`); } // Send events to backend async sendEvent(event) { try { // Use sendBeacon when in unload scenario (best-effort, non-blocking) if (this.isUnloadScenario() && 'sendBeacon' in navigator) { if (!event.env) { throw new Error("Event missing env snapshot"); } const apiEvent = { cid: event.customerId, cna: event.customerName, uid: event.userId, eid: event.id, dtm: event.timestamp, e: event.type, ev: event.data, tv: "v1", sid: event.sessionId, tna: "web", ...event.env, }; const payload = JSON.stringify({ events: [apiEvent] }); const ok = navigator.sendBeacon?.(`${this.endpoint.replace(/\/$/, '')}`, new Blob([payload], { type: 'application/json' })); debug("sendBeacon invoked", { ok }); return !!ok; } if (!event.env) { throw new Error("Event missing env snapshot"); } const env = event.env; const apiEvent = { cid: event.customerId, cna: event.customerName, uid: event.userId, // may be undefined per requirement eid: event.id, dtm: event.timestamp, e: event.type, ev: event.data, tv: "v1", sid: event.sessionId, tna: "web", ...env, }; debug("Sending event", { events: [apiEvent] }); // Use the post method await this.post("", { events: [apiEvent] }); debug("Event sent successfully"); return true; } catch (error$1) { error("Failed to send event", error$1); throw error$1; } } // Send a batch of events (optimized for queue flush) async sendEventsBatch(events) { if (!events.length) return true; // Validate env presence for (const e of events) { if (!e.env) { throw new Error("Event missing env snapshot"); } } const apiEvents = events.map((event) => ({ cid: event.customerId, cna: event.customerName, uid: event.userId, eid: event.id, dtm: event.timestamp, e: event.type, ev: event.data, tv: "v1", sid: event.sessionId, tna: "web", ...event.env, })); // Unload scenario: attempt sendBeacon with the whole batch if (this.isUnloadScenario() && 'sendBeacon' in navigator) { const payload = JSON.stringify({ events: apiEvents }); const ok = navigator.sendBeacon?.(`${this.endpoint.replace(/\/$/, '')}`, new Blob([payload], { type: 'application/json' })); debug("sendBeacon batch invoked", { ok, count: apiEvents.length }); return !!ok; } debug("Sending events batch", { count: apiEvents.length }); await this.post("", { events: apiEvents }); return true; } isUnloadScenario() { // Only treat true unload visibility as unload scenario. Being offline should not force sendBeacon. return document.visibilityState === "hidden" || ("sendBeacon" in navigator === false); } // buildEnvironmentFields is intentionally unused; env is captured at event-time via captureEnvSnapshot // Best-effort client-side IP fetch; cached for subsequent events fetchIpAddress() { if (this.ipFetchInFlight) return; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); this.ipFetchInFlight = fetch("https://api.ipify.org?format=json", { signal: controller.signal, credentials: "omit", }) .then(async (res) => { clearTimeout(timeoutId); if (!res.ok) return; const data = await res.json(); if (data && typeof data.ip === "string") { this.ipAddress = data.ip; } }) .catch(() => { }) .finally(() => { this.ipFetchInFlight = null; }); } // Expose cached IP for snapshotting at event time getCachedIp() { return this.ipAddress; } } function captureEnvSnapshot(userId, ipAddress) { let pageLoadtime = null; try { // Prefer modern Navigation Timing API const navEntries = (performance && performance.getEntriesByType) ? performance.getEntriesByType('navigation') : []; const nav = (navEntries && navEntries[0]); if (nav && typeof nav.loadEventEnd === 'number' && typeof nav.startTime === 'number') { // loadEventEnd is relative to startTime for PerformanceNavigationTiming pageLoadtime = Math.max(0, Math.round(nav.loadEventEnd - nav.startTime)); } else { // Fallback to deprecated performance.timing when nav entry is unavailable const timing = (performance && performance.timing) || null; if (timing && timing.loadEventEnd > 0 && timing.navigationStart > 0) { pageLoadtime = timing.loadEventEnd - timing.navigationStart; } } } catch { } return { ua: navigator.userAgent, sh: typeof screen !== 'undefined' ? screen.height : 0, sw: typeof screen !== 'undefined' ? screen.width : 0, l: navigator.language, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, p: navigator.platform, an: !userId, vh: typeof window !== 'undefined' ? window.innerHeight : 0, vw: typeof window !== 'undefined' ? window.innerWidth : 0, pt: typeof document !== 'undefined' ? document.title : '', pu: typeof window !== 'undefined' ? window.location.href : '', pp: typeof window !== 'undefined' ? window.location.pathname : '', pd: typeof window !== 'undefined' ? window.location.hostname : '', pl: pageLoadtime, pr: typeof document !== 'undefined' && document.referrer ? document.referrer : null, ip: ipAddress ?? null, }; } class EventTracker { constructor(apiClient, eventQueue, sessionManager, clientId, customerId, customerName) { this.apiClient = apiClient; this.eventQueue = eventQueue; this.sessionManager = sessionManager; this.clientId = clientId; this.customerId = customerId; this.customerName = customerName; } async track(eventData) { // Create complete event with session data const event = { ...eventData, clientId: this.clientId, customerId: this.customerId || "cust-012345", customerName: this.customerName || "Default Customer", sessionId: this.sessionManager.getSessionId(), userId: this.sessionManager.getUserId(), timestamp: Date.now(), env: captureEnvSnapshot(this.sessionManager.getUserId(), this.apiClient.getCachedIp?.() ?? null) }; try { // First attempt to send immediately const success = await this.apiClient.sendEvent(event); if (!success) { // 400 errors are handled and logged in sendEvent return; } } catch (error) { // On any error, persist once; flushing handles batches this.eventQueue.add(event); } } // Method to manually flush the queue async flushQueue() { while (true) { const batch = this.eventQueue.getBatch(); if (batch.length === 0) return; try { const success = await this.apiClient.sendEventsBatch(batch); if (!success) { // Put the batch back once and stop to avoid tight loop this.eventQueue.addMany(batch); return; } } catch (error) { // Put the batch back once and stop; user/app can retry later this.eventQueue.addMany(batch); return; } } } // Debug method to check queue status getQueueStatus() { return this.eventQueue.getQueueStatus(); } // Debug method to clear queue clearQueue() { this.eventQueue.clearQueue(); } } // Static properties that don't change during a session // Utility to generate CSS selector for an element const getElementSelector = (element, maxDepth = 5) => { if (!element || element === document.body) return 'body'; const path = []; let current = element; let depth = 0; while (current && current !== document.body && depth < maxDepth) { let selector = current.tagName.toLowerCase(); if (current.id) { selector += `#${current.id}`; path.unshift(selector); break; } if (current.className) { const classes = current.className.split(' ').filter(c => c.trim()).slice(0, 3); if (classes.length > 0) { selector += `.${classes.join('.')}`; } } // Add nth-child if there are siblings with same tag const siblings = current.parentElement?.children; if (siblings && siblings.length > 1) { const sameTagSiblings = Array.from(siblings).filter(s => s.tagName === current.tagName); if (sameTagSiblings.length > 1) { const index = Array.from(siblings).indexOf(current) + 1; selector += `:nth-child(${index})`; } } path.unshift(selector); current = current.parentElement; depth++; } return path.join(' > '); }; // Utility to safely get text content const getSafeTextContent = (element, maxLength = 100) => { const text = element.textContent?.trim(); if (!text) return null; return text.length > maxLength ? text.substring(0, maxLength) + '...' : text; }; // Sensitive elements that should never be tracked const SENSITIVE_SELECTORS = [ 'input[type="password"]', 'input[type="email"]', 'input[type="tel"]', 'input[type="credit-card"]', 'input[type="cc-number"]', 'input[type="cardnumber"]', 'input[name*="password"]', 'input[name*="ssn"]', 'input[name*="social"]', '[data-cruxstack-ignore]', '[autocomplete="cc-number"]', '[autocomplete="cc-exp"]', '[autocomplete="cc-csc"]' ].join(','); // Rate limiting constants const RATE_LIMITS = { CLICK_THROTTLE_MS: 100, FORM_CHANGE_DEBOUNCE_MS: 300, MAX_TEXT_LENGTH: 100 }; // Check if an element should be ignored for tracking const shouldIgnoreElement = (element) => { // Check if element matches sensitive selectors if (element.matches(SENSITIVE_SELECTORS)) { return true; } // Check if element is inside a sensitive container if (element.closest(SENSITIVE_SELECTORS)) { return true; } // Check for data attributes that indicate sensitive content if (element.hasAttribute('data-sensitive') || element.hasAttribute('data-private') || element.hasAttribute('data-no-track')) { return true; } return false; }; // Redact sensitive values from form inputs const redactSensitiveValue = (element, value) => { const input = element; const type = input.type?.toLowerCase(); const name = input.name?.toLowerCase(); // Always redact password fields if (type === 'password') { return '[REDACTED_PASSWORD]'; } // Redact email fields if (type === 'email' || name?.includes('email')) { return '[REDACTED_EMAIL]'; } // Redact phone numbers if (type === 'tel' || name?.includes('phone') || name?.includes('tel')) { return '[REDACTED_PHONE]'; } // Redact credit card fields if (name?.includes('card') || name?.includes('credit') || type?.includes('cc')) { return '[REDACTED_CARD]'; } // Redact SSN or social security if (name?.includes('ssn') || name?.includes('social')) { return '[REDACTED_SSN]'; } // For other inputs, return limited info if (input.tagName === 'INPUT') { return value.length > 0 ? `[${value.length} chars]` : null; } return value; }; // Clean data object by removing or redacting sensitive information const cleanEventData = (data) => { const cleaned = { ...data }; // Remove sensitive fields const sensitiveFields = ['password', 'ssn', 'social', 'creditCard', 'cvv']; sensitiveFields.forEach(field => { if (cleaned[field]) { delete cleaned[field]; } }); // Redact URLs that might contain sensitive info if (cleaned.url && typeof cleaned.url === 'string') { try { const url = new URL(cleaned.url); // Remove sensitive query parameters const sensitiveParams = ['token', 'key', 'password', 'secret', 'auth']; sensitiveParams.forEach(param => { url.searchParams.delete(param); }); cleaned.url = url.toString(); } catch (e) { // Invalid URL, keep as is } } return cleaned; }; // Check if we should rate limit this event let eventCounts = new Map(); const shouldRateLimit = (eventType, limit = 100, windowMs = 60000) => { const now = Date.now(); const key = eventType; const current = eventCounts.get(key) || { count: 0, lastReset: now }; // Reset counter if window has passed if (now - current.lastReset > windowMs) { current.count = 0; current.lastReset = now; } current.count++; eventCounts.set(key, current); return current.count > limit; }; let lastClickTime = 0; let pageLoadTime$1 = Date.now(); // Throttle function to prevent too many click events const throttle = (func, delay) => { let timeoutId = null; let lastExecTime = 0; return function (...args) { const currentTime = Date.now(); if (currentTime - lastExecTime > delay) { func.apply(this, args); lastExecTime = currentTime; } else { if (timeoutId) clearTimeout(timeoutId); timeoutId = window.setTimeout(() => { func.apply(this, args); lastExecTime = Date.now(); }, delay - (currentTime - lastExecTime)); } }; }; // Get element attributes (limited set) const getElementAttributes = (element) => { const attrs = {}; const allowedAttrs = ['data-testid', 'data-track', 'role', 'aria-label', 'title']; allowedAttrs.forEach(attr => { const value = element.getAttribute(attr); if (value) { attrs[attr] = value; } }); return attrs; }; // Calculate DOM depth from body const getDOMDepth = (element) => { let depth = 0; let current = element; while (current && current !== document.body) { depth++; current = current.parentElement; } return depth; }; // Process click event and extract data const processClickEvent = (event) => { const target = event.target; // Privacy and safety checks if (!target || shouldIgnoreElement(target)) { return null; } // Only track clicks on actionable elements const actionableSelectors = [ 'button', 'a', 'input', 'select', 'textarea', '[role="button"]', '[role="link"]', '[role="tab"]', '[onclick]', '[data-clickable]', '[data-track]' ]; const isActionable = actionableSelectors.some(selector => target.matches(selector) || target.closest(selector)); if (!isActionable) { return null; // Skip clicks on non-actionable elements } // Rate limiting if (shouldRateLimit('click', 50, 10000)) { // 50 clicks per 10 seconds max return null; } const now = Date.now(); const timeSinceLastClick = lastClickTime > 0 ? now - lastClickTime : null; lastClickTime = now; // Collect click-specific data const clickData = { element: { tag: target.tagName.toLowerCase(), id: target.id || null, classes: target.className || null, text: getSafeTextContent(target, RATE_LIMITS.MAX_TEXT_LENGTH), href: target.href || null, selector: getElementSelector(target), attributes: getElementAttributes(target) }, position: { x: event.clientX, y: event.clientY, pageX: event.pageX, pageY: event.pageY }, context: { isContentEditable: target.isContentEditable, hasChildren: target.children.length > 0, parentTag: target.parentElement?.tagName.toLowerCase() || null, depth: getDOMDepth(target) }, timing: { timeOnPage: now - pageLoadTime$1, timeSinceLastClick } }; // If the clicked element is an input, add inputMeta if (target.tagName.toLowerCase() === 'input') { const input = target; let label = null; if (input.id) { const labelElem = document.querySelector(`label[for='${input.id}']`); if (labelElem) label = labelElem.textContent?.trim() || null; } clickData.inputMeta = { type: input.type || null, name: input.name || null, placeholder: input.placeholder || null, label }; } // Clean sensitive data on event-specific payload only return cleanEventData(clickData); }; // Create throttled click handler const createClickHandler = (trackingCallback) => { return throttle((event) => { const clickData = processClickEvent(event); if (clickData) { trackingCallback(clickData); } }, RATE_LIMITS.CLICK_THROTTLE_MS); }; const setupClickCapture = (trackEvent) => { // Create the click handler const clickHandler = createClickHandler(trackEvent); // Add event listener without capture phase to reduce duplications document.addEventListener('click', clickHandler, { passive: true }); // Return cleanup function return () => { document.removeEventListener('click', clickHandler); }; }; // Track form interactions const formInteractions = new Map(); let pageLoadTime = Date.now(); // Debounce function for form changes const debounce = (func, delay) => { let timeoutId = null; return function (...args) { if (timeoutId) clearTimeout(timeoutId); timeoutId = window.setTimeout(() => func.apply(this, args), delay); }; }; // Get form element data const getFormData = (form) => { Array.from(document.querySelectorAll('form')); return { id: form.id || null, classes: form.className || null, action: form.action || null, method: form.method || 'get', enctype: form.enctype || null, elementCount: form.elements.length, selector: getElementSelector(form) }; }; // Get field data with privacy protection const getFieldData = (element, form) => { const input = element; Array.from(form.elements); return { name: input.name || null, id: input.id || null, type: input.type || element.tagName.toLowerCase(), value: redactSensitiveValue(element, input.value || ''), placeholder: input.placeholder || null, required: input.required || false, selector: getElementSelector(element) }; }; // Analyze form submission const analyzeSubmission = (form) => { const elements = Array.from(form.elements); let filledCount = 0; let requiredCount = 0; let errorCount = 0; // elements is HTMLFormControlsCollection, which doesn't have forEach, so use for loop for (let i = 0; i < elements.length; i++) { const input = elements[i]; if (input.required) { requiredCount++; } if (input.value && input.value.trim()) { filledCount++; } if (!input.checkValidity()) { errorCount++; } } const interaction = formInteractions.get(form); const timeToSubmit = interaction ? Date.now() - interaction.firstInteraction : 0; return { isValid: form.checkValidity(), errorCount, filledFieldCount: filledCount, requiredFieldCount: requiredCount, timeToSubmit }; }; // Track form interaction timing const trackFormInteraction = (form, field) => { const now = Date.now(); if (!formInteractions.has(form)) { formInteractions.set(form, { firstInteraction: now, lastInteraction: now, fieldInteractions: new Set() }); } const interaction = formInteractions.get(form); interaction.lastInteraction = now; if (field) { interaction.fieldInteractions.add(field); } }; // Process form events const processFormEvent = (event, eventType) => { const target = event.target; // Privacy and safety checks if (!target || shouldIgnoreElement(target)) { return null; } // Find the form const form = target.closest('form'); if (!form) { return null; } // Add debug logging for form events (if debug is enabled) if (typeof window !== 'undefined' && window.cruxstackDebug) { console.log(`Cruxstack: Form ${eventType} event detected`, { element: target.tagName, formId: form.id || 'no-id', fieldName: target.name || 'no-name', fieldType: target.type || 'no-type' }); } const now = Date.now(); const forms = Array.from(document.querySelectorAll('form')); const formIndex = forms.indexOf(form); // Track interaction trackFormInteraction(form, target); const interaction = formInteractions.get(form); // Base form data const formEventData = { form: getFormData(form), eventType, timing: { timeOnPage: now - pageLoadTime, timeInForm: interaction ? now - interaction.firstInteraction : null, timeSinceLastInteraction: interaction && interaction.lastInteraction ? now - interaction.lastInteraction : null }, context: { formIndex, totalFormsOnPage: forms.length } }; // Add field data for field-specific events if (['change', 'focus', 'blur'].includes(eventType)) { const formElements = Array.from(form.elements); const fieldIndex = formElements.indexOf(target); formEventData.field = getFieldData(target, form); if (formEventData.context) { formEventData.context.fieldIndex = fieldIndex >= 0 ? fieldIndex : undefined; } } // Add submission data for submit events if (eventType === 'submit') { formEventData.submission = analyzeSubmission(form); } // Clean sensitive data on event-specific payload only return cleanEventData(formEventData); }; // Create debounced handlers for different event types const createFormHandlers = (trackingCallback) => { const debouncedChangeHandler = debounce((event) => { const formData = processFormEvent(event, 'change'); if (formData) { trackingCallback(formData); } }, RATE_LIMITS.FORM_CHANGE_DEBOUNCE_MS); const submitHandler = (event) => { const formData = processFormEvent(event, 'submit'); if (formData) { trackingCallback(formData); } }; const focusHandler = (event) => { const formData = processFormEvent(event, 'focus'); if (formData) { trackingCallback(formData); } }; const blurHandler = (event) => { const formData = processFormEvent(event, 'blur'); if (formData) { trackingCallback(formData); } }; return { change: debouncedChangeHandler, submit: submitHandler, focus: focusHandler, blur: blurHandler }; }; const setupFormCapture = (trackEvent) => { // Create the form handlers const handlers = createFormHandlers(trackEvent); // Add event listeners without capture phase to reduce duplications document.addEventListener('submit', handlers.submit); document.addEventListener('change', handlers.change, { passive: true }); document.addEventListener('focus', handlers.focus, { passive: true }); document.addEventListener('blur', handlers.blur, { passive: true }); // Return cleanup function return () => { document.removeEventListener('submit', handlers.submit); document.removeEventListener('change', handlers.change); document.removeEventListener('focus', handlers.focus); document.removeEventListener('blur', handlers.blur); }; }; // Session tracking let sessionStartTime = Date.now(); let pageViewCount = 0; let lastPageTime = null; let isFirstPageInSession = true; let maxScrollDepthPercent = 0; // (Performance, content, and navigation details are handled in the top-level envelope; ev keeps only session/timing/scroll) // Generate page view data const generatePageViewData = (triggeredBy, isSPA = false) => { const now = Date.now(); // Calculate time on previous page const timeOnPreviousPage = lastPageTime ? now - lastPageTime : null; // Update tracking variables pageViewCount++; const wasFirstPage = isFirstPageInSession; isFirstPageInSession = false; // Build page view data const pageViewData = { // Only include event-specific portions; common env fields are moved to envelope session: { isFirstPageInSession: wasFirstPage, pageViewCount, timeOnPreviousPage }, timing: { sessionDuration: now - sessionStartTime, timeToPageView: now - sessionStartTime }, scrollDepthPercent: Math.min(100, Math.max(0, Math.round(maxScrollDepthPercent))) }; lastPageTime = now; // Merge with common properties // Clean sensitive data (only event-specific payload) return cleanEventData(pageViewData); }; // Handle different types of navigation const handleInitialPageView = () => { // Setup scroll tracking on initial page load setupScrollDepthTracking(); return generatePageViewData('initial', false); }; const handleSPANavigation = (triggeredBy) => { // Reset scroll depth tracking for new SPA page resetScrollDepthTracking(); setupScrollDepthTracking(); return generatePageViewData(triggeredBy, true); }; // Scroll depth tracking utilities function setupScrollDepthTracking() { maxScrollDepthPercent = 0; const onScroll = () => { try { const scrollTop = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight); const maxScrollable = Math.max(1, docHeight - viewportHeight); const currentDepth = Math.round(((scrollTop + viewportHeight) / maxScrollable) * 100); if (currentDepth > maxScrollDepthPercent) { maxScrollDepthPercent = currentDepth; } } catch { } }; window.addEventListener('scroll', onScroll, { passive: true }); // Store handler for potential future removal if needed (not strictly necessary as we keep it during session) } function resetScrollDepthTracking() { maxScrollDepthPercent = 0; } let currentPath = window.location.pathname; const setupPageViewCapture = (trackEvent) => { // Track initial page view const initialPageView = handleInitialPageView(); if (initialPageView) { trackEvent(initialPageView); } // Handle browser back/forward navigation const handlePopState = () => { const pageView = handleSPANavigation('popstate'); if (pageView) { trackEvent(pageView); } currentPath = window.location.pathname; }; // Handle hash changes (for hash-based routing) const handleHashChange = () => { const pageView = handleSPANavigation('hashchange'); if (pageView) { trackEvent(pageView); } }; // Override history.pushState for SPA navigation detection const originalPushState = history.pushState; const pushStateHandler = function (...args) { const result = originalPushState.apply(this, args); // Check if the path actually changed if (window.location.pathname !== currentPath) { setTimeout(() => { const pageView = handleSPANavigation('pushstate'); if (pageView) { trackEvent(pageView); } currentPath = window.location.pathname; }, 0); // Use setTimeout to ensure DOM updates are complete } return result; }; // Override history.replaceState for SPA navigation detection const originalReplaceState = history.replaceState; const replaceStateHandler = function (...args) { const result = originalReplaceState.apply(this, args); // Check if the path actually changed if (window.location.pathname !== currentPath) { setTimeout(() => { const pageView = handleSPANavigation('replacestate'); if (pageView) { trackEvent(pageView); } currentPath = window.location.pathname; }, 0); } return result; }; // Apply history overrides history.pushState = pushStateHandler; history.replaceState = replaceStateHandler; // Add event listeners window.addEventListener('popstate', handlePopState); window.addEventListener('hashchange', handleHashChange); // Return cleanup function return () => { // Restore original history methods history.pushState = originalPushState; history.replaceState = originalReplaceState; // Remove event listeners window.removeEventListener('popstate', handlePopState); window.removeEventListener('hashchange', handleHashChange); }; }; // Main autocapture setup function const setupAutocapture = (trackers, config) => { // Skip if autocapture is disabled if (config.autoCapture === false) { return () => { }; // Return no-op cleanup function } const cleanupFunctions = []; // Set up each capture type try { // Page views (should be first to capture initial page load) const pageViewCleanup = setupPageViewCapture(trackers.trackPageView); cleanupFunctions.push(pageViewCleanup); // Click tracking const clickCleanup = setupClickCapture(trackers.trackClick); cleanupFunctions.push(clickCleanup); // Form tracking const formCleanup = setupFormCapture(trackers.trackForm); cleanupFunctions.push(formCleanup); if (config.debugLog) { console.log('Cruxstack: Autocapture initialized successfully'); } } catch (error) { if (config.debugLog) { console.error('Cruxstack: Error setting up autocapture:', error); } // Clean up any successfully initialized trackers cleanupFunctions.forEach(cleanup => { try { cleanup(); } catch (cleanupError) { console.error('Cruxstack: Error during cleanup:', cleanupError); } }); return () => { }; // Return no-op if setup failed } // Return combined cleanup function return () => { cleanupFunctions.forEach(cleanup => { try { cleanup(); } catch (error) { if (config.debugLog) { console.error('Cruxstack: Error during autocapture cleanup:', error); } } }); if (config.debugLog) { console.log('Cruxstack: Autocapture cleaned up'); } }; }; let sessionManager; let eventQueue; let apiClient; let eventTracker; let autocaptureCleanup = null; let globalConfig = null; // Store event listener references for proper cleanup let unloadHandler = null; let onlineHandler = null; // Generate plain UUID for event IDs function generateEventId() { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; const v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function init(config) { try { // Prevent multiple initializations if (eventTracker && globalConfig) { if (config.debugLog) { console.warn("Cruxstack: SDK already initialized. Skipping re-initialization."); } return; } // Validate config if (!config.clientId) { throw new Error("Cruxstack: clientId is required. Please provide a valid client identifier."); } if (typeof config.clientId !== "string" || config.clientId.trim() === "") { throw new Error("Cruxstack: clientId must be a non-empty string."); } // Store config globally globalConfig = config; // Initialize core modules setDebugLog(config.debugLog || false); sessionManager = new SessionManager(config); eventQueue = new EventQueue(); apiClient = new ApiClient(config.clientId, config.customerId, config.customerName, config.debugLog || false); eventTracker = new EventTracker(apiClient, eventQueue, sessionManager, config.clientId, config.customerId, config.customerName); // Setup autocapture if enabled if (config.autoCapture !== false) { const autocaptureTrackers = { trackClick: (data) => { eventTracker.track({ type: "click", data, id: generateEventId(), clientId: config.clientId, customerId: config.customerId, customerName: config.customerName, }); }, trackForm: (data) => { eventTracker.track({ type: `form_${data.eventType}`, data, id: generateEventId(), clientId: config.clientId, customerId: config.customerId, customerName: config.customerName, }); }, trackPageView: (data) => { eventTracker.track({ type: "page_view", data, id: generateEventId(), clientId: config.clientId, customerId: config.customerId, customerName: config.customerName, }); }, }; autocaptureCleanup = s