dev experience
This commit is contained in:
404
apps/website/lib/infrastructure/ErrorReplay.ts
Normal file
404
apps/website/lib/infrastructure/ErrorReplay.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user