/** * Error Queue - Batched Error Reporting with Rate Limiting * * Optimizes error reporting by: * 1. Batching multiple errors together * 2. Rate limiting duplicate errors * 3. Deduplicating similar errors * 4. Backpressure handling */ import type { ErrorReportPayload } from '../types.js'; import { Logger } from './Logger.js'; /** * Error queue configuration */ export interface ErrorQueueConfig { /** Maximum batch size before auto-flush */ maxBatchSize?: number; /** Flush interval in milliseconds */ flushInterval?: number; /** Enable error deduplication */ deduplication?: boolean; /** Deduplication window in milliseconds */ deduplicationWindow?: number; /** Maximum queue size (prevent memory leaks) */ maxQueueSize?: number; } /** * Error fingerprint for deduplication */ interface ErrorFingerprint { type: string; message: string; lastSeen: number; } /** * ErrorQueue class for batched and rate-limited error reporting */ export class ErrorQueue { private readonly config: Required; private queue: ErrorReportPayload[] = []; private flushTimer: ReturnType | null = null; private errorFingerprints: Map = new Map(); private cleanupTimer: ReturnType | null = null; private readonly logger: Logger; constructor( config: ErrorQueueConfig = {}, private readonly sendBatch: (errors: ErrorReportPayload[]) => Promise, debug: boolean = false ) { this.logger = new Logger(debug, 'WioEX:ErrorQueue'); this.config = { maxBatchSize: config.maxBatchSize ?? 10, flushInterval: config.flushInterval ?? 5000, deduplication: config.deduplication ?? true, deduplicationWindow: config.deduplicationWindow ?? 60000, // 1 minute maxQueueSize: config.maxQueueSize ?? 100, }; // Start cleanup timer for old fingerprints if (this.config.deduplication) { this.startCleanupTimer(); } } /** * Add error to queue */ public async add(error: ErrorReportPayload): Promise { // Check deduplication if (this.config.deduplication && this.isDuplicate(error)) { return; // Skip duplicate error } // Check queue size limit if (this.queue.length >= this.config.maxQueueSize) { // Queue full - flush immediately to prevent memory leak await this.flush(); } // Add to queue this.queue.push(error); // Update fingerprint if (this.config.deduplication) { this.updateFingerprint(error); } // Auto-flush if batch size reached if (this.queue.length >= this.config.maxBatchSize) { await this.flush(); } else { // Schedule flush if not already scheduled this.scheduleFlush(); } } /** * Flush queue immediately */ public async flush(): Promise { // Cancel scheduled flush this.cancelScheduledFlush(); // Nothing to flush if (this.queue.length === 0) { return; } // Get errors to send const errorsToSend = this.queue.splice(0, this.queue.length); // Send batch (fire and forget, catch errors silently) try { await this.sendBatch(errorsToSend); } catch (error: unknown) { // Silently fail - don't throw errors from error queue this.logger.warn('Failed to send error batch:', error); } } /** * Destroy queue and cleanup resources */ public destroy(): void { this.cancelScheduledFlush(); this.stopCleanupTimer(); this.queue = []; this.errorFingerprints.clear(); } /** * Get current queue size */ public size(): number { return this.queue.length; } /** * Check if error is duplicate */ private isDuplicate(error: ErrorReportPayload): boolean { const key = this.getFingerprint(error); const fingerprint = this.errorFingerprints.get(key); if (fingerprint === undefined) { return false; // First time seeing this error } // Check if within deduplication window const now = Date.now(); const timeSinceLastSeen = now - fingerprint.lastSeen; return timeSinceLastSeen < this.config.deduplicationWindow; } /** * Update error fingerprint */ private updateFingerprint(error: ErrorReportPayload): void { const key = this.getFingerprint(error); this.errorFingerprints.set(key, { type: error.error.type, message: error.error.message, lastSeen: Date.now(), }); } /** * Get error fingerprint (hash key) */ private getFingerprint(error: ErrorReportPayload): string { // Simple fingerprint: type + message (first 100 chars) const message = error.error.message.substring(0, 100); return `${error.error.type}:${message}`; } /** * Schedule flush */ private scheduleFlush(): void { if (this.flushTimer !== null) { return; // Already scheduled } this.flushTimer = setTimeout(() => { void this.flush(); }, this.config.flushInterval); } /** * Cancel scheduled flush */ private cancelScheduledFlush(): void { if (this.flushTimer !== null) { clearTimeout(this.flushTimer); this.flushTimer = null; } } /** * Start cleanup timer for old fingerprints */ private startCleanupTimer(): void { // Clean up every 10 seconds for better memory management this.cleanupTimer = setInterval(() => { this.cleanupOldFingerprints(); }, 10000); } /** * Stop cleanup timer */ private stopCleanupTimer(): void { if (this.cleanupTimer !== null) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } } /** * Clean up old fingerprints to prevent memory leak */ private cleanupOldFingerprints(): void { const now = Date.now(); const cutoff = now - this.config.deduplicationWindow; const initialSize = this.errorFingerprints.size; for (const [key, fingerprint] of this.errorFingerprints.entries()) { if (fingerprint.lastSeen < cutoff) { this.errorFingerprints.delete(key); } } // Force cleanup if map gets too large (high-frequency error protection) if (this.errorFingerprints.size > 1000) { const entries = Array.from(this.errorFingerprints.entries()); entries.sort(([, a], [, b]) => b.lastSeen - a.lastSeen); this.errorFingerprints.clear(); // Keep only the 500 most recent entries for (let i = 0; i < Math.min(500, entries.length); i++) { const [key, fingerprint] = entries[i]; this.errorFingerprints.set(key, fingerprint); } } const cleaned = initialSize - this.errorFingerprints.size; if (cleaned > 0) { this.logger.debug(`Cleaned ${cleaned} old error fingerprints`); } } }