dev experience
This commit is contained in:
@@ -23,6 +23,8 @@ export interface ApiErrorContext {
|
||||
retryCount?: number;
|
||||
wasRetry?: boolean;
|
||||
troubleshooting?: string;
|
||||
source?: string;
|
||||
componentStack?: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
||||
580
apps/website/lib/infrastructure/ApiRequestLogger.ts
Normal file
580
apps/website/lib/infrastructure/ApiRequestLogger.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
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(),
|
||||
};
|
||||
}
|
||||
657
apps/website/lib/infrastructure/GlobalErrorHandler.ts
Normal file
657
apps/website/lib/infrastructure/GlobalErrorHandler.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user