UNPKG

logceptor

Version:

NestJS interceptor to log HTTP requests and responses with full control, correlation IDs, file rotation, sensitive data masking, and production-ready features.

246 lines (217 loc) 7.16 kB
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger, } from '@nestjs/common'; import { Observable, tap } from 'rxjs'; import * as fs from 'fs/promises'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; export type LogFormat = 'text' | 'json'; export type LogLevel = 'log' | 'warn' | 'error' | 'debug'; export interface LoggerInterceptorOptions { level?: LogLevel; format?: LogFormat; filename?: string; folder?: string; maskFields?: string[]; maxFileSizeMB?: number; } @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger('logceptor'); private readonly level: LogLevel; private readonly format: LogFormat; private readonly logFilePath: string; private readonly maskFields: string[]; private readonly maxFileSizeBytes: number; constructor(options: LoggerInterceptorOptions = {}) { this.level = options.level ?? 'log'; this.format = options.format ?? 'text'; const folder = options.folder ?? process.cwd(); const filename = options.filename ?? 'http_logs.txt'; this.logFilePath = path.join(folder, filename); this.maskFields = options.maskFields ?? ['password', 'token']; const sizeMB = options.maxFileSizeMB ?? 10; this.maxFileSizeBytes = sizeMB * 1024 * 1024; console.log(`🚀 LoggingInterceptor writing to: ${this.logFilePath}`); } intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); const { method, originalUrl, body, query, params, headers, ip, protocol, httpVersion, } = req; const correlationId = headers['x-correlation-id'] || uuidv4(); req.correlationId = correlationId; res.setHeader('x-correlation-id', correlationId); const maskedBody = this.maskSensitive(body); const timestamp = new Date().toISOString(); const now = Date.now(); const requestLog = this.formatLog('request', { timestamp, correlationId, method, url: originalUrl, body: maskedBody, query, params, ip: headers['x-forwarded-for'] || ip, hostname: req.hostname, userAgent: headers['user-agent'], referer: headers['referer'] || '', protocol, httpVersion, }); this.log(`${method} ${originalUrl} [${correlationId}]`, this.level); this.appendToFile(requestLog); return next.handle().pipe( tap((data) => { const duration = Date.now() - now; const maskedRes = this.maskSensitive(this.extractPayload(data)); const responseLog = this.formatLog('response', { timestamp, correlationId, url: originalUrl, duration: `${duration}ms`, response: maskedRes, }); this.log(`${method} ${originalUrl} completed in ${duration}ms [${correlationId}]`, this.level); this.appendToFile(responseLog); }), ); } private extractPayload(data: any): any { return (typeof data === 'object' && data !== null) ? data : { data }; } private formatLog(type: 'request' | 'response', data: Record<string, any>): string { if (this.format === 'json') { return this.safeStringify({ type, ...data }) + '\n'; } if (type === 'request') { return ` *************** REQUEST *************** Timestamp : ${data.timestamp} Correlation ID: ${data.correlationId} Method : ${data.method} URL : ${data.url} Params : ${this.safeStringify(data.params)} Query : ${this.safeStringify(data.query)} Body : ${this.safeStringify(data.body)} IP : ${data.ip} Hostname : ${data.hostname} User-Agent : ${data.userAgent} Referer : ${data.referer} Protocol : ${data.protocol} HTTP Version : ${data.httpVersion} ****************************************\n`; } return ` *************** RESPONSE ************** Timestamp : ${data.timestamp} Correlation ID: ${data.correlationId} URL : ${data.url} Time Taken : ${data.duration} Response Body : ${this.safeStringify(data.response)} ****************************************\n`; } private safeStringify(obj: any): string { const seen = new WeakSet(); return JSON.stringify(obj, (key, value) => { if (typeof value === 'object' && value !== null) { if (seen.has(value)) return '[Circular]'; seen.add(value); } return value; }); } private maskSensitive(obj: any): any { if (!obj || typeof obj !== 'object') return obj; const seen = new WeakSet(); const deepMask = (value: any): any => { if (value === null || typeof value !== 'object') return value; if (seen.has(value)) return '[Circular]'; seen.add(value); const masked: any = Array.isArray(value) ? [] : {}; for (const key in value) { if (this.maskFields.includes(key)) { masked[key] = '****'; } else if (typeof value[key] === 'object') { masked[key] = deepMask(value[key]); } else { masked[key] = value[key]; } } return masked; }; return deepMask(obj); } private async appendToFile(log: string) { try { const dir = path.dirname(this.logFilePath); await fs.mkdir(dir, { recursive: true }); let rotate = false; try { const stat = await fs.stat(this.logFilePath); if (stat.size + Buffer.byteLength(log) > this.maxFileSizeBytes) { rotate = true; } } catch { // File does not exist yet } if (rotate) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const rotatedName = `${this.logFilePath}.old-${timestamp}.log`; await fs.rename(this.logFilePath, rotatedName); this.logger.log(`Rotated log file: ${rotatedName}`); } await fs.appendFile(this.logFilePath, log); await this.cleanupOldLogs(dir); } catch (err) { this.logger.error('Failed to write log to file', err); } } private async cleanupOldLogs(dir: string) { const files = await fs.readdir(dir); const now = Date.now(); const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; for (const file of files) { const filePath = path.join(dir, file); const stat = await fs.stat(filePath); if (stat.isFile()) { const age = now - stat.mtimeMs; const ext = path.extname(file); if ((age > THIRTY_DAYS || stat.size > this.maxFileSizeBytes) && ['.log', '.txt'].includes(ext)) { await fs.unlink(filePath); this.logger.log(`Deleted old log: ${file}`); } } } } private log(message: string, level: LogLevel) { switch (level) { case 'log': this.logger.log(message); break; case 'warn': this.logger.warn(message); break; case 'error': this.logger.error(message); break; case 'debug': this.logger.debug(message); break; } } }