399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
/**
|
|
* Error Replay System
|
|
* Allows developers to replay errors with the exact same context
|
|
*/
|
|
|
|
import { ApiError } from '../gateways/api/base/ApiError';
|
|
import { getGlobalApiLogger } from './ApiRequestLogger';
|
|
import { getGlobalErrorHandler } from './GlobalErrorHandler';
|
|
|
|
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<string, unknown> = {}): ReplayContext {
|
|
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 { __GRIDPILOT_REACT_ERRORS__?: Array<{ error?: { message?: string }; componentStack?: string }> }).__GRIDPILOT_REACT_ERRORS__?.slice(-5).map((e) => ({
|
|
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<void> {
|
|
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 { type?: string } | undefined)?.type as import('../gateways/api/base/ApiError').ApiErrorType) || 'UNKNOWN_ERROR',
|
|
{
|
|
timestamp: replay.timestamp,
|
|
...(replay.error.context as Record<string, unknown> | undefined),
|
|
}
|
|
)
|
|
: 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<string, unknown> = {}): 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<string, unknown>) =>
|
|
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(),
|
|
};
|
|
} |