/** * 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; 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(); 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 = {}; 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, 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 | 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; byStatus: Record; } { const stats = { total: this.requestHistory.length, successful: 0, failed: 0, averageDuration: 0, byMethod: {} as Record, byStatus: {} as Record, }; 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): void { this.options = { ...this.options, ...newOptions }; } /** * Create a logged fetch function */ createLoggedFetch(): typeof fetch { const logger = this; const originalFetch = typeof window !== 'undefined' ? window.fetch : fetch; return async (input: RequestInfo | URL, init?: RequestInit): Promise => { 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 | 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 fetch = typeof window !== 'undefined' ? window.fetch : fetch): typeof fetch { const logger = getGlobalApiLogger(); return async (input: RequestInfo | URL, init?: RequestInit): Promise => { 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 | 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) => logger.updateOptions(options), }; }