diff --git a/.gitignore b/.gitignore index 5beb4dc..ffa1447 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules .env logs/ tmp-test-events/ +.env.test diff --git a/README.md b/README.md index 69f4c0c..9f154dd 100644 --- a/README.md +++ b/README.md @@ -100,15 +100,70 @@ npm run run-once # una sola pasada, útil para probar npm run start # corre en loop ``` +## Correr como servicio de Windows (NSSM) + +En Windows Server, el dispatcher (`npm start`) se deja instalado como +servicio con [NSSM](https://nssm.cc/) para que arranque solo con el +server y se reinicie si el proceso muere. Pasos (reemplazar +`` y las rutas por las reales): + +```powershell +# 1. Instalar el servicio: Application = node.exe, Arguments = CLI de tsx + entrypoint +nssm install "C:\Program Files\nodejs\node.exe" "C:\ruta\al\proyecto\node_modules\tsx\dist\cli.mjs src/index.ts" + +# 2. Directorio de trabajo -- IMPRESCINDIBLE: acá se resuelve el .env (dotenv lee del cwd) +nssm set AppDirectory "C:\ruta\al\proyecto" + +# 3. Arranque automático con el server +nssm set Start SERVICE_AUTO_START + +# 4. Reinicio automático si el proceso crashea +nssm set AppExit Default Restart + +# 5. Captura de stderr como red de seguridad (la app ya loguea sola, ver +# "Logs" abajo, así que Output/stdout no hace falta -- solo serviría +# para duplicar lo mismo) +nssm set AppStderr "C:\ruta\al\proyecto\logs\nssm-stderr.log" + +# 6. Arrancarlo +nssm start +Get-Service # debe decir "Running" +``` + +`nssm install ` sin más argumentos abre un instalador +gráfico equivalente (útil si hay sesión de escritorio): en la pestaña +*Application* van el Path/Startup directory/Arguments (pasos 1-2), en +*Details* el Startup type (paso 3), en *Exit actions* el restart (paso +4) y en *I/O* el stderr (paso 5). + +⚠️ No usar `npm.cmd` como Application — NSSM no trackea bien el proceso +Node hijo que lanza npm, y al parar el servicio queda un proceso Node +huérfano corriendo. + +Para probar que el servicio instalado esté vivo (más allá de que +`Get-Service` diga `Running`): seguir el log del día en vivo y confirmar +que aparecen ticks nuevos cada `POLL_INTERVAL_SECONDS`. + +```powershell +Get-Content "C:\ruta\al\proyecto\logs\dispatcher-$(Get-Date -Format yyyy-MM-dd).log" -Tail 20 -Wait +``` + +Para desinstalar o reconfigurar: `nssm stop ` seguido de +`nssm remove confirm`, o `nssm edit ` +para el GUI de edición. + ## Logs -Cada tick escribe a `logs/dispatcher-YYYY-MM-DD.log` (uno por día) además -de la consola — línea simple ` [NIVEL] mensaje`, legible -por cualquier visualizador genérico (`tail -f logs/dispatcher-*.log`, -`lnav`, etc.). Siempre deja al menos una línea de resumen por tick -(`Tick: N evento(s) nuevo(s), M pendiente(s) de entrega`), incluso sin -actividad — para poder confirmar que el proceso sigue vivo. No versionado -(`.gitignore`). +Cada tick escribe a `logs/dispatcher-YYYY-MM-DD.log` (uno por día, +rotando a medianoche hora local del server) además de la consola — línea +simple ` [NIVEL] mensaje` (ej. +`2026-09-16T13:50:32.565-03:00 [INFO] ...`), legible por cualquier +visualizador genérico (`tail -f logs/dispatcher-*.log`, `lnav`, etc.). Se +usa hora local (no UTC) a propósito para que los logs sean auditables +directo contra la hora de pared, sin restar husos horarios a mano. +Siempre deja al menos una línea de resumen por tick (`Tick: N evento(s) +nuevo(s), M pendiente(s) de entrega`), incluso sin actividad — para poder +confirmar que el proceso sigue vivo. No versionado (`.gitignore`). ## Estructura diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 4bb17f9..f2573a9 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,5 +1,6 @@ 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 @@ -9,14 +10,65 @@ import { fileURLToPath } from 'node:url'; 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 = date.toISOString().slice(0, 10); + const day = localTimestamp(date).slice(0, 10); return `${LOGS_DIR}dispatcher-${day}.log`; } function write(level: string, message: string): void { const now = new Date(); - appendFileSync(logFilePath(now), `${now.toISOString()} [${level}] ${message}\n`); + appendFileSync(logFilePath(now), `${localTimestamp(now)} [${level}] ${message}\n`); +} + +// 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 = { @@ -29,8 +81,15 @@ export const logger = { write('WARN', message); }, error(message: string, err?: unknown): void { - const detail = err instanceof Error ? (err.stack ?? err.message) : err !== undefined ? String(err) : ''; - console.error(message, err ?? ''); - write('ERROR', detail ? `${message} ${detail}` : message); + 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}`); }, };