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
text/typescript
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;
}
()
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;
}
}
}