/** * Structured Logger — FASE-05 Code Quality * * Lightweight structured logging wrapper with JSON output for production * and human-readable output for development. Replaces scattered console.log * calls with consistent, parseable log entries. * * When APP_LOG_TO_FILE is enabled, log entries are also appended as JSON lines * to the application log file for the Console Log Viewer. * * @module shared/utils/structuredLogger */ import { getCorrelationId } from "../middleware/correlationId"; import { appendFileSync, existsSync, mkdirSync } from "fs"; import { dirname, resolve } from "path"; import { getAppLogFilePath, getAppLogLevel, getAppLogToFile } from "@/lib/logEnv"; const LOG_LEVELS: Record = { debug: 10, info: 20, warn: 30, error: 40, fatal: 50, }; const currentLevel = LOG_LEVELS[getAppLogLevel("info").toLowerCase() || ""] || LOG_LEVELS.info; const isProduction = process.env.NODE_ENV === "production"; // File logging configuration const logToFile = getAppLogToFile(); const logFilePath = resolve(getAppLogFilePath()); // Ensure log directory exists once at module load if (logToFile) { try { const dir = dirname(logFilePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } catch { // silently ignore — will retry on each write } } /** * Append a JSON log line to the log file (non-blocking best-effort). */ function writeToFile(entry: Record) { if (!logToFile) return; try { appendFileSync(logFilePath, JSON.stringify(entry) + "\n"); } catch { // Silently fail — file logging should never break the app } } function formatEntry( level: string, component: string, message: string, meta?: Record ) { const entry: Record = { timestamp: new Date().toISOString(), level, component, message, ...meta, }; // Add correlation ID if available const correlationId = getCorrelationId() as string | undefined; if (correlationId) { entry.correlationId = correlationId; } if (isProduction) { return JSON.stringify(entry); } // Human-readable for development const metaStr = meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ""; const corrStr = correlationId ? ` [${correlationId.slice(0, 8)}]` : ""; return `[${entry.timestamp}] ${level.toUpperCase().padEnd(5)} [${component}]${corrStr} ${message}${metaStr}`; } function buildEntry( level: string, component: string, message: string, meta?: Record ) { const entry: Record = { timestamp: new Date().toISOString(), level, component, message, ...meta, }; const correlationId = getCorrelationId() as string | undefined; if (correlationId) { entry.correlationId = correlationId; } return entry; } // EPIPE-safe error deduplication + rate limiting (#1006) const _recentErrors = new Map(); const DEDUP_WINDOW_MS = 5_000; const MAX_WRITES_PER_SECOND = 50; let _writeCount = 0; let _writeWindowStart = Date.now(); function shouldSuppressError(message: string): boolean { const now = Date.now(); // Rate limit: max writes per second if (now - _writeWindowStart > 1000) { _writeCount = 0; _writeWindowStart = now; } if (_writeCount >= MAX_WRITES_PER_SECOND) return true; // Dedup: suppress identical messages within window const existing = _recentErrors.get(message); if (existing && now - existing.firstSeen < DEDUP_WINDOW_MS) { existing.count++; return true; } // Cleanup old entries if (_recentErrors.size > 100) { for (const [key, entry] of _recentErrors) { if (now - entry.firstSeen > DEDUP_WINDOW_MS) _recentErrors.delete(key); } } _recentErrors.set(message, { count: 1, firstSeen: now }); _writeCount++; return false; } export function createLogger(component: string) { return { debug(message: string, meta?: Record) { if (currentLevel <= LOG_LEVELS.debug) { const entry = buildEntry("debug", component, message, meta); console.debug(formatEntry("debug", component, message, meta)); writeToFile(entry); } }, info(message: string, meta?: Record) { if (currentLevel <= LOG_LEVELS.info) { const entry = buildEntry("info", component, message, meta); console.info(formatEntry("info", component, message, meta)); writeToFile(entry); } }, warn(message: string, meta?: Record) { if (currentLevel <= LOG_LEVELS.warn) { const entry = buildEntry("warn", component, message, meta); console.warn(formatEntry("warn", component, message, meta)); writeToFile(entry); } }, error(message: string, meta?: Record) { if (currentLevel <= LOG_LEVELS.error) { if (shouldSuppressError(message)) return; const entry = buildEntry("error", component, message, meta); // Use stderr.write to avoid Next.js console patching that triggers EPIPE loops try { process.stderr.write(formatEntry("error", component, message, meta) + "\n"); } catch {} writeToFile(entry); } }, fatal(message: string, meta?: Record) { if (shouldSuppressError(message)) return; const entry = buildEntry("fatal", component, message, meta); try { process.stderr.write(formatEntry("fatal", component, message, meta) + "\n"); } catch {} writeToFile(entry); }, child(defaultMeta: Record) { return createLogger(component); }, }; } export { LOG_LEVELS };