599 lines
16 KiB
TypeScript
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),
|
|
};
|
|
} |