Files
gridpilot.gg/apps/website/lib/infrastructure/ErrorReplay.ts
2026-01-01 16:40:14 +01:00

404 lines
11 KiB
TypeScript

/**
* 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;
appMode: 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 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',
appMode: process.env.NEXT_PUBLIC_GRIDPILOT_MODE || 'pre-launch',
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 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}
App Mode: ${replay.metadata.appMode}
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(),
};
}