dev experience
This commit is contained in:
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user