/** * Error Tracking Module * * Provides centralized error tracking and reporting. * Features: * - Error collection and grouping * - Error reporting to backend * - Error analytics * - Integration with ErrorBoundary * - Source map support * - Error filtering and sampling */ import { Logger } from '../../core/logger.js'; /** * ErrorTracker - Centralized error tracking */ export class ErrorTracker { constructor(config = {}) { this.config = { endpoint: config.endpoint || '/api/errors', enabled: config.enabled ?? true, sampleRate: config.sampleRate ?? 1.0, // 0.0 to 1.0 maxErrors: config.maxErrors || 100, groupingWindow: config.groupingWindow || 60000, // 1 minute includeStack: config.includeStack ?? true, includeContext: config.includeContext ?? true, includeUserAgent: config.includeUserAgent ?? true, includeUrl: config.includeUrl ?? true, filters: config.filters || [], beforeSend: config.beforeSend || null, ...config }; this.errors = []; this.errorGroups = new Map(); // Map this.reportQueue = []; this.isReporting = false; // Initialize error handlers if (this.config.enabled) { this.init(); } Logger.info('[ErrorTracker] Initialized', { enabled: this.config.enabled, endpoint: this.config.endpoint, sampleRate: this.config.sampleRate }); } /** * Create a new ErrorTracker instance */ static create(config = {}) { return new ErrorTracker(config); } /** * Initialize error tracking */ init() { // Global error handler window.addEventListener('error', (event) => { this.captureException(event.error || new Error(event.message), { type: 'unhandled', filename: event.filename, lineno: event.lineno, colno: event.colno }); }); // Unhandled promise rejection handler window.addEventListener('unhandledrejection', (event) => { this.captureException(event.reason, { type: 'unhandledrejection' }); }); // Report errors periodically this.startReporting(); } /** * Capture an exception */ captureException(error, context = {}) { if (!this.config.enabled) { return; } // Sample rate check if (Math.random() > this.config.sampleRate) { Logger.debug('[ErrorTracker] Error sampled out'); return; } // Apply filters if (this.shouldFilter(error, context)) { Logger.debug('[ErrorTracker] Error filtered out'); return; } // Create error data const errorData = this.createErrorData(error, context); // Apply beforeSend hook if (this.config.beforeSend) { const modified = this.config.beforeSend(errorData); if (modified === null || modified === false) { return; // Blocked by beforeSend } if (modified) { Object.assign(errorData, modified); } } // Add to errors array this.errors.push(errorData); // Limit errors array size if (this.errors.length > this.config.maxErrors) { this.errors.shift(); } // Group errors this.groupError(errorData); // Queue for reporting this.reportQueue.push(errorData); Logger.debug('[ErrorTracker] Error captured', errorData); // Trigger error event this.triggerErrorEvent(errorData); } /** * Create error data object */ createErrorData(error, context = {}) { const errorData = { message: error?.message || String(error), name: error?.name || 'Error', stack: this.config.includeStack ? this.getStackTrace(error) : undefined, timestamp: Date.now(), type: context.type || 'error', context: this.config.includeContext ? { ...context, userAgent: this.config.includeUserAgent ? navigator.userAgent : undefined, url: this.config.includeUrl ? window.location.href : undefined, referrer: document.referrer || undefined, viewport: { width: window.innerWidth, height: window.innerHeight } } : context }; // Add additional error properties if (error && typeof error === 'object') { Object.keys(error).forEach(key => { if (!['message', 'name', 'stack'].includes(key)) { errorData[key] = error[key]; } }); } return errorData; } /** * Get stack trace */ getStackTrace(error) { if (error?.stack) { return error.stack; } try { throw new Error(); } catch (e) { return e.stack || 'No stack trace available'; } } /** * Generate error fingerprint for grouping */ generateFingerprint(errorData) { // Group by message and stack trace (first few lines) const message = errorData.message || ''; const stack = errorData.stack || ''; const stackLines = stack.split('\n').slice(0, 3).join('\n'); return `${errorData.name}:${message}:${stackLines}`; } /** * Group errors */ groupError(errorData) { const fingerprint = this.generateFingerprint(errorData); const now = Date.now(); if (!this.errorGroups.has(fingerprint)) { this.errorGroups.set(fingerprint, { fingerprint, count: 0, firstSeen: now, lastSeen: now, errors: [] }); } const group = this.errorGroups.get(fingerprint); group.count++; group.lastSeen = now; group.errors.push(errorData); // Limit errors in group if (group.errors.length > 10) { group.errors.shift(); } // Clean up old groups this.cleanupGroups(now); } /** * Clean up old error groups */ cleanupGroups(now) { const cutoff = now - this.config.groupingWindow; for (const [fingerprint, group] of this.errorGroups.entries()) { if (group.lastSeen < cutoff) { this.errorGroups.delete(fingerprint); } } } /** * Check if error should be filtered */ shouldFilter(error, context) { for (const filter of this.config.filters) { if (typeof filter === 'function') { if (filter(error, context) === false) { return true; // Filter out } } else if (filter instanceof RegExp) { const message = error?.message || String(error); if (filter.test(message)) { return true; // Filter out } } } return false; } /** * Start reporting errors */ startReporting() { // Report errors periodically setInterval(() => { this.flushReports(); }, 5000); // Every 5 seconds } /** * Flush error reports to backend */ async flushReports() { if (this.isReporting || this.reportQueue.length === 0) { return; } this.isReporting = true; try { const errorsToReport = [...this.reportQueue]; this.reportQueue = []; if (errorsToReport.length === 0) { return; } // Send errors to backend const response = await fetch(this.config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ errors: errorsToReport, errorGroups: Array.from(this.errorGroups.values()).map(group => ({ fingerprint: group.fingerprint, count: group.count, firstSeen: group.firstSeen, lastSeen: group.lastSeen })) }) }); if (!response.ok) { throw new Error(`Error reporting failed: ${response.status}`); } Logger.debug('[ErrorTracker] Errors reported', { count: errorsToReport.length }); } catch (error) { Logger.error('[ErrorTracker] Failed to report errors', error); // Re-queue errors for retry // Note: In production, you might want to limit retries } finally { this.isReporting = false; } } /** * Manually report errors */ async report() { await this.flushReports(); } /** * Get error groups */ getErrorGroups() { return Array.from(this.errorGroups.values()); } /** * Get errors */ getErrors() { return [...this.errors]; } /** * Clear errors */ clearErrors() { this.errors = []; this.errorGroups.clear(); this.reportQueue = []; } /** * Trigger error event */ triggerErrorEvent(errorData) { const event = new CustomEvent('error-tracker:error', { detail: errorData, bubbles: true }); window.dispatchEvent(event); } /** * Destroy error tracker */ destroy() { // Flush remaining errors this.flushReports(); // Clear data this.clearErrors(); Logger.info('[ErrorTracker] Destroyed'); } } /** * Create a global error tracker instance */ let globalErrorTracker = null; /** * Get or create global error tracker */ export function getGlobalErrorTracker(config = {}) { if (!globalErrorTracker) { globalErrorTracker = ErrorTracker.create(config); } return globalErrorTracker; }