UNPKG

@tinytapanalytics/sdk

Version:

Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time

1,012 lines (855 loc) 30 kB
/** * Micro-Interaction Psychology Tracking * Tracks HOW users interact (not just what), with adaptive performance optimization * * Features: * - Rage click detection * - Hesitation tracking * - Form fill analysis * - Adaptive performance (auto-adjusts based on device capabilities) * - Client-side significance scoring * - Smart batching and rate limiting */ import { TinyTapAnalyticsConfig } from '../types/index'; export interface MicroInteractionEvent { id?: string; // Unique ID to prevent duplicates type: string; elementSelector: string; elementType?: string; pageUrl?: string; timestamp: number; hesitationDuration?: number; isRageClick: boolean; clickCount?: number; cursorDistance?: number; formFillSpeed?: number; backspaceCount?: number; scrollSpeed?: number; isErrorState: boolean; significanceScore: number; } export interface TrackingProfile { name: string; significanceThreshold: number; maxEventsPerMinute: number; batchSize: number; batchInterval: number; } export class MicroInteractionTracking { private config: TinyTapAnalyticsConfig; private sdk: any; private isActive = false; // Tracking profiles private readonly profiles: Record<string, TrackingProfile> = { minimal: { name: 'minimal', significanceThreshold: 0.9, maxEventsPerMinute: 5, batchSize: 3, batchInterval: 10000 }, balanced: { name: 'balanced', significanceThreshold: 0.6, maxEventsPerMinute: 10, batchSize: 5, batchInterval: 5000 }, detailed: { name: 'detailed', significanceThreshold: 0.3, maxEventsPerMinute: 20, batchSize: 10, batchInterval: 3000 }, performance: { name: 'performance', significanceThreshold: 0.5, maxEventsPerMinute: 8, batchSize: 4, batchInterval: 7000 } }; private currentProfile: TrackingProfile; // Event queue and batching private eventQueue: MicroInteractionEvent[] = []; private batchTimer?: number; // Interaction state private interactionState: { lastClick?: { element: string; time: number; count: number }; hoverStart?: number; lastMousePos?: { x: number; y: number }; cursorDistance?: number; inputStart?: { time: number; element: string; initialValue: string }; backspaceCount?: number; lastScroll?: { position: number; time: number }; } = {}; // Rate limiting private eventTimestamps: number[] = []; // Statistics tracking private stats = { eventCount: 0, filteredEventCount: 0, rateLimitedCount: 0, batchCount: 0 }; // Performance monitoring private performanceMonitor?: PerformanceMonitor; private adaptiveOptimizer?: AdaptivePerformanceOptimizer; // Event listeners cleanup private listeners: Map<string, { target: EventTarget; event: string; handler: EventListener; options?: AddEventListenerOptions }> = new Map(); private listenerIdCounter = 0; constructor(config: TinyTapAnalyticsConfig, sdk: any) { this.config = config; this.sdk = sdk; // Select profile const profileName = (config as any).microInteractionProfile || 'balanced'; this.currentProfile = this.profiles[profileName] || this.profiles.balanced; // Initialize performance monitoring if adaptive is enabled if ((config as any).adaptiveSampling !== false) { this.performanceMonitor = new PerformanceMonitor(); this.adaptiveOptimizer = new AdaptivePerformanceOptimizer(this.performanceMonitor); } } /** * Start micro-interaction tracking */ public start(): void { if (this.isActive) { return; } this.isActive = true; // Attach event listeners this.attachClickTracking(); this.attachDeadClickTracking(); this.attachInputTracking(); this.attachMouseTracking(); this.attachScrollTracking(); // Start batch timer this.startBatchTimer(); // Start adaptive optimization if enabled if (this.adaptiveOptimizer) { this.adaptiveOptimizer.start(() => { this.applyAdaptiveSettings(); }); } if (this.config.debug) { console.log(`[TinyTapAnalytics] Micro-interaction tracking started with profile: ${this.currentProfile.name}`); } } /** * Stop micro-interaction tracking */ public stop(): void { if (!this.isActive) { return; } this.isActive = false; // Clear batch timer if (this.batchTimer) { clearTimeout(this.batchTimer); } // Stop adaptive optimizer if (this.adaptiveOptimizer) { this.adaptiveOptimizer.stop(); } // Clean up event listeners this.listeners.forEach(({ target, event, handler, options }) => { target.removeEventListener(event, handler, options); }); this.listeners.clear(); // Flush remaining events this.flush(); if (this.config.debug) { console.log('[TinyTapAnalytics] Micro-interaction tracking stopped'); } } /** * Apply adaptive performance settings */ private applyAdaptiveSettings(): void { if (!this.adaptiveOptimizer) { return; } const recommendation = this.adaptiveOptimizer.getRecommendation(); if (recommendation.shouldThrottle) { // Increase threshold (less events), increase batch interval this.currentProfile.significanceThreshold = Math.min(0.95, this.currentProfile.significanceThreshold + 0.1); this.currentProfile.batchInterval = Math.min(15000, this.currentProfile.batchInterval + 2000); if (this.config.debug) { console.log(`[TinyTapAnalytics] Throttling: ${recommendation.reason}`); } } else if (recommendation.canIncrease) { // Decrease threshold (more events), decrease batch interval this.currentProfile.significanceThreshold = Math.max(0.3, this.currentProfile.significanceThreshold - 0.05); this.currentProfile.batchInterval = Math.max(3000, this.currentProfile.batchInterval - 1000); if (this.config.debug) { console.log(`[TinyTapAnalytics] Increasing fidelity: ${recommendation.reason}`); } } } /** * Attach click tracking with rage click detection */ private attachClickTracking(): void { const clickHandler = (event: Event) => { const target = event.target as Element; const selector = this.getElementSelector(target); const now = Date.now(); // Detect rage clicks const lastClick = this.interactionState.lastClick; const isRageClick = lastClick?.element === selector && (now - lastClick.time) < 500 && lastClick.count >= 2; const clickCount = lastClick?.element === selector ? lastClick.count + 1 : 1; // Calculate hesitation const hesitationDuration = this.interactionState.hoverStart ? now - this.interactionState.hoverStart : undefined; // Track the event this.trackEvent({ type: 'click', elementSelector: selector, elementType: target.tagName.toLowerCase(), pageUrl: window.location.href, timestamp: now, hesitationDuration, isRageClick, clickCount, cursorDistance: this.interactionState.cursorDistance, isErrorState: this.isElementInErrorState(target), significanceScore: 0 // Will be calculated }); // Update state this.interactionState.lastClick = { element: selector, time: now, count: clickCount }; }; this.addEventListener(document, 'click', clickHandler, true); } /** * Attach dead click detection (clicks on non-interactive elements that look clickable) */ private attachDeadClickTracking(): void { const deadClickHandler = (event: Event) => { const target = event.target as Element; // Skip if already tracked as interactive if (this.isInteractiveElement(target)) { return; } // Check if element LOOKS clickable but isn't const looksClickable = window.getComputedStyle(target).cursor === 'pointer' || (target.closest('[style*="cursor: pointer"]') !== null) || (target.tagName.match(/^(DIV|SPAN|P|IMG)$/) && target.textContent?.match(/^(Click|View|See|Learn|Read|More|Details|Info|Buy|Shop|Get)/i)); if (looksClickable) { this.trackEvent({ type: 'dead_click', elementSelector: this.getElementSelector(target), elementType: target.tagName.toLowerCase(), pageUrl: window.location.href, timestamp: Date.now(), isRageClick: false, isErrorState: false, significanceScore: 0.8 // Dead clicks are highly significant - indicate UI confusion }); if (this.config.debug) { console.log('[TinyTapAnalytics] Dead click detected on:', this.getElementSelector(target)); } } }; this.addEventListener(document, 'click', deadClickHandler, true); } /** * Attach input field tracking */ private attachInputTracking(): void { const focusHandler = (event: Event) => { const target = event.target as HTMLInputElement; if (!this.isFormElement(target)) { return; } // Skip password fields for privacy if (target.type === 'password') { return; } const selector = this.getElementSelector(target); this.interactionState.inputStart = { time: Date.now(), element: selector, initialValue: target.value || '' }; this.interactionState.backspaceCount = 0; }; const blurHandler = (event: Event) => { const target = event.target as HTMLInputElement; if (!this.isFormElement(target)) { return; } // Skip password fields for privacy if (target.type === 'password') { return; } const selector = this.getElementSelector(target); const inputStart = this.interactionState.inputStart; if (!inputStart || inputStart.element !== selector) { return; } const now = Date.now(); const duration = now - inputStart.time; const finalValue = target.value || ''; const charactersTyped = Math.abs(finalValue.length - inputStart.initialValue.length); const formFillSpeed = duration > 0 ? (charactersTyped / duration) * 1000 : 0; // Calculate significance score for input events let inputSignificance = 0.3; // Base significance for input tracking // High backspace count indicates corrections/frustration if (this.interactionState.backspaceCount && this.interactionState.backspaceCount > 2) { inputSignificance += 0.3; } // Error state is highly significant if (this.isElementInErrorState(target)) { inputSignificance += 0.3; } // Very slow typing (< 1 char/sec) or very fast (> 10 char/sec) might indicate copy-paste or struggle if (formFillSpeed > 0 && (formFillSpeed < 1 || formFillSpeed > 10)) { inputSignificance += 0.2; } this.trackEvent({ type: 'input_blur', elementSelector: selector, elementType: target.tagName.toLowerCase(), pageUrl: window.location.href, timestamp: now, formFillSpeed, backspaceCount: this.interactionState.backspaceCount, isRageClick: false, isErrorState: this.isElementInErrorState(target), significanceScore: Math.min(inputSignificance, 1.0) }); this.interactionState.inputStart = undefined; this.interactionState.backspaceCount = 0; }; const inputHandler = (event: Event) => { const target = event.target as HTMLInputElement; // Skip password fields for privacy if (target && target.type === 'password') { return; } const inputEvent = event as InputEvent; if (inputEvent.inputType === 'deleteContentBackward') { this.interactionState.backspaceCount = (this.interactionState.backspaceCount || 0) + 1; } }; this.addEventListener(document, 'focus', focusHandler, true); this.addEventListener(document, 'blur', blurHandler, true); this.addEventListener(document, 'input', inputHandler, true); } /** * Attach mouse movement tracking (RAF-throttled for better performance) */ private attachMouseTracking(): void { let rafId: number | null = null; const lastPosition = { x: 0, y: 0 }; const processMouseMove = (x: number, y: number) => { // Update cursor distance if (this.interactionState.lastMousePos) { const distance = Math.sqrt( Math.pow(x - this.interactionState.lastMousePos.x, 2) + Math.pow(y - this.interactionState.lastMousePos.y, 2) ); this.interactionState.cursorDistance = (this.interactionState.cursorDistance || 0) + distance; } this.interactionState.lastMousePos = { x, y }; // Track hover start for hesitation calculation const element = document.elementFromPoint(x, y); if (element && this.isInteractiveElement(element)) { if (!this.interactionState.hoverStart) { this.interactionState.hoverStart = Date.now(); } } else { this.interactionState.hoverStart = undefined; } rafId = null; }; const mouseMoveHandler = (event: Event) => { const mouseEvent = event as MouseEvent; lastPosition.x = mouseEvent.clientX; lastPosition.y = mouseEvent.clientY; // Only schedule processing if not already scheduled if (rafId === null) { rafId = requestAnimationFrame(() => { processMouseMove(lastPosition.x, lastPosition.y); }); } }; this.addEventListener(document, 'mousemove', mouseMoveHandler, { passive: true }); } /** * Attach scroll tracking (throttled) */ private attachScrollTracking(): void { let lastScroll = 0; const scrollHandler = () => { const now = Date.now(); if (now - lastScroll < 200) { return; } // Throttle to 5Hz lastScroll = now; const scrollTop = window.pageYOffset || document.documentElement.scrollTop; if (this.interactionState.lastScroll) { const distance = Math.abs(scrollTop - this.interactionState.lastScroll.position); const time = now - this.interactionState.lastScroll.time; const scrollSpeed = time > 0 ? (distance / time) * 1000 : 0; // Only track significant scrolls if (distance > 50) { this.trackEvent({ type: 'scroll', elementSelector: 'window', elementType: 'window', pageUrl: window.location.href, timestamp: now, scrollSpeed, isRageClick: false, isErrorState: false, significanceScore: 0 }); } } this.interactionState.lastScroll = { position: scrollTop, time: now }; }; this.addEventListener(window, 'scroll', scrollHandler, { passive: true }); } /** * Track a micro-interaction event */ private trackEvent(event: MicroInteractionEvent): void { // Generate unique ID for this event to prevent duplicates event.id = this.generateEventId(event); // Calculate significance score event.significanceScore = this.calculateSignificance(event); if (this.config.debug) { console.log('[TinyTapAnalytics] Event:', event.type, 'Significance:', event.significanceScore.toFixed(2)); } // Filter by significance threshold if (event.significanceScore < this.currentProfile.significanceThreshold) { this.stats.filteredEventCount++; if (this.config.debug) { console.log('[TinyTapAnalytics] Event filtered (low significance)'); } return; } // Apply rate limiting if (!this.allowEvent()) { this.stats.rateLimitedCount++; if (this.config.debug) { console.log('[TinyTapAnalytics] Event dropped (rate limit)'); } return; } // Add to queue this.eventQueue.push(event); this.stats.eventCount++; // Send batch if queue is full if (this.eventQueue.length >= this.currentProfile.batchSize) { this.sendBatch(); } } /** * Generate unique ID for micro-interaction event * Format: {sessionId}_{timestamp}_{type}_{hash} */ private generateEventId(event: MicroInteractionEvent): string { const sessionId = this.sdk.getSessionId(); const hash = this.simpleHash(`${event.type}_${event.elementSelector}_${event.timestamp}`); return `${sessionId}_${event.timestamp}_${hash}`; } /** * Simple hash function for event ID generation */ private simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36); } /** * Calculate significance score for an event */ private calculateSignificance(event: MicroInteractionEvent): number { let score = 0.5; // Base score // Dead clicks are highly significant - indicate UI confusion if (event.type === 'dead_click') { return 0.8; } // Input events have custom significance calculation if (event.type === 'input_blur') { // Use the pre-calculated score from the input tracking handler return event.significanceScore; } // High significance events if (event.isRageClick) { score += 0.5; } if (event.isErrorState) { score += 0.3; } if (event.type === 'form_submit') { score += 0.4; } // Hesitation indicates uncertainty/confusion if (event.hesitationDuration) { if (event.hesitationDuration > 2000) { score += 0.3; } else if (event.hesitationDuration > 1000) { score += 0.2; } } // Multiple clicks on same element if (event.clickCount && event.clickCount > 2) { score += 0.2; } // Slow form filling indicates difficulty if (event.formFillSpeed !== undefined && event.formFillSpeed < 1.0 && event.formFillSpeed > 0) { score += 0.2; } // Many corrections indicate confusion if (event.backspaceCount && event.backspaceCount > 3) { score += 0.2; } // Very fast scrolling might indicate frustration if (event.scrollSpeed && event.scrollSpeed > 2000) { score += 0.15; } return Math.min(1.0, score); } /** * Rate limiting check */ private allowEvent(): boolean { const now = Date.now(); const oneMinuteAgo = now - 60000; // Remove old timestamps this.eventTimestamps = this.eventTimestamps.filter(ts => ts > oneMinuteAgo); if (this.eventTimestamps.length >= this.currentProfile.maxEventsPerMinute) { return false; } this.eventTimestamps.push(now); return true; } /** * Start batch timer with Page Visibility API support */ private startBatchTimer(): void { const scheduleBatch = () => { if (this.eventQueue.length > 0) { this.sendBatch(); } // Reschedule if page is visible and tracking is active if (!document.hidden && this.isActive) { this.batchTimer = window.setTimeout(scheduleBatch, this.currentProfile.batchInterval); } }; // Start initial timer this.batchTimer = window.setTimeout(scheduleBatch, this.currentProfile.batchInterval); // Handle visibility changes const visibilityHandler = () => { if (document.hidden) { // Flush when hiding if (this.eventQueue.length > 0) { this.sendBatch(); } // Clear timer if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = undefined; } } else if (this.isActive) { // Resume when visible scheduleBatch(); } }; this.addEventListener(document, 'visibilitychange', visibilityHandler); } /** * Send batch of events to server */ private async sendBatch(): Promise<void> { if (this.eventQueue.length === 0) { return; } const batch = this.eventQueue.splice(0, this.currentProfile.batchSize); try { // Send directly to micro-interactions endpoint (not analytics track endpoint) const apiUrl = this.config.endpoint || 'https://api.tinytapanalytics.com'; const headers: Record<string, string> = { 'Content-Type': 'application/json' }; if (this.config.apiKey) { headers['X-API-Key'] = this.config.apiKey; } const response = await fetch(`${apiUrl}/api/v1/micro-interactions`, { method: 'POST', headers, body: JSON.stringify({ events: batch, sessionId: this.sdk.getSessionId(), websiteId: this.config.websiteId, timestamp: Date.now() }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } this.stats.batchCount++; if (this.config.debug) { console.log(`[TinyTapAnalytics] Micro-interaction batch sent successfully: ${batch.length} events`); } } catch (error) { console.error('[TinyTapAnalytics] Error sending micro-interaction batch:', error); // Re-queue events on failure (with limit) if (this.eventQueue.length < 50) { this.eventQueue.unshift(...batch); } } } /** * Flush remaining events (called on page unload) */ public flush(): void { if (this.eventQueue.length > 0) { // Use sendBeacon for reliable delivery during page unload const batch = this.eventQueue.splice(0); const apiUrl = this.config.endpoint || 'https://api.tinytapanalytics.com'; // sendBeacon can't send custom headers, so include API key and websiteId in URL params const params = new URLSearchParams(); if (this.config.apiKey) { params.append('apiKey', this.config.apiKey); } if (this.config.websiteId) { params.append('websiteId', this.config.websiteId); } const data = JSON.stringify({ events: batch, sessionId: this.sdk.getSessionId(), websiteId: this.config.websiteId, timestamp: Date.now() }); const url = `${apiUrl}/api/v1/micro-interactions${params.toString() ? '?' + params.toString() : ''}`; const blob = new Blob([data], { type: 'application/json' }); navigator.sendBeacon(url, blob); } } // Helper methods /** * Add event listener with proper cleanup tracking */ private addEventListener(target: EventTarget, event: string, handler: EventListener, options?: boolean | AddEventListenerOptions): string { const key = `${event}-${this.listenerIdCounter++}`; target.addEventListener(event, handler, options); const normalizedOptions = typeof options === 'boolean' ? { capture: options } : options; this.listeners.set(key, { target, event, handler, options: normalizedOptions }); return key; } private getElementSelector(element: Element): string { // Prefer data-tracking attributes (convention for analytics) const trackingId = element.getAttribute('data-track-id') || element.getAttribute('data-testid') || element.getAttribute('data-cy'); if (trackingId) { return `[data-track-id="${trackingId}"]`; } // Then ID if (element.id) { return `#${element.id}`; } // Build a unique path (max 4 levels) const path: string[] = []; let current: Element | null = element; while (current && current !== document.body && path.length < 4) { let selector = current.tagName.toLowerCase(); // Add meaningful classes (filter out utility classes) if (current.className && typeof current.className === 'string') { const meaningfulClasses = current.className .trim() .split(/\s+/) .filter(c => c && !c.match(/^(d-|m-|p-|text-|bg-|flex-|grid-|w-|h-|border-|rounded-|shadow-|opacity-)/)) .slice(0, 2) .join('.'); if (meaningfulClasses) { selector += `.${meaningfulClasses}`; } } // Add nth-child if no meaningful identifier if (current.parentElement && selector === current.tagName.toLowerCase()) { const siblings = Array.from(current.parentElement.children); const index = siblings.indexOf(current) + 1; selector += `:nth-child(${index})`; } path.unshift(selector); current = current.parentElement; } return path.join(' > ') || element.tagName.toLowerCase(); } private isFormElement(element: Element): boolean { const tagName = element.tagName.toLowerCase(); return tagName === 'input' || tagName === 'textarea' || tagName === 'select'; } private isInteractiveElement(element: Element): boolean { const tagName = element.tagName.toLowerCase(); const interactiveTags = ['button', 'a', 'input', 'select', 'textarea']; return interactiveTags.includes(tagName) || (element as HTMLElement).onclick !== null; } private isElementInErrorState(element: Element): boolean { return element.classList.contains('error') || element.classList.contains('invalid') || element.getAttribute('aria-invalid') === 'true' || !!element.parentElement?.querySelector('.error-message'); } /** * Update the tracking profile dynamically */ public setProfile(profileName: 'minimal' | 'balanced' | 'detailed' | 'performance'): void { const newProfile = this.profiles[profileName]; if (!newProfile) { console.warn(`[TinyTapAnalytics] Invalid profile name: ${profileName}. Using current profile.`); return; } const oldProfile = this.currentProfile.name; this.currentProfile = newProfile; if (this.config.debug) { console.log(`[TinyTapAnalytics] Micro-interaction profile changed: ${oldProfile} → ${profileName}`); } // If batch timer needs adjustment, restart it if (this.isActive) { if (this.batchTimer) { clearTimeout(this.batchTimer); } this.startBatchTimer(); } } /** * Get current profile name */ public getProfile(): string { return this.currentProfile.name; } /** * Get current tracking statistics */ public getStats(): { isActive: boolean; profile: string; currentProfile: string; queueSize: number; eventsThisMinute: number; significanceThreshold: number; maxEventsPerMinute: number; batchSize: number; eventCount: number; filteredEventCount: number; rateLimitedCount: number; batchCount: number; } { const now = Date.now(); const oneMinuteAgo = now - 60000; const recentEvents = this.eventTimestamps.filter(ts => ts > oneMinuteAgo).length; return { isActive: this.isActive, profile: this.currentProfile.name, currentProfile: this.currentProfile.name, queueSize: this.eventQueue.length, eventsThisMinute: recentEvents, significanceThreshold: this.currentProfile.significanceThreshold, maxEventsPerMinute: this.currentProfile.maxEventsPerMinute, batchSize: this.currentProfile.batchSize, eventCount: this.stats.eventCount, filteredEventCount: this.stats.filteredEventCount, rateLimitedCount: this.stats.rateLimitedCount, batchCount: this.stats.batchCount }; } } /** * Performance Monitor - Tracks device capabilities */ class PerformanceMonitor { public getMetrics(): { memory?: { usagePercent: number }; battery?: { level: number; charging: boolean }; connection?: { effectiveType: string; saveData?: boolean }; } { const metrics: any = {}; // Memory if ((performance as any).memory) { const memory = (performance as any).memory; metrics.memory = { usagePercent: (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100 }; } // Connection if ((navigator as any).connection) { const conn = (navigator as any).connection; metrics.connection = { effectiveType: conn.effectiveType, saveData: conn.saveData }; } return metrics; } } /** * Adaptive Performance Optimizer */ class AdaptivePerformanceOptimizer { private monitor: PerformanceMonitor; private monitorInterval?: number; constructor(monitor: PerformanceMonitor) { this.monitor = monitor; } public start(callback: () => void): void { this.monitorInterval = window.setInterval(() => { callback(); }, 30000); // Check every 30 seconds } public stop(): void { if (this.monitorInterval) { clearInterval(this.monitorInterval); } } public getRecommendation(): { shouldThrottle: boolean; canIncrease: boolean; reason: string | null; } { const metrics = this.monitor.getMetrics(); const recommendation = { shouldThrottle: false, canIncrease: false, reason: null as string | null }; // Check memory pressure if (metrics.memory && metrics.memory.usagePercent > 90) { recommendation.shouldThrottle = true; recommendation.reason = 'high_memory_usage'; return recommendation; } // Check network if (metrics.connection) { if (metrics.connection.saveData || metrics.connection.effectiveType === 'slow-2g' || metrics.connection.effectiveType === '2g') { recommendation.shouldThrottle = true; recommendation.reason = 'poor_network'; return recommendation; } if (metrics.connection.effectiveType === '4g' && (!metrics.memory || metrics.memory.usagePercent < 50)) { recommendation.canIncrease = true; recommendation.reason = 'good_conditions'; } } return recommendation; } }