UNPKG

@varia-bly/variably-sdk

Version:

Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, LLM experiments with React hooks, and real-time dynamic configurations

543 lines 19.6 kB
/** * Automatic event tracking for Variably SDK * Based on the comprehensive metrics collection plan in docs/concepts/metrics-collection.md */ export class AutoTracker { constructor(config = {}, trackEventCallback, trackExperimentMetricCallback, getUserContext) { this.currentExperimentContext = new Map(); // gate_key -> experiment_id this.maxScrollDepth = 0; this.isTracking = false; /** * Handle click events */ this.handleClick = (event) => { const target = event.target; if (!target || !this.shouldTrackClick(target)) { return; } const clickData = this.extractClickData(target); this.trackEvent({ name: 'user_click', properties: { element_type: target.tagName.toLowerCase(), element_text: target.textContent?.substring(0, 100) || '', element_id: target.id || undefined, element_class: target.className || undefined, page_url: window.location.href, session_id: this.sessionId, ...clickData, ...this.getExperimentContext() } }); // Check for conversion tracking if (this.config.conversionTracking?.enabled) { this.checkAndTrackConversions(target); } }; /** * Handle scroll events */ this.handleScroll = () => { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const scrollHeight = document.documentElement.scrollHeight - window.innerHeight; const scrollPercent = Math.round((scrollTop / scrollHeight) * 100); if (scrollPercent > this.maxScrollDepth) { this.maxScrollDepth = scrollPercent; // Track scroll thresholds for (const threshold of this.config.scrollThresholds) { if (scrollPercent >= threshold && this.maxScrollDepth - scrollPercent < threshold) { this.trackEvent({ name: 'scroll_depth', properties: { scroll_depth: threshold, max_scroll_depth: scrollPercent, page_url: window.location.href, session_id: this.sessionId, ...this.getExperimentContext() } }); break; } } } }; /** * Handle form submissions */ this.handleFormSubmit = (event) => { const form = event.target; if (!form || this.shouldExcludeElement(form)) { return; } this.trackEvent({ name: 'form_submitted', properties: { form_id: form.id || undefined, form_action: form.action || undefined, form_method: form.method || 'get', page_url: window.location.href, session_id: this.sessionId, ...this.getExperimentContext() } }); }; /** * Handle page visibility changes */ this.handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { this.trackSessionPause(); } else if (document.visibilityState === 'visible') { this.trackSessionResume(); } }; /** * Handle page unload */ this.handlePageUnload = () => { this.trackSessionEnd(); }; /** * Handle page navigation changes (SPA) */ this.handlePageChange = () => { if (this.config.pageViews) { // Track page view for new page setTimeout(() => this.trackPageView(), 100); } }; this.config = { ...AutoTracker.DEFAULT_CONFIG, ...config }; this.trackEventCallback = trackEventCallback; this.trackExperimentMetricCallback = trackExperimentMetricCallback; this.getUserContext = getUserContext; this.sessionStartTime = Date.now(); this.sessionId = this.generateSessionId(); } /** * Start automatic tracking */ start() { if (this.isTracking || typeof window === 'undefined') { return; } this.isTracking = true; this.setupEventListeners(); // Track initial page view if (this.config.pageViews) { this.trackPageView(); } } /** * Stop automatic tracking */ stop() { if (!this.isTracking) { return; } this.isTracking = false; this.cleanup(); // Track final session duration if (this.config.sessionDuration) { this.trackSessionEnd(); } } /** * Check if tracking is currently active */ get isActive() { return this.isTracking; } /** * Update experiment context when feature gates are evaluated */ updateExperimentContext(gateKey, experimentId) { if (experimentId) { this.currentExperimentContext.set(gateKey, experimentId); } } /** * Setup all event listeners for automatic tracking */ setupEventListeners() { // Click tracking if (this.config.clicks) { document.addEventListener('click', this.handleClick, true); } // Scroll depth tracking if (this.config.scrollDepth) { window.addEventListener('scroll', this.handleScroll, { passive: true }); } // Form submission tracking if (this.config.formSubmissions) { document.addEventListener('submit', this.handleFormSubmit, true); } // Page visibility changes for session tracking document.addEventListener('visibilitychange', this.handleVisibilityChange); // Page unload for session duration window.addEventListener('beforeunload', this.handlePageUnload); // Page navigation (SPA support) window.addEventListener('popstate', this.handlePageChange); // Override pushState and replaceState for SPA navigation this.interceptHistoryAPI(); } /** * Clean up event listeners */ cleanup() { document.removeEventListener('click', this.handleClick, true); window.removeEventListener('scroll', this.handleScroll); document.removeEventListener('submit', this.handleFormSubmit, true); document.removeEventListener('visibilitychange', this.handleVisibilityChange); window.removeEventListener('beforeunload', this.handlePageUnload); window.removeEventListener('popstate', this.handlePageChange); } /** * Track page view event */ trackPageView() { this.trackEvent({ name: 'page_view', properties: { page_url: window.location.href, page_title: document.title, referrer: document.referrer || undefined, session_id: this.sessionId, viewport_width: window.innerWidth, viewport_height: window.innerHeight, user_agent: navigator.userAgent, ...this.getExperimentContext() } }); } /** * Check and track conversion events based on configuration */ checkAndTrackConversions(element) { const conversionEvents = this.config.conversionTracking?.conversionEvents || []; for (const conversionEvent of conversionEvents) { if (element.matches?.(conversionEvent.selector) || element.closest(conversionEvent.selector)) { this.trackConversionEvent(element, conversionEvent); break; // Only track the first matching conversion event } } } /** * Track a specific conversion event */ trackConversionEvent(element, conversionEvent) { const properties = { session_id: this.sessionId, page_url: window.location.href, ...this.getExperimentContext() }; // Extract configured properties if (conversionEvent.properties) { for (const [propName, extraction] of Object.entries(conversionEvent.properties)) { const value = this.extractPropertyValue(element, extraction); if (value !== undefined) { properties[propName] = value; } } } // Add data extraction if configured const dataExtraction = this.config.conversionTracking?.dataExtraction; if (dataExtraction?.trackPosition) { properties.position_in_list = this.getElementPosition(element); } if (dataExtraction?.trackLayoutType) { properties.layout_type = this.getLayoutType(dataExtraction.layoutSelectors); } this.trackEvent({ name: conversionEvent.eventName, properties }); } /** * Track session end */ trackSessionEnd() { const sessionDuration = Date.now() - this.sessionStartTime; if (sessionDuration >= this.config.minSessionDuration) { this.trackEvent({ name: 'session_end', properties: { session_duration: sessionDuration, session_id: this.sessionId, max_scroll_depth: this.maxScrollDepth, page_url: window.location.href, ...this.getExperimentContext() } }); } } /** * Track session pause */ trackSessionPause() { const sessionDuration = Date.now() - this.sessionStartTime; this.trackEvent({ name: 'session_pause', properties: { session_duration: sessionDuration, session_id: this.sessionId, page_url: window.location.href, ...this.getExperimentContext() } }); } /** * Track session resume */ trackSessionResume() { this.trackEvent({ name: 'session_resume', properties: { session_id: this.sessionId, page_url: window.location.href, ...this.getExperimentContext() } }); } /** * Check if click should be tracked */ shouldTrackClick(element) { // Check exclude selectors first if (this.shouldExcludeElement(element)) { return false; } // Check if element matches tracking selectors return this.config.clickSelectors.some(selector => element.matches?.(selector) || element.closest(selector)); } /** * Check if element should be excluded from tracking */ shouldExcludeElement(element) { return this.config.excludeSelectors.some(selector => element.matches?.(selector) || element.closest(selector)); } /** * Extract click data from element */ extractClickData(element) { return { href: element.getAttribute('href') || undefined, data_track: element.getAttribute('data-track') || undefined, xpath: this.getElementXPath(element) }; } /** * Extract property value based on configuration */ extractPropertyValue(element, extraction) { const targetElement = extraction.selector ? element.querySelector(extraction.selector) || element.closest(extraction.selector) : element; if (!targetElement) return undefined; let value; // Extract value based on attribute switch (extraction.attribute) { case 'textContent': value = targetElement.textContent?.trim(); break; case 'href': value = targetElement.href; break; case 'value': value = targetElement.value; break; default: // Handle data-* attributes or other attributes value = targetElement.getAttribute(extraction.attribute); break; } // Apply transforms if (value && extraction.transform) { switch (extraction.transform) { case 'number': const parsed = parseFloat(value); value = isNaN(parsed) ? undefined : parsed; break; case 'trim': value = String(value).trim(); break; case 'toLowerCase': value = String(value).toLowerCase(); break; case 'toUpperCase': value = String(value).toUpperCase(); break; } } return value; } /** * Get element position in its container */ getElementPosition(element) { const parent = element.parentElement; if (!parent) return undefined; const siblings = Array.from(parent.children).filter(child => child.tagName === element.tagName); const position = siblings.indexOf(element) + 1; return position > 0 ? position : undefined; } /** * Get current layout type based on configuration */ getLayoutType(layoutSelectors = {}) { // Check configured layout selectors for (const [layoutName, selector] of Object.entries(layoutSelectors)) { if (document.querySelector(selector)) { return layoutName; } } return undefined; } /** * Get current experiment context */ getExperimentContext() { const context = {}; for (const [gateKey, experimentId] of this.currentExperimentContext.entries()) { context[`${gateKey}_experiment_id`] = experimentId; } return context; } /** * Generate unique session ID */ generateSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Get XPath for element (for precise tracking) */ getElementXPath(element) { if (element.id) { return `//*[@id="${element.id}"]`; } const parts = []; let current = element; while (current && current !== document.body) { const tagName = current.tagName.toLowerCase(); const siblings = Array.from(current.parentElement?.children || []) .filter(el => el.tagName === current.tagName); if (siblings.length > 1) { const index = siblings.indexOf(current) + 1; parts.unshift(`${tagName}[${index}]`); } else { parts.unshift(tagName); } current = current.parentElement; } return '/' + parts.join('/'); } /** * Track event using the provided callback, with experiment attribution when applicable */ async trackEvent(event) { try { const userContext = this.getUserContext(); if (!userContext?.userId) { throw new Error('User context with userId is required for auto-tracking events'); } // Check if user has any active experiment contexts and track as experiment metrics const experimentContextEntries = Array.from(this.currentExperimentContext.entries()); if (experimentContextEntries.length > 0) { // Track as experiment metrics for all active experiments for (const [gateKey, experimentId] of experimentContextEntries) { await this.trackExperimentMetricCallback(experimentId, { userId: userContext.userId, metricKey: event.name, value: this.getEventValue(event), sessionId: this.sessionId, timestamp: new Date(), metadata: { ...event.properties, gate_key: gateKey, source: 'auto_tracker' } }); } } else { // Track as regular analytics event await this.trackEventCallback({ ...event, userId: userContext.userId, timestamp: new Date(), context: userContext }); } } catch (error) { // Silently fail to not break user experience console.warn('Auto-tracker failed to send event:', error); } } /** * Extract numeric value from event for experiment metrics */ getEventValue(event) { // For most events, we track them as count = 1 // Special cases can be handled based on event name or properties switch (event.name) { case 'scroll_depth': return Number(event.properties?.scroll_depth) || 1; case 'session_end': return Number(event.properties?.session_duration) || 1; case 'session_pause': return Number(event.properties?.session_duration) || 1; default: return 1; // Default count for events like clicks, page views, form submissions } } /** * Intercept History API for SPA navigation detection */ interceptHistoryAPI() { const originalPushState = window.history.pushState.bind(window.history); const originalReplaceState = window.history.replaceState.bind(window.history); window.history.pushState = (data, unused, url) => { originalPushState(data, unused, url); setTimeout(() => { if (this.config?.pageViews) { this.trackPageView(); } }, 100); }; window.history.replaceState = (data, unused, url) => { originalReplaceState(data, unused, url); setTimeout(() => { if (this.config?.pageViews) { this.trackPageView(); } }, 100); }; } } // Default configuration AutoTracker.DEFAULT_CONFIG = { pageViews: true, clicks: true, scrollDepth: true, sessionDuration: true, formSubmissions: false, clickSelectors: ['button', 'a', '[data-track]'], excludeSelectors: ['.no-track', '[data-no-track]'], minSessionDuration: 30000, // 30 seconds scrollThresholds: [25, 50, 75, 90], conversionTracking: { enabled: false, conversionEvents: [], dataExtraction: { trackPosition: false, trackLayoutType: false, layoutSelectors: {} } } }; //# sourceMappingURL=auto-tracker.js.map