dev experience

This commit is contained in:
2026-01-01 16:40:14 +01:00
parent 17d715f259
commit df7e5db5ba
12 changed files with 3745 additions and 6 deletions

View File

@@ -0,0 +1,580 @@
/**
* API Request/Response Logger for Maximum Developer Transparency
* Captures all API requests, responses, and timing information
*/
import { getGlobalErrorHandler } from './GlobalErrorHandler';
import { ConsoleLogger } from './logging/ConsoleLogger';
export interface ApiRequestLog {
id: string;
timestamp: string;
url: string;
method: string;
headers?: Record<string, string>;
body?: unknown;
response?: {
status: number;
statusText: string;
headers: Headers;
body: unknown;
duration: number;
};
error?: {
message: string;
type: string;
stack?: string;
};
metadata: {
timestamp: string;
duration: number;
retryCount: number;
wasRetry: boolean;
};
}
export interface ApiRequestLoggerOptions {
/**
* Log all requests to console
*/
logToConsole?: boolean;
/**
* Log request bodies
*/
logBodies?: boolean;
/**
* Log response bodies
*/
logResponses?: boolean;
/**
* Maximum number of requests to keep in history
*/
maxHistory?: number;
/**
* Filter out certain requests (e.g., health checks)
*/
requestFilter?: (url: string, method: string) => boolean;
/**
* Mask sensitive data in logs
*/
maskSensitiveData?: boolean;
/**
* Sensitive keys to mask
*/
sensitiveKeys?: string[];
}
export class ApiRequestLogger {
private options: ApiRequestLoggerOptions;
private logger: ConsoleLogger;
private requestHistory: ApiRequestLog[] = [];
private requestCounter = 0;
private activeRequests = new Map<string, ApiRequestLog>();
constructor(options: ApiRequestLoggerOptions = {}) {
this.options = {
logToConsole: options.logToConsole ?? process.env.NODE_ENV === 'development',
logBodies: options.logBodies ?? process.env.NODE_ENV === 'development',
logResponses: options.logResponses ?? process.env.NODE_ENV === 'development',
maxHistory: options.maxHistory ?? 100,
requestFilter: options.requestFilter,
maskSensitiveData: options.maskSensitiveData ?? true,
sensitiveKeys: options.sensitiveKeys ?? ['password', 'token', 'authorization', 'cookie', 'secret', 'key'],
...options,
};
this.logger = new ConsoleLogger();
}
/**
* Generate unique request ID
*/
private generateId(): string {
this.requestCounter++;
return `req_${Date.now()}_${this.requestCounter}`;
}
/**
* Check if request should be logged
*/
private shouldLog(url: string, method: string): boolean {
if (this.options.requestFilter) {
return this.options.requestFilter(url, method);
}
// Filter out health checks and metrics by default
const filteredPatterns = ['/health', '/metrics', '/api/health', '/api/metrics'];
return !filteredPatterns.some(pattern => url.includes(pattern));
}
/**
* Mask sensitive data in objects
*/
private maskSensitiveData(data: unknown): unknown {
if (!this.options.maskSensitiveData) return data;
if (typeof data === 'string') {
// Check if string contains sensitive data
const lower = data.toLowerCase();
if (this.options.sensitiveKeys!.some(key => lower.includes(key))) {
return '[MASKED]';
}
return data;
}
if (Array.isArray(data)) {
return data.map(item => this.maskSensitiveData(item));
}
if (typeof data === 'object' && data !== null) {
const masked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const lowerKey = key.toLowerCase();
if (this.options.sensitiveKeys!.some(sensitive => lowerKey.includes(sensitive))) {
masked[key] = '[MASKED]';
} else {
masked[key] = this.maskSensitiveData(value);
}
}
return masked;
}
return data;
}
/**
* Log request start
*/
logRequest(url: string, method: string, headers?: Record<string, string>, body?: unknown): string {
if (!this.shouldLog(url, method)) {
return '';
}
const id = this.generateId();
const timestamp = new Date().toISOString();
const log: ApiRequestLog = {
id,
timestamp,
url,
method,
headers: this.maskSensitiveData(headers) as Record<string, string> | undefined,
body: this.maskSensitiveData(body),
metadata: {
timestamp,
duration: 0,
retryCount: 0,
wasRetry: false,
},
};
this.activeRequests.set(id, log);
if (this.options.logToConsole) {
console.groupCollapsed(`%c[API REQUEST] ${method} ${url}`, 'color: #00aaff; font-weight: bold;');
console.log('Request ID:', id);
console.log('Timestamp:', timestamp);
if (headers) console.log('Headers:', log.headers);
if (body && this.options.logBodies) console.log('Body:', log.body);
console.groupEnd();
}
return id;
}
/**
* Log successful response
*/
logResponse(id: string, response: Response, body: unknown, duration: number): void {
const log = this.activeRequests.get(id);
if (!log) return;
log.response = {
status: response.status,
statusText: response.statusText,
headers: response.headers,
body: this.maskSensitiveData(body),
duration,
};
log.metadata.duration = duration;
// Move from active to history
this.activeRequests.delete(id);
this.addToHistory(log);
if (this.options.logToConsole) {
const statusColor = response.ok ? '#00ff88' : '#ff4444';
console.groupCollapsed(`%c[API RESPONSE] ${log.method} ${log.url} - ${response.status}`, `color: ${statusColor}; font-weight: bold;`);
console.log('Request ID:', id);
console.log('Duration:', `${duration.toFixed(2)}ms`);
console.log('Status:', `${response.status} ${response.statusText}`);
if (this.options.logResponses) {
console.log('Response Body:', log.response.body);
}
console.groupEnd();
}
}
/**
* Log error response
*/
logError(id: string, error: Error, duration: number): void {
const log = this.activeRequests.get(id);
if (!log) return;
log.error = {
message: error.message,
type: error.name,
stack: error.stack,
};
log.metadata.duration = duration;
// Move from active to history
this.activeRequests.delete(id);
this.addToHistory(log);
if (this.options.logToConsole) {
console.groupCollapsed(`%c[API ERROR] ${log.method} ${log.url}`, 'color: #ff4444; font-weight: bold;');
console.log('Request ID:', id);
console.log('Duration:', `${duration.toFixed(2)}ms`);
console.log('Error:', error.message);
console.log('Type:', error.name);
if (error.stack) {
console.log('Stack:', error.stack);
}
console.groupEnd();
}
// Report to global error handler
const globalHandler = getGlobalErrorHandler();
globalHandler.report(error, {
source: 'api_request',
url: log.url,
method: log.method,
duration,
requestId: id,
});
}
/**
* Update retry count for a request
*/
updateRetryCount(id: string, retryCount: number): void {
const log = this.activeRequests.get(id);
if (log) {
log.metadata.retryCount = retryCount;
log.metadata.wasRetry = retryCount > 0;
}
}
/**
* Add log to history
*/
private addToHistory(log: ApiRequestLog): void {
this.requestHistory.push(log);
// Keep only last N requests
const maxHistory = this.options.maxHistory || 100;
if (this.requestHistory.length > maxHistory) {
this.requestHistory = this.requestHistory.slice(-maxHistory);
}
}
/**
* Get request history
*/
getHistory(): ApiRequestLog[] {
return [...this.requestHistory];
}
/**
* Get active requests
*/
getActiveRequests(): ApiRequestLog[] {
return Array.from(this.activeRequests.values());
}
/**
* Clear history
*/
clearHistory(): void {
this.requestHistory = [];
if (this.options.logToConsole) {
this.logger.info('API request history cleared');
}
}
/**
* Get statistics
*/
getStats(): {
total: number;
successful: number;
failed: number;
averageDuration: number;
byMethod: Record<string, number>;
byStatus: Record<number, number>;
} {
const stats = {
total: this.requestHistory.length,
successful: 0,
failed: 0,
averageDuration: 0,
byMethod: {} as Record<string, number>,
byStatus: {} as Record<number, number>,
};
let totalDuration = 0;
this.requestHistory.forEach(log => {
if (log.response) {
if (log.response.status >= 200 && log.response.status < 300) {
stats.successful++;
} else {
stats.failed++;
}
totalDuration += log.response.duration;
stats.byStatus[log.response.status] = (stats.byStatus[log.response.status] || 0) + 1;
} else if (log.error) {
stats.failed++;
}
stats.byMethod[log.method] = (stats.byMethod[log.method] || 0) + 1;
});
stats.averageDuration = stats.total > 0 ? totalDuration / stats.total : 0;
return stats;
}
/**
* Export logs for debugging
*/
exportLogs(format: 'json' | 'text' = 'json'): string {
if (format === 'json') {
return JSON.stringify(this.requestHistory, null, 2);
}
// Text format
return this.requestHistory.map(log => {
const parts = [
`[${log.timestamp}] ${log.method} ${log.url}`,
`ID: ${log.id}`,
];
if (log.response) {
parts.push(`Status: ${log.response.status}`);
parts.push(`Duration: ${log.response.duration.toFixed(2)}ms`);
}
if (log.error) {
parts.push(`Error: ${log.error.message}`);
}
if (log.body && this.options.logBodies) {
parts.push(`Body: ${JSON.stringify(log.body)}`);
}
return parts.join(' | ');
}).join('\n');
}
/**
* Find requests by URL pattern
*/
findRequests(urlPattern: string | RegExp): ApiRequestLog[] {
const pattern = typeof urlPattern === 'string'
? new RegExp(urlPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
: urlPattern;
return this.requestHistory.filter(log => pattern.test(log.url));
}
/**
* Get slowest requests
*/
getSlowestRequests(limit: number = 10): ApiRequestLog[] {
return this.requestHistory
.filter(log => log.response && log.response.duration > 0)
.sort((a, b) => (b.response?.duration || 0) - (a.response?.duration || 0))
.slice(0, limit);
}
/**
* Get requests by time range
*/
getRequestsByTimeRange(startTime: Date, endTime: Date): ApiRequestLog[] {
return this.requestHistory.filter(log => {
const logTime = new Date(log.timestamp);
return logTime >= startTime && logTime <= endTime;
});
}
/**
* Update options dynamically
*/
updateOptions(newOptions: Partial<ApiRequestLoggerOptions>): void {
this.options = { ...this.options, ...newOptions };
}
/**
* Create a logged fetch function
*/
createLoggedFetch(): typeof window.fetch {
const logger = this;
const originalFetch = window.fetch;
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const startTime = performance.now();
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method || 'GET';
// Log request start
const requestId = logger.logRequest(
url,
method,
init?.headers as Record<string, string> | undefined,
init?.body ? JSON.parse(init.body as string) : undefined
);
try {
const response = await originalFetch(input, init);
const endTime = performance.now();
const duration = endTime - startTime;
// Clone response to read body without consuming it
const responseClone = response.clone();
let responseBody: unknown;
try {
const contentType = responseClone.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
responseBody = await responseClone.json();
} else {
responseBody = await responseClone.text();
}
} catch {
responseBody = '[Unable to read response body]';
}
// Log successful response
logger.logResponse(requestId, response, responseBody, duration);
return response;
} catch (error) {
const endTime = performance.now();
const duration = endTime - startTime;
// Log error
if (error instanceof Error) {
logger.logError(requestId, error, duration);
}
throw error;
}
};
}
}
/**
* Global instance accessor
*/
let globalApiLoggerInstance: ApiRequestLogger | null = null;
export function getGlobalApiLogger(): ApiRequestLogger {
if (!globalApiLoggerInstance) {
globalApiLoggerInstance = new ApiRequestLogger();
}
return globalApiLoggerInstance;
}
/**
* Initialize global API logger
*/
export function initializeApiLogger(options?: ApiRequestLoggerOptions): ApiRequestLogger {
const logger = new ApiRequestLogger(options);
globalApiLoggerInstance = logger;
return logger;
}
/**
* Fetch interceptor that automatically logs all requests
*/
export function createLoggedFetch(originalFetch: typeof window.fetch = window.fetch): typeof window.fetch {
const logger = getGlobalApiLogger();
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const startTime = performance.now();
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method || 'GET';
// Log request start
const requestId = logger.logRequest(
url,
method,
init?.headers as Record<string, string> | undefined,
init?.body ? JSON.parse(init.body as string) : undefined
);
try {
const response = await originalFetch(input, init);
const endTime = performance.now();
const duration = endTime - startTime;
// Clone response to read body without consuming it
const responseClone = response.clone();
let responseBody: unknown;
try {
const contentType = responseClone.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
responseBody = await responseClone.json();
} else {
responseBody = await responseClone.text();
}
} catch {
responseBody = '[Unable to read response body]';
}
// Log successful response
logger.logResponse(requestId, response, responseBody, duration);
return response;
} catch (error) {
const endTime = performance.now();
const duration = endTime - startTime;
// Log error
if (error instanceof Error) {
logger.logError(requestId, error, duration);
}
throw error;
}
};
}
/**
* React hook for API logging
*/
export function useApiLogger() {
const logger = getGlobalApiLogger();
return {
getHistory: () => logger.getHistory(),
getStats: () => logger.getStats(),
clearHistory: () => logger.clearHistory(),
exportLogs: (format?: 'json' | 'text') => logger.exportLogs(format),
findRequests: (pattern: string | RegExp) => logger.findRequests(pattern),
getSlowestRequests: (limit?: number) => logger.getSlowestRequests(limit),
getRequestsByTimeRange: (start: Date, end: Date) => logger.getRequestsByTimeRange(start, end),
updateOptions: (options: Partial<ApiRequestLoggerOptions>) => logger.updateOptions(options),
};
}

View 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(),
};
}

View File

@@ -0,0 +1,657 @@
/**
* Enhanced Global Error Handler for Maximum Developer Transparency
* Captures all uncaught errors, promise rejections, and React errors
*/
import { ApiError } from '../api/base/ApiError';
import { getGlobalErrorReporter } from './EnhancedErrorReporter';
import { ConsoleLogger } from './logging/ConsoleLogger';
import { getGlobalReplaySystem } from './ErrorReplay';
export interface GlobalErrorHandlerOptions {
/**
* Enable detailed error overlays in development
*/
showDevOverlay?: boolean;
/**
* Log all errors to console with maximum detail
*/
verboseLogging?: boolean;
/**
* Capture stack traces with enhanced context
*/
captureEnhancedStacks?: boolean;
/**
* Report to external services (Sentry, etc.)
*/
reportToExternal?: boolean;
/**
* Custom error filter to ignore certain errors
*/
errorFilter?: (error: Error) => boolean;
}
export class GlobalErrorHandler {
private options: GlobalErrorHandlerOptions;
private logger: ConsoleLogger;
private errorReporter: ReturnType<typeof getGlobalErrorReporter>;
private errorHistory: Array<{
error: Error | ApiError;
timestamp: string;
context?: unknown;
stackEnhanced?: string;
}> = [];
private readonly MAX_HISTORY = 100;
private isInitialized = false;
constructor(options: GlobalErrorHandlerOptions = {}) {
this.options = {
showDevOverlay: options.showDevOverlay ?? process.env.NODE_ENV === 'development',
verboseLogging: options.verboseLogging ?? process.env.NODE_ENV === 'development',
captureEnhancedStacks: options.captureEnhancedStacks ?? process.env.NODE_ENV === 'development',
reportToExternal: options.reportToExternal ?? process.env.NODE_ENV === 'production',
errorFilter: options.errorFilter,
};
this.logger = new ConsoleLogger();
this.errorReporter = getGlobalErrorReporter();
}
/**
* Initialize global error handlers
*/
initialize(): void {
if (this.isInitialized) {
console.warn('[GlobalErrorHandler] Already initialized');
return;
}
// Handle uncaught JavaScript errors
window.addEventListener('error', this.handleWindowError);
// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', this.handleUnhandledRejection);
// Override console.error to capture framework errors
this.overrideConsoleError();
// React error boundary fallback
this.setupReactErrorHandling();
this.isInitialized = true;
if (this.options.verboseLogging) {
this.logger.info('Global error handler initialized', {
devOverlay: this.options.showDevOverlay,
verboseLogging: this.options.verboseLogging,
enhancedStacks: this.options.captureEnhancedStacks,
});
}
}
/**
* Handle window errors (uncaught JavaScript errors)
*/
private handleWindowError = (event: ErrorEvent): void => {
const error = event.error;
// Apply error filter if provided
if (this.options.errorFilter && !this.options.errorFilter(error)) {
return;
}
const enhancedContext = this.captureEnhancedContext('window_error', {
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
message: event.message,
});
// Log with maximum detail
this.logErrorWithMaximumDetail(error, enhancedContext);
// Store in history
this.addToHistory(error, enhancedContext);
// Show dev overlay if enabled
if (this.options.showDevOverlay) {
this.showDevOverlay(error, enhancedContext);
}
// Report to external if enabled
if (this.options.reportToExternal) {
this.reportToExternal(error, enhancedContext);
}
// Auto-capture for replay in development
if (this.options.showDevOverlay) {
const replaySystem = getGlobalReplaySystem();
replaySystem.autoCapture(error, enhancedContext);
}
// Prevent default error logging in dev to avoid duplicates
if (this.options.showDevOverlay) {
event.preventDefault();
}
};
/**
* Handle unhandled promise rejections
*/
private handleUnhandledRejection = (event: PromiseRejectionEvent): void => {
const error = event.reason;
// Apply error filter if provided
if (this.options.errorFilter && !this.options.errorFilter(error)) {
return;
}
const enhancedContext = this.captureEnhancedContext('unhandled_promise', {
promise: event.promise,
reason: typeof error === 'string' ? error : error?.message || 'Unknown promise rejection',
});
// Log with maximum detail
this.logErrorWithMaximumDetail(error, enhancedContext);
// Store in history
this.addToHistory(error, enhancedContext);
// Show dev overlay if enabled
if (this.options.showDevOverlay) {
this.showDevOverlay(error, enhancedContext);
}
// Report to external if enabled
if (this.options.reportToExternal) {
this.reportToExternal(error, enhancedContext);
}
// Prevent default logging
if (this.options.showDevOverlay) {
event.preventDefault();
}
};
/**
* Override console.error to capture framework errors
*/
private overrideConsoleError(): void {
const originalError = console.error;
console.error = (...args: unknown[]): void => {
// Call original first
originalError.apply(console, args);
// Try to extract error from arguments
const error = args.find(arg => arg instanceof Error) ||
args.find(arg => typeof arg === 'object' && arg !== null && 'message' in arg) ||
new Error(args.map(a => String(a)).join(' '));
if (error instanceof Error) {
const enhancedContext = this.captureEnhancedContext('console_error', {
originalArgs: args,
});
// Store in history
this.addToHistory(error, enhancedContext);
// Show dev overlay if enabled
if (this.options.showDevOverlay) {
this.showDevOverlay(error, enhancedContext);
}
}
};
}
/**
* Setup React-specific error handling
*/
private setupReactErrorHandling(): void {
// This will be used by React Error Boundaries
// We'll provide a global registry for React errors
(window as any).__GRIDPILOT_REACT_ERRORS__ = [];
}
/**
* Capture enhanced context with stack trace and environment info
*/
private captureEnhancedContext(type: string, additionalContext: Record<string, unknown> = {}): Record<string, unknown> {
const stack = new Error().stack;
return {
type,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
language: navigator.language,
platform: navigator.platform,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
screen: {
width: window.screen.width,
height: window.screen.height,
},
memory: (performance as any).memory ? {
usedJSHeapSize: (performance as any).memory.usedJSHeapSize,
totalJSHeapSize: (performance as any).memory.totalJSHeapSize,
} : null,
connection: (navigator as any).connection ? {
effectiveType: (navigator as any).connection.effectiveType,
downlink: (navigator as any).connection.downlink,
rtt: (navigator as any).connection.rtt,
} : null,
...additionalContext,
enhancedStack: this.options.captureEnhancedStacks ? this.enhanceStackTrace(stack) : undefined,
};
}
/**
* Enhance stack trace with additional context
*/
private enhanceStackTrace(stack?: string): string | undefined {
if (!stack) return undefined;
// Add source map information if available
const lines = stack.split('\n').slice(1); // Remove first line (error message)
const enhanced = lines.map(line => {
// Try to extract file and line info
const match = line.match(/at (.+) \((.+):(\d+):(\d+)\)/) ||
line.match(/at (.+):(\d+):(\d+)/);
if (match) {
const func = match[1] || 'anonymous';
const file = match[2] || match[1];
const lineNum = match[3] || match[2];
const colNum = match[4] || match[3];
// Add source map comment if in development
if (process.env.NODE_ENV === 'development' && file.includes('.js')) {
return `at ${func} (${file}:${lineNum}:${colNum}) [Source Map: ${file}.map]`;
}
return `at ${func} (${file}:${lineNum}:${colNum})`;
}
return line.trim();
});
return enhanced.join('\n');
}
/**
* Log error with maximum detail
*/
private logErrorWithMaximumDetail(error: Error | ApiError, context: Record<string, unknown>): void {
if (!this.options.verboseLogging) return;
const isApiError = error instanceof ApiError;
// Group all related information
console.groupCollapsed(`%c[GLOBAL ERROR] ${error.name || 'Error'}: ${error.message}`,
'color: #ff4444; font-weight: bold; font-size: 14px;'
);
// Error details
console.log('Error Details:', {
name: error.name,
message: error.message,
stack: error.stack,
type: isApiError ? error.type : 'N/A',
severity: isApiError ? error.getSeverity() : 'error',
retryable: isApiError ? error.isRetryable() : 'N/A',
connectivity: isApiError ? error.isConnectivityIssue() : 'N/A',
});
// Context information
console.log('Context:', context);
// Enhanced stack trace
if (context.enhancedStack) {
console.log('Enhanced Stack Trace:\n' + context.enhancedStack);
}
// API-specific information
if (isApiError && error.context) {
console.log('API Context:', error.context);
}
// Environment information
console.log('Environment:', {
mode: process.env.NODE_ENV,
nextPublicMode: process.env.NEXT_PUBLIC_GRIDPILOT_MODE,
version: process.env.NEXT_PUBLIC_APP_VERSION,
buildTime: process.env.NEXT_PUBLIC_BUILD_TIME,
});
// Performance metrics
if (context.memory && typeof context.memory === 'object' &&
'usedJSHeapSize' in context.memory && 'totalJSHeapSize' in context.memory) {
const memory = context.memory as { usedJSHeapSize: number; totalJSHeapSize: number };
console.log('Memory Usage:', {
used: `${(memory.usedJSHeapSize / 1024 / 1024).toFixed(2)} MB`,
total: `${(memory.totalJSHeapSize / 1024 / 1024).toFixed(2)} MB`,
});
}
// Network information
if (context.connection) {
console.log('Network:', context.connection);
}
// Error history (last 5 errors)
if (this.errorHistory.length > 0) {
console.log('Recent Error History:', this.errorHistory.slice(-5));
}
console.groupEnd();
// Also log to our logger
this.logger.error(error.message, error, context);
}
/**
* Show development overlay with error details
*/
private showDevOverlay(error: Error | ApiError, context: Record<string, unknown>): void {
// Check if overlay already exists
const existingOverlay = document.getElementById('gridpilot-error-overlay');
if (existingOverlay) {
// Update existing overlay
this.updateDevOverlay(existingOverlay, error, context);
return;
}
// Create new overlay
const overlay = document.createElement('div');
overlay.id = 'gridpilot-error-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
color: #fff;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
z-index: 999999;
overflow: auto;
padding: 20px;
border: 4px solid #ff4444;
box-shadow: 0 0 50px rgba(255, 68, 68, 0.5);
`;
this.updateDevOverlay(overlay, error, context);
document.body.appendChild(overlay);
// Add keyboard shortcut to dismiss
const dismissHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === 'Enter') {
overlay.remove();
document.removeEventListener('keydown', dismissHandler);
}
};
document.addEventListener('keydown', dismissHandler);
}
/**
* Update existing dev overlay
*/
private updateDevOverlay(overlay: HTMLElement, error: Error | ApiError, context: Record<string, unknown>): void {
const isApiError = error instanceof ApiError;
const timestamp = new Date().toLocaleTimeString();
overlay.innerHTML = `
<div style="max-width: 1200px; margin: 0 auto;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 20px;">
<div>
<h1 style="color: #ff4444; margin: 0 0 10px 0; font-size: 24px;">
🚨 UNCAUGHT ERROR - DEVELOPMENT MODE
</h1>
<div style="color: #888;">${timestamp} | Press ESC or ENTER to dismiss</div>
</div>
<button onclick="this.parentElement.parentElement.remove()"
style="background: #ff4444; color: white; border: none; padding: 8px 16px; cursor: pointer; border-radius: 4px; font-weight: bold;">
CLOSE
</button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div style="background: #1a1a1a; padding: 15px; border-radius: 8px; border: 1px solid #333;">
<h3 style="color: #ffaa00; margin-top: 0;">Error Information</h3>
<div style="line-height: 1.6;">
<div><strong>Type:</strong> <span style="color: #ff4444;">${isApiError ? error.type : error.name}</span></div>
<div><strong>Message:</strong> ${error.message}</div>
${isApiError ? `<div><strong>Severity:</strong> ${error.getSeverity()}</div>` : ''}
${isApiError ? `<div><strong>Retryable:</strong> ${error.isRetryable()}</div>` : ''}
${isApiError ? `<div><strong>Connectivity:</strong> ${error.isConnectivityIssue()}</div>` : ''}
</div>
</div>
<div style="background: #1a1a1a; padding: 15px; border-radius: 8px; border: 1px solid #333;">
<h3 style="color: #00aaff; margin-top: 0;">Environment</h3>
<div style="line-height: 1.6;">
<div><strong>Mode:</strong> ${process.env.NODE_ENV}</div>
<div><strong>App Mode:</strong> ${process.env.NEXT_PUBLIC_GRIDPILOT_MODE || 'pre-launch'}</div>
<div><strong>URL:</strong> ${window.location.href}</div>
<div><strong>User Agent:</strong> ${navigator.userAgent}</div>
</div>
</div>
</div>
${isApiError && error.context ? `
<div style="background: #1a1a1a; padding: 15px; border-radius: 8px; border: 1px solid #333; margin-bottom: 20px;">
<h3 style="color: #00ff88; margin-top: 0;">API Context</h3>
<pre style="background: #000; padding: 10px; border-radius: 4px; overflow-x: auto; margin: 0;">${JSON.stringify(error.context, null, 2)}</pre>
</div>
` : ''}
<div style="background: #1a1a1a; padding: 15px; border-radius: 8px; border: 1px solid #333; margin-bottom: 20px;">
<h3 style="color: #ff4444; margin-top: 0;">Stack Trace</h3>
<pre style="background: #000; padding: 10px; border-radius: 4px; overflow-x: auto; margin: 0; white-space: pre-wrap;">${context.enhancedStack || error.stack || 'No stack trace available'}</pre>
</div>
<div style="background: #1a1a1a; padding: 15px; border-radius: 8px; border: 1px solid #333; margin-bottom: 20px;">
<h3 style="color: #ffaa00; margin-top: 0;">Additional Context</h3>
<pre style="background: #000; padding: 10px; border-radius: 4px; overflow-x: auto; margin: 0;">${JSON.stringify(context, null, 2)}</pre>
</div>
<div style="background: #1a1a1a; padding: 15px; border-radius: 8px; border: 1px solid #333;">
<h3 style="color: #00aaff; margin-top: 0;">Quick Actions</h3>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button onclick="navigator.clipboard.writeText(\`${error.message}\n\nStack:\n${error.stack}\n\nContext:\n${JSON.stringify(context, null, 2)}\`)"
style="background: #0066cc; color: white; border: none; padding: 8px 12px; cursor: pointer; border-radius: 4px;">
📋 Copy Error Details
</button>
<button onclick="window.location.reload()"
style="background: #cc6600; color: white; border: none; padding: 8px 12px; cursor: pointer; border-radius: 4px;">
🔄 Reload Page
</button>
<button onclick="console.clear(); console.log('Error details:', ${JSON.stringify({ error: error.message, stack: error.stack, context }).replace(/"/g, '"')})"
style="background: #6600cc; color: white; border: none; padding: 8px 12px; cursor: pointer; border-radius: 4px;">
📝 Log to Console
</button>
</div>
</div>
<div style="margin-top: 20px; padding: 15px; background: #222; border-radius: 4px; border-left: 4px solid #ffaa00;">
<strong>💡 Tip:</strong> This overlay only appears in development mode. In production, errors are logged silently and handled gracefully.
</div>
</div>
`;
}
/**
* Report error to external services
*/
private reportToExternal(error: Error | ApiError, context: Record<string, unknown>): void {
// This is a placeholder for external error reporting (Sentry, LogRocket, etc.)
// In a real implementation, you would send structured data to your error tracking service
if (this.options.verboseLogging) {
console.log('[EXTERNAL REPORT] Would send to error tracking service:', {
error: {
name: error.name,
message: error.message,
stack: error.stack,
type: error instanceof ApiError ? error.type : undefined,
},
context,
timestamp: new Date().toISOString(),
});
}
}
/**
* Add error to history
*/
private addToHistory(error: Error | ApiError, context: Record<string, unknown>): void {
const entry = {
error,
timestamp: new Date().toISOString(),
context,
stackEnhanced: context.enhancedStack as string | undefined,
};
this.errorHistory.push(entry);
// Keep only last N errors
if (this.errorHistory.length > this.MAX_HISTORY) {
this.errorHistory = this.errorHistory.slice(-this.MAX_HISTORY);
}
}
/**
* Get error history
*/
getErrorHistory(): Array<{ error: Error | ApiError; timestamp: string; context?: unknown; stackEnhanced?: string }> {
return [...this.errorHistory];
}
/**
* Clear error history
*/
clearHistory(): void {
this.errorHistory = [];
if (this.options.verboseLogging) {
this.logger.info('Error history cleared');
}
}
/**
* Get statistics about errors
*/
getStats(): {
total: number;
byType: Record<string, number>;
recent: Array<{ timestamp: string; message: string; type: string }>;
} {
const stats = {
total: this.errorHistory.length,
byType: {} as Record<string, number>,
recent: this.errorHistory.slice(-10).map(entry => ({
timestamp: entry.timestamp,
message: entry.error.message,
type: entry.error instanceof ApiError ? entry.error.type : entry.error.name || 'Error',
})),
};
this.errorHistory.forEach(entry => {
const type = entry.error instanceof ApiError ? entry.error.type : entry.error.name || 'Error';
stats.byType[type] = (stats.byType[type] || 0) + 1;
});
return stats;
}
/**
* Manually report an error
*/
report(error: Error | ApiError, additionalContext: Record<string, unknown> = {}): void {
const context = this.captureEnhancedContext('manual_report', additionalContext);
this.logErrorWithMaximumDetail(error, context);
this.addToHistory(error, context);
if (this.options.showDevOverlay) {
this.showDevOverlay(error, context);
// Auto-capture for replay
const replaySystem = getGlobalReplaySystem();
replaySystem.autoCapture(error, context);
}
if (this.options.reportToExternal) {
this.reportToExternal(error, context);
}
}
/**
* Destroy the error handler and remove all listeners
*/
destroy(): void {
window.removeEventListener('error', this.handleWindowError);
window.removeEventListener('unhandledrejection', this.handleUnhandledRejection);
// Restore original console.error
if ((console as any)._originalError) {
console.error = (console as any)._originalError;
}
// Remove overlay if exists
const overlay = document.getElementById('gridpilot-error-overlay');
if (overlay) {
overlay.remove();
}
this.isInitialized = false;
if (this.options.verboseLogging) {
this.logger.info('Global error handler destroyed');
}
}
}
/**
* Global instance accessor
*/
let globalErrorHandlerInstance: GlobalErrorHandler | null = null;
export function getGlobalErrorHandler(): GlobalErrorHandler {
if (!globalErrorHandlerInstance) {
globalErrorHandlerInstance = new GlobalErrorHandler();
}
return globalErrorHandlerInstance;
}
/**
* Initialize global error handling
*/
export function initializeGlobalErrorHandling(options?: GlobalErrorHandlerOptions): GlobalErrorHandler {
const handler = new GlobalErrorHandler(options);
handler.initialize();
globalErrorHandlerInstance = handler;
return handler;
}
/**
* React hook for manual error reporting
*/
export function useGlobalErrorHandler() {
const handler = getGlobalErrorHandler();
return {
report: (error: Error | ApiError, context?: Record<string, unknown>) => handler.report(error, context),
getHistory: () => handler.getErrorHistory(),
getStats: () => handler.getStats(),
clearHistory: () => handler.clearHistory(),
};
}