@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
text/typescript
/**
* 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;
}
}