Files
sap-legacy-webhook-emulator/src/lib/logger.ts
T
george.calle 5a2e149825 Dispatcher: evitar ticks solapados, enviar en lotes y mejorar logs
- index.ts: guard tickRunning; un tick largo ya no se solapa con el
  siguiente de node-cron (causaba -2035 en @EVN1 y -1116 cruzados por
  requests concurrentes sobre la misma sesion de Service Layer).
- process-tick.ts: un evento con error ya no corta el tick; progreso
  cada 100 eventos; envios en lotes de MAX_EVENTS_PER_BATCH (default 30)
  para no superar el tamano de body del receptor (HTTP 413).
- evn1-repository.ts: findExisting falla si el GET falla, en vez de
  tratarlo como "no existe" y crear un duplicado.
- service-layer-client.ts: errores incluyen metodo, ruta y body.
- logger.ts: no poder escribir el archivo de log no tumba el proceso.
- CLAUDE.md: notas operativas y mejoras pendientes.
2026-10-20 15:26:39 -03:00

107 lines
4.2 KiB
TypeScript

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<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 = {
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}`);
},
};