UNPKG

@tinytapanalytics/sdk

Version:

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

412 lines (356 loc) 11.1 kB
/** * Event Queue for TinyTapAnalytics SDK * Handles batching, persistence, and intelligent retry logic for events */ import { TinyTapAnalyticsConfig, QueuedEvent } from '../types/index'; import { NetworkManager } from './NetworkManager'; export class EventQueue { private config: TinyTapAnalyticsConfig; private networkManager: NetworkManager; private queue: QueuedEvent[] = []; private isProcessing = false; private processingInterval: number | null = null; private persistenceKey = 'tinytapanalytics_event_queue'; private readonly maxQueueSize = 1000; private readonly maxRetries = 3; private readonly retentionHours = 24; constructor(config: TinyTapAnalyticsConfig, networkManager: NetworkManager) { this.config = config; this.networkManager = networkManager; this.loadPersistedEvents(); this.startProcessing(); this.setupUnloadHandler(); } /** * Enqueue an event for processing */ public async enqueue(payload: any, priority: 'low' | 'normal' | 'high' = 'normal'): Promise<void> { const event: QueuedEvent = { id: this.generateEventId(), payload, queuedAt: Date.now(), attempts: 0, priority }; // Check if we can send immediately (high priority + good network) if (priority === 'high' && this.networkManager.canSendData() && this.queue.length === 0) { try { if (this.config.useFallbackStrategy) { await this.networkManager.sendWithFallback(payload); } else { await this.networkManager.sendEvent(payload); } return; // Success, no need to queue } catch (error) { // Failed immediate send, queue for retry } } // Add to queue this.queue.push(event); // Sort queue by priority and timestamp this.sortQueue(); // Enforce queue size limit if (this.queue.length > this.maxQueueSize) { // Remove oldest low-priority events this.queue = this.queue.filter((e, index) => e.priority !== 'low' || index >= this.queue.length - this.maxQueueSize + 100 ).slice(-this.maxQueueSize); } // Persist queue this.persistQueue(); // Trigger immediate processing for high priority events if (priority === 'high' && !this.isProcessing) { this.processQueue(); } } /** * Flush all events immediately */ public async flush(): Promise<void> { if (this.queue.length === 0) { return; } // Try to send all events immediately const events = [...this.queue]; this.queue = []; try { // Send in batches const batchSize = this.config.batchSize || 10; const batches = this.chunkArray(events.map(e => e.payload), batchSize); for (const batch of batches) { if (batch.length === 1) { await this.networkManager.sendEvent(batch[0]); } else { await this.networkManager.sendBatch(batch); } } // Clear persistence this.clearPersistedQueue(); } catch (error) { // Restore queue on failure this.queue = events.concat(this.queue); this.sortQueue(); throw error; } } /** * Get queue statistics */ public getStats(): { queueSize: number; isProcessing: boolean; highPriorityCount: number; failedEventCount: number; } { return { queueSize: this.queue.length, isProcessing: this.isProcessing, highPriorityCount: this.queue.filter(e => e.priority === 'high').length, failedEventCount: this.queue.filter(e => e.attempts >= this.maxRetries).length }; } /** * Start periodic queue processing */ private startProcessing(): void { const interval = this.config.flushInterval || 5000; this.processingInterval = window.setInterval(() => { if (!this.isProcessing && this.queue.length > 0) { this.processQueue(); } }, interval); } /** * Process the event queue */ private async processQueue(): Promise<void> { if (this.isProcessing || this.queue.length === 0) { return; } // Check network conditions if (!this.networkManager.canSendData()) { return; } this.isProcessing = true; try { const batchSize = this.config.batchSize || 10; const eventsToProcess = this.queue.splice(0, batchSize); if (eventsToProcess.length === 0) { return; } // Separate by retry status const freshEvents = eventsToProcess.filter(e => e.attempts === 0); const retryEvents = eventsToProcess.filter(e => e.attempts > 0); // Process fresh events in batch if (freshEvents.length > 0) { try { if (this.config.useFallbackStrategy) { // Use fallback strategy - send individually for better reliability for (const event of freshEvents) { try { await this.networkManager.sendWithFallback(event.payload); } catch (error) { this.handleFailedEvent(event, error); } } } else { // Use regular batching if (freshEvents.length === 1) { await this.networkManager.sendEvent(freshEvents[0].payload); } else { await this.networkManager.sendBatch(freshEvents.map(e => e.payload)); } } // Success - events are processed } catch (error) { // Failed - mark for retry if (!this.config.useFallbackStrategy) { this.handleFailedEvents(freshEvents, error); } } } // Process retry events individually for (const event of retryEvents) { try { if (this.config.useFallbackStrategy) { await this.networkManager.sendWithFallback(event.payload); } else { await this.networkManager.sendEvent(event.payload); } // Success - event is processed } catch (error) { this.handleFailedEvent(event, error); } } // Update persistence this.persistQueue(); } finally { this.isProcessing = false; } // Continue processing if queue still has events if (this.queue.length > 0) { setTimeout(() => this.processQueue(), 1000); } } /** * Handle failed events - add back to queue with retry logic */ private handleFailedEvents(events: QueuedEvent[], error: any): void { events.forEach(event => this.handleFailedEvent(event, error)); } /** * Handle a single failed event */ private handleFailedEvent(event: QueuedEvent, error: any): void { event.attempts++; if (event.attempts < this.maxRetries) { // Calculate next retry time with exponential backoff const backoffMs = Math.min( 1000 * Math.pow(2, event.attempts) + Math.random() * 1000, 30000 // Max 30 seconds ); event.nextRetry = Date.now() + backoffMs; // Add back to queue this.queue.push(event); this.sortQueue(); } else { // Max retries exceeded - drop event if (this.config.debug) { console.warn('TinyTapAnalytics: Event dropped after max retries:', event.id, error); } } } /** * Sort queue by priority and retry time */ private sortQueue(): void { this.queue.sort((a, b) => { // First by priority const priorityOrder = { high: 3, normal: 2, low: 1 }; const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority]; if (priorityDiff !== 0) { return priorityDiff; } // Then by retry time (ready to retry first) const aReady = !a.nextRetry || Date.now() >= a.nextRetry; const bReady = !b.nextRetry || Date.now() >= b.nextRetry; if (aReady && !bReady) { return -1; } if (!aReady && bReady) { return 1; } // Finally by queue time (oldest first) return a.queuedAt - b.queuedAt; }); } /** * Load persisted events from storage */ private loadPersistedEvents(): void { try { const stored = localStorage.getItem(this.persistenceKey); if (stored) { const data = JSON.parse(stored); if (Array.isArray(data.events)) { // Filter out expired events const cutoffTime = Date.now() - (this.retentionHours * 60 * 60 * 1000); this.queue = data.events.filter((event: QueuedEvent) => event.queuedAt > cutoffTime ); this.sortQueue(); if (this.config.debug && this.queue.length > 0) { console.log(`TinyTapAnalytics: Restored ${this.queue.length} persisted events`); } } } } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: Failed to load persisted events:', error); } } } /** * Persist queue to storage */ private persistQueue(): void { try { const data = { timestamp: Date.now(), events: this.queue }; localStorage.setItem(this.persistenceKey, JSON.stringify(data)); } catch (error) { // Storage might be full or disabled if (this.config.debug) { console.warn('TinyTapAnalytics: Failed to persist queue:', error); } } } /** * Clear persisted queue */ private clearPersistedQueue(): void { try { localStorage.removeItem(this.persistenceKey); } catch (error) { // Ignore } } /** * Setup page unload handler for final flush */ private setupUnloadHandler(): void { const handleUnload = () => { if (this.queue.length > 0) { // Try to send critical events via sendBeacon const criticalEvents = this.queue .filter(e => e.priority === 'high') .slice(0, 5) // Limit to 5 most critical .map(e => e.payload); criticalEvents.forEach(event => { this.networkManager.sendBeacon(event); }); // Persist remaining events for next session this.persistQueue(); } }; window.addEventListener('beforeunload', handleUnload); window.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { handleUnload(); } }); } /** * Chunk array into smaller batches */ private chunkArray<T>(array: T[], chunkSize: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < array.length; i += chunkSize) { chunks.push(array.slice(i, i + chunkSize)); } return chunks; } /** * Generate unique event ID */ private generateEventId(): string { return 'evt_' + Date.now().toString(36) + '_' + Math.random().toString(36).substring(2, 11); } /** * Clean up and stop processing */ public destroy(): void { if (this.processingInterval) { clearInterval(this.processingInterval); this.processingInterval = null; } // Final flush attempt if (this.queue.length > 0) { this.persistQueue(); } this.queue = []; this.isProcessing = false; } }