import type { Params } from "nestjs-pino"; import type { Options as PinoHttpOptions } from "pino-http"; import type { IncomingMessage, ServerResponse } from "http"; import type { ConfigService } from "@nestjs/config"; import { join } from "path"; import { mkdir } from "fs/promises"; export class LoggingConfig { static async createPinoConfig(configService: ConfigService): Promise { const nodeEnv = configService.get("NODE_ENV", "development"); const logLevel = configService.get("LOG_LEVEL", "info"); const appName = configService.get("APP_NAME", "customer-portal-bff"); // Ensure logs directory exists for production if (nodeEnv === "production") { try { await mkdir("logs", { recursive: true }); } catch { // Directory might already exist } } // Base Pino configuration const pinoConfig: PinoHttpOptions = { level: logLevel, name: appName, base: { service: appName, environment: nodeEnv, pid: process.pid, }, timestamp: true, // Ensure sensitive fields are redacted across all logs redact: { paths: [ // Common headers "req.headers.authorization", "req.headers.cookie", // Auth "password", "password2", "token", "secret", "jwt", "apiKey", // Custom params that may carry secrets "params.password", "params.password2", "params.secret", "params.token", ], remove: true, }, formatters: { level: (label: string) => ({ level: label }), bindings: () => ({}), // Remove default hostname/pid from every log }, serializers: { // Keep logs concise: omit headers by default req: (req: { method?: string; url?: string; remoteAddress?: string; remotePort?: number; }) => ({ method: req.method, url: req.url, remoteAddress: req.remoteAddress, remotePort: req.remotePort, }), res: (res: { statusCode: number }) => ({ statusCode: res.statusCode, }), err: (err: { constructor: { name: string }; message: string; stack?: string; code?: string; status?: number; }) => ({ type: err.constructor.name, message: err.message, stack: err.stack, ...(err.code && { code: err.code }), ...(err.status && { status: err.status }), }), }, }; // Development: Pretty printing if (nodeEnv === "development") { pinoConfig.transport = { target: "pino-pretty", options: { colorize: true, translateTime: "yyyy-mm-dd HH:MM:ss", ignore: "pid,hostname", singleLine: false, hideObject: false, }, }; } // Production: File logging with rotation if (nodeEnv === "production") { pinoConfig.transport = { targets: [ // Console output for container logs { target: "pino/file", level: logLevel, options: { destination: 1 }, // stdout }, // Combined log file { target: "pino/file", level: "info", options: { destination: join("logs", `${appName}-combined.log`), mkdir: true, }, }, // Error log file { target: "pino/file", level: "error", options: { destination: join("logs", `${appName}-error.log`), mkdir: true, }, }, ], }; } return { pinoHttp: { ...pinoConfig, // Auto-generate correlation IDs genReqId: (req: IncomingMessage, res: ServerResponse) => { const existingIdHeader = req.headers["x-correlation-id"]; const existingId = Array.isArray(existingIdHeader) ? existingIdHeader[0] : existingIdHeader; if (existingId) return existingId; const correlationId = LoggingConfig.generateCorrelationId(); res.setHeader("x-correlation-id", correlationId); return correlationId; }, // Custom log levels: only warn on 4xx and error on 5xx customLogLevel: (_req: IncomingMessage, res: ServerResponse, err?: unknown) => { if (res.statusCode >= 400 && res.statusCode < 500) return "warn"; if (res.statusCode >= 500 || err) return "error"; return "silent" as any; }, // Suppress success messages entirely customSuccessMessage: () => "", customErrorMessage: ( req: IncomingMessage, res: ServerResponse, err: { message?: string } ) => { const method = req.method ?? ""; const url = req.url ?? ""; return `${method} ${url} ${res.statusCode} - ${err.message ?? "error"}`; }, }, }; } /** * Sanitize headers to remove sensitive information */ private static sanitizeHeaders(headers: any): any { if (!headers || typeof headers !== "object") { return headers; } const sensitiveKeys = [ "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", "password", "secret", "token", "jwt", "bearer", ]; const sanitized = { ...headers }; for (const key in sanitized) { if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive.toLowerCase()))) { sanitized[key] = "[REDACTED]"; } } return sanitized; } /** * Generate correlation ID */ private static generateCorrelationId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Get log levels for different environments */ static getLogLevels(level: string): string[] { const logLevels: Record = { error: ["error"], warn: ["error", "warn"], info: ["error", "warn", "info"], debug: ["error", "warn", "info", "debug"], verbose: ["error", "warn", "info", "debug", "verbose"], }; return logLevels[level] || logLevels.info; } }