Logs en hora local, errores de red legibles y guía NSSM en el README
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec312d9461
commit
3fd81bbb3e
3 files changed
+127
-12
No files matched your search
@@ -3,3 +3,4 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
logs/
|
logs/
|
||||||
tmp-test-events/
|
tmp-test-events/
|
||||||
|
.env.test
|
||||||
@@ -100,15 +100,70 @@ npm run run-once # una sola pasada, útil para probar
|
|||||||
npm run start # corre en loop
|
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
|
||||||
|
`<NombreServicio>` y las rutas por las reales):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. Instalar el servicio: Application = node.exe, Arguments = CLI de tsx + entrypoint
|
||||||
|
nssm install <NombreServicio> "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 <NombreServicio> AppDirectory "C:\ruta\al\proyecto"
|
||||||
|
|
||||||
|
# 3. Arranque automático con el server
|
||||||
|
nssm set <NombreServicio> Start SERVICE_AUTO_START
|
||||||
|
|
||||||
|
# 4. Reinicio automático si el proceso crashea
|
||||||
|
nssm set <NombreServicio> 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 <NombreServicio> AppStderr "C:\ruta\al\proyecto\logs\nssm-stderr.log"
|
||||||
|
|
||||||
|
# 6. Arrancarlo
|
||||||
|
nssm start <NombreServicio>
|
||||||
|
Get-Service <NombreServicio> # debe decir "Running"
|
||||||
|
```
|
||||||
|
|
||||||
|
`nssm install <NombreServicio>` 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 <NombreServicio>` seguido de
|
||||||
|
`nssm remove <NombreServicio> confirm`, o `nssm edit <NombreServicio>`
|
||||||
|
para el GUI de edición.
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
Cada tick escribe a `logs/dispatcher-YYYY-MM-DD.log` (uno por día) además
|
Cada tick escribe a `logs/dispatcher-YYYY-MM-DD.log` (uno por día,
|
||||||
de la consola — línea simple `<timestamp ISO> [NIVEL] mensaje`, legible
|
rotando a medianoche hora local del server) además de la consola — línea
|
||||||
por cualquier visualizador genérico (`tail -f logs/dispatcher-*.log`,
|
simple `<timestamp ISO con offset local> [NIVEL] mensaje` (ej.
|
||||||
`lnav`, etc.). Siempre deja al menos una línea de resumen por tick
|
`2026-09-16T13:50:32.565-03:00 [INFO] ...`), legible por cualquier
|
||||||
(`Tick: N evento(s) nuevo(s), M pendiente(s) de entrega`), incluso sin
|
visualizador genérico (`tail -f logs/dispatcher-*.log`, `lnav`, etc.). Se
|
||||||
actividad — para poder confirmar que el proceso sigue vivo. No versionado
|
usa hora local (no UTC) a propósito para que los logs sean auditables
|
||||||
(`.gitignore`).
|
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
|
## Estructura
|
||||||
|
|
||||||
|
|||||||
+64
-5
@@ -1,5 +1,6 @@
|
|||||||
import { appendFileSync, mkdirSync } from 'node:fs';
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
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
|
// 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
|
// 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));
|
const LOGS_DIR = fileURLToPath(new URL('../../logs/', import.meta.url));
|
||||||
mkdirSync(LOGS_DIR, { recursive: true });
|
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 {
|
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`;
|
return `${LOGS_DIR}dispatcher-${day}.log`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(level: string, message: string): void {
|
function write(level: string, message: string): void {
|
||||||
const now = new Date();
|
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<string, string> = {
|
||||||
|
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 = {
|
export const logger = {
|
||||||
@@ -29,8 +81,15 @@ export const logger = {
|
|||||||
write('WARN', message);
|
write('WARN', message);
|
||||||
},
|
},
|
||||||
error(message: string, err?: unknown): void {
|
error(message: string, err?: unknown): void {
|
||||||
const detail = err instanceof Error ? (err.stack ?? err.message) : err !== undefined ? String(err) : '';
|
const summary = describeError(err);
|
||||||
console.error(message, err ?? '');
|
const headline = summary ? `${message} ${summary}` : message;
|
||||||
write('ERROR', detail ? `${message} ${detail}` : 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}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user