/** * Error Replay System * Allows developers to replay errors with the exact same context */ import { getGlobalErrorHandler } from './GlobalErrorHandler'; import { getGlobalApiLogger } from './ApiRequestLogger'; import { ApiError } from '../api/base/ApiError'; export interface ReplayContext { timestamp: string; error: { message: string; type: string; stack?: string; context?: unknown; }; environment: { userAgent: string; url: string; viewport: { width: number; height: number }; language: string; platform: string; }; apiRequests: Array<{ url: string; method: string; duration?: number; status?: number; error?: string; }>; reactErrors: Array<{ message: string; componentStack?: string; }>; metadata: { mode: string; timestamp: string; replayId: string; }; } export class ErrorReplaySystem { private replayHistory: ReplayContext[] = []; private readonly MAX_REPLAYS = 20; /** * Capture current state for replay */ captureReplay(error: Error | ApiError, additionalContext: Record = {}): ReplayContext { const globalHandler = getGlobalErrorHandler(); const apiLogger = getGlobalApiLogger(); const replayId = `replay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const replay: ReplayContext = { timestamp: new Date().toISOString(), error: { message: error.message, type: error instanceof ApiError ? error.type : error.name || 'Error', stack: error.stack, context: error instanceof ApiError ? error.context : additionalContext, }, environment: { userAgent: navigator.userAgent, url: window.location.href, viewport: { width: window.innerWidth, height: window.innerHeight, }, language: navigator.language, platform: navigator.platform, }, apiRequests: apiLogger.getHistory().slice(-10).map(log => ({ url: log.url, method: log.method, duration: log.response?.duration, status: log.response?.status, error: log.error?.message, })), reactErrors: (window as any).__GRIDPILOT_REACT_ERRORS__?.slice(-5).map((e: any) => ({ message: e.error?.message || 'Unknown React error', componentStack: e.componentStack, })) || [], metadata: { mode: process.env.NODE_ENV || 'unknown', timestamp: new Date().toISOString(), replayId, }, }; this.replayHistory.push(replay); // Keep only last N replays if (this.replayHistory.length > this.MAX_REPLAYS) { this.replayHistory = this.replayHistory.slice(-this.MAX_REPLAYS); } // Store in localStorage for persistence across sessions this.persistReplay(replay); return replay; } /** * Persist replay to localStorage */ private persistReplay(replay: ReplayContext): void { try { const key = `gridpilot_replay_${replay.metadata.replayId}`; localStorage.setItem(key, JSON.stringify(replay)); // Also add to index const indexKey = 'gridpilot_replay_index'; const existing = JSON.parse(localStorage.getItem(indexKey) || '[]'); existing.push({ id: replay.metadata.replayId, timestamp: replay.timestamp, error: replay.error.message, type: replay.error.type, }); localStorage.setItem(indexKey, JSON.stringify(existing.slice(-this.MAX_REPLAYS))); } catch (e) { // Storage might be full or disabled console.warn('Failed to persist replay:', e); } } /** * Get all replays */ getReplays(): ReplayContext[] { return [...this.replayHistory]; } /** * Get replay by ID */ getReplay(replayId: string): ReplayContext | null { // Check memory first const inMemory = this.replayHistory.find(r => r.metadata.replayId === replayId); if (inMemory) return inMemory; // Check localStorage try { const key = `gridpilot_replay_${replayId}`; const stored = localStorage.getItem(key); if (stored) { return JSON.parse(stored); } } catch (e) { console.warn('Failed to load replay from storage:', e); } return null; } /** * Replay an error by re-executing it with the same context */ async replay(replayId: string): Promise { const replay = this.getReplay(replayId); if (!replay) { console.error(`Replay ${replayId} not found`); return; } console.groupCollapsed(`%c[REPLAY] Replaying error: ${replay.error.message}`, 'color: #00ff88; font-weight: bold; font-size: 14px;' ); console.log('Original Error:', replay.error); console.log('Environment:', replay.environment); console.log('API Requests:', replay.apiRequests); console.log('React Errors:', replay.reactErrors); console.log('Metadata:', replay.metadata); // Recreate the error const error = replay.error.type === 'ApiError' ? new ApiError( replay.error.message, (replay.error.context as any)?.type || 'UNKNOWN_ERROR', { timestamp: replay.timestamp, ...(replay.error.context as any), replayId: replay.metadata.replayId, } ) : new Error(replay.error.message); if (replay.error.stack) { error.stack = replay.error.stack; } // Report through global handler const globalHandler = getGlobalErrorHandler(); globalHandler.report(error, { source: 'replay', replayId: replay.metadata.replayId, originalTimestamp: replay.timestamp, environment: replay.environment, apiRequests: replay.apiRequests, reactErrors: replay.reactErrors, }); console.groupEnd(); // Show a notification if (typeof window !== 'undefined') { const notificationEvent = new CustomEvent('gridpilot-notification', { detail: { type: 'replay_success', title: 'Error Replay Complete', message: `Replayed error from ${new Date(replay.timestamp).toLocaleString()}`, variant: 'toast', } }); window.dispatchEvent(notificationEvent); } } /** * Export replay data */ exportReplay(replayId: string, format: 'json' | 'text' = 'json'): string { const replay = this.getReplay(replayId); if (!replay) { return 'Replay not found'; } if (format === 'json') { return JSON.stringify(replay, null, 2); } // Text format return ` Error Replay Report =================== Replay ID: ${replay.metadata.replayId} Timestamp: ${replay.timestamp} ERROR ----- Type: ${replay.error.type} Message: ${replay.error.message} Stack: ${replay.error.stack || 'N/A'} ENVIRONMENT ----------- User Agent: ${replay.environment.userAgent} URL: ${replay.environment.url} Viewport: ${replay.environment.viewport.width}x${replay.environment.viewport.height} Language: ${replay.environment.language} Platform: ${replay.environment.platform} API REQUESTS (${replay.apiRequests.length}) ----------------${replay.apiRequests.map(req => ` ${req.method} ${req.url} ${req.status ? `Status: ${req.status}` : ''} ${req.duration ? `Duration: ${req.duration}ms` : ''} ${req.error ? `Error: ${req.error}` : ''} `).join('')} REACT ERRORS (${replay.reactErrors.length}) ----------------${replay.reactErrors.map(react => ` ${react.message} ${react.componentStack ? `Component Stack: ${react.componentStack}` : ''} `).join('')} METADATA -------- Mode: ${replay.metadata.mode} Original Timestamp: ${replay.metadata.timestamp} `; } /** * Delete replay */ deleteReplay(replayId: string): void { // Remove from memory this.replayHistory = this.replayHistory.filter(r => r.metadata.replayId !== replayId); // Remove from localStorage try { localStorage.removeItem(`gridpilot_replay_${replayId}`); // Update index const indexKey = 'gridpilot_replay_index'; const existing = JSON.parse(localStorage.getItem(indexKey) || '[]'); const updated = existing.filter((r: any) => r.id !== replayId); localStorage.setItem(indexKey, JSON.stringify(updated)); } catch (e) { console.warn('Failed to delete replay:', e); } } /** * Clear all replays */ clearAll(): void { this.replayHistory = []; // Clear from localStorage try { const indexKey = 'gridpilot_replay_index'; const existing = JSON.parse(localStorage.getItem(indexKey) || '[]'); existing.forEach((r: any) => { localStorage.removeItem(`gridpilot_replay_${r.id}`); }); localStorage.removeItem(indexKey); } catch (e) { console.warn('Failed to clear replays:', e); } } /** * Get replay index (summary of all available replays) */ getReplayIndex(): Array<{ id: string; timestamp: string; error: string; type: string }> { try { const indexKey = 'gridpilot_replay_index'; const stored = localStorage.getItem(indexKey); if (stored) { return JSON.parse(stored); } } catch (e) { console.warn('Failed to load replay index:', e); } // Return in-memory index return this.replayHistory.map(r => ({ id: r.metadata.replayId, timestamp: r.timestamp, error: r.error.message, type: r.error.type, })); } /** * Auto-capture errors for replay */ autoCapture(error: Error | ApiError, context: Record = {}): void { // Only capture in development or if explicitly enabled if (process.env.NODE_ENV !== 'development') { return; } const replay = this.captureReplay(error, context); if (console) { console.log('%c[REPLAY] Captured error for replay', 'color: #00ff88; font-weight: bold;', { replayId: replay.metadata.replayId, message: replay.error.message, }); } } } /** * Global instance accessor */ let globalReplayInstance: ErrorReplaySystem | null = null; export function getGlobalReplaySystem(): ErrorReplaySystem { if (!globalReplayInstance) { globalReplayInstance = new ErrorReplaySystem(); } return globalReplayInstance; } /** * Initialize replay system */ export function initializeReplaySystem(): ErrorReplaySystem { const system = new ErrorReplaySystem(); globalReplayInstance = system; return system; } /** * React hook for error replay */ export function useErrorReplay() { const system = getGlobalReplaySystem(); return { capture: (error: Error | ApiError, context?: Record) => system.captureReplay(error, context), replay: (replayId: string) => system.replay(replayId), getReplays: () => system.getReplays(), getReplay: (replayId: string) => system.getReplay(replayId), exportReplay: (replayId: string, format?: 'json' | 'text') => system.exportReplay(replayId, format), deleteReplay: (replayId: string) => system.deleteReplay(replayId), clearAll: () => system.clearAll(), getReplayIndex: () => system.getReplayIndex(), }; }