import { appendFileSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { inspect } from 'node:util'; // Log a archivo, uno por día (logs/dispatcher-YYYY-MM-DD.log), además de // consola — para que un servicio Windows (NSSM/PM2/Task Scheduler, ver // README) no dependa de que algo capture su stdout. Formato de línea // simple (timestamp ISO + nivel entre corchetes) para que lo pueda leer // cualquier visualizador de logs genérico (ej. lnav, o un `tail -f`). const LOGS_DIR = fileURLToPath(new URL('../../logs/', import.meta.url)); mkdirSync(LOGS_DIR, { recursive: true }); // Hora local del server (no UTC) con offset explícito, para que los logs // sean auditables directamente contra la hora de pared sin tener que // restar el huso horario a mano. function pad(value: number, length = 2): string { return String(value).padStart(length, '0'); } function localTimestamp(date: Date): string { const offsetMinutes = -date.getTimezoneOffset(); const sign = offsetMinutes >= 0 ? '+' : '-'; const absMinutes = Math.abs(offsetMinutes); const offset = `${sign}${pad(Math.floor(absMinutes / 60))}:${pad(absMinutes % 60)}`; return ( `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` + offset ); } function logFilePath(date: Date): string { const day = localTimestamp(date).slice(0, 10); return `${LOGS_DIR}dispatcher-${day}.log`; } // No poder escribir el archivo de log (ej. creado por el servicio como // SYSTEM y corrido a mano con otro usuario: EPERM) nunca debe tumbar el // dispatcher a mitad de un tick — se avisa una vez y se sigue solo por consola. let fileWriteFailed = false; function write(level: string, message: string): void { if (fileWriteFailed) return; const now = new Date(); try { appendFileSync(logFilePath(now), `${localTimestamp(now)} [${level}] ${message}\n`); } catch (err) { fileWriteFailed = true; console.warn(`[logger] No se pudo escribir ${logFilePath(now)} (${err instanceof Error ? err.message : String(err)}) — se sigue solo por consola.`); } } // Errores de red de axios (ECONNREFUSED, timeout, DNS...): primero se // muestra una frase legible para el operador y después el error completo. // El error de axios trae adjunto el body del Login, así que antes de // imprimirlo se enmascara la contraseña. const NETWORK_ERRORS: Record = { ECONNREFUSED: 'conexión rechazada (¿Service Layer detenido o puerto bloqueado?)', ETIMEDOUT: 'tiempo de espera agotado', ECONNABORTED: 'tiempo de espera agotado', ENOTFOUND: 'no se pudo resolver el nombre del servidor', ECONNRESET: 'el servidor cerró la conexión', EHOSTUNREACH: 'servidor inalcanzable', }; function describeError(err: unknown): string { if (!(err instanceof Error)) return err !== undefined ? String(err) : ''; const axiosErr = err as Error & { isAxiosError?: boolean; code?: string; config?: { baseURL?: string; url?: string } }; if (axiosErr.isAxiosError) { const target = `${axiosErr.config?.baseURL ?? ''}${axiosErr.config?.url ?? ''}`; const reason = (axiosErr.code && NETWORK_ERRORS[axiosErr.code]) ?? axiosErr.message; return `No se pudo conectar a ${target}: ${reason}`; } return err.message; } function redactPassword(err: unknown): unknown { const config = (err as { config?: { data?: unknown } } | undefined)?.config; if (config && typeof config.data === 'string') { config.data = config.data.replace(/("Password"\s*:\s*")(?:[^"\\]|\\.)*"/g, '$1***"'); } return err; } export const logger = { info(message: string): void { console.log(message); write('INFO', message); }, warn(message: string): void { console.warn(message); write('WARN', message); }, error(message: string, err?: unknown): void { const summary = describeError(err); const headline = summary ? `${message} ${summary}` : message; if (err === undefined) { console.error(headline); write('ERROR', headline); return; } const full = inspect(redactPassword(err)); console.error(`${headline}\n--- Detalle completo ---\n${full}`); write('ERROR', `${headline}\n${full}`); }, };