Files
gridpilot.gg/apps/website/lib/infrastructure/ApiRequestLogger.ts
2026-01-06 13:21:55 +01:00

599 lines
16 KiB
TypeScript

/**
* 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) {
// Use enhanced logger for beautiful output
this.logger.debug(`API Request: ${method} ${url}`, {
requestId: id,
timestamp,
headers: this.options.logBodies ? log.headers : '[headers hidden]',
body: this.options.logBodies ? log.body : '[body hidden]',
});
}
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 isSuccess = response.ok;
const context = {
requestId: id,
duration: `${duration.toFixed(2)}ms`,
status: `${response.status} ${response.statusText}`,
...(this.options.logResponses && { body: log.response.body }),
};
if (isSuccess) {
this.logger.info(`API Response: ${log.method} ${log.url}`, context);
} else {
this.logger.warn(`API Response: ${log.method} ${log.url}`, context);
}
}
}
/**
* 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) {
const isNetworkError = error.message.includes('fetch') ||
error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError');
const context = {
requestId: id,
duration: `${duration.toFixed(2)}ms`,
errorType: error.name,
...(process.env.NODE_ENV === 'development' && error.stack ? { stack: error.stack } : {}),
};
// Use warn level for network errors (expected), error level for others
if (isNetworkError) {
this.logger.warn(`API Network Error: ${log.method} ${log.url}`, context);
} else {
this.logger.error(`API Error: ${log.method} ${log.url}`, error, context);
}
}
// Don't report network errors to external services (they're expected)
const isNetworkError = error.message.includes('fetch') ||
error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError');
if (!isNetworkError) {
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 fetch {
const logger = this;
const originalFetch = typeof window !== 'undefined' ? window.fetch : 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 fetch = typeof window !== 'undefined' ? window.fetch : fetch): typeof 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),
};
}