0012 — Registro de errores con aviso por correo
Fecha: 2026-09-23 · Estado: aceptada
Contexto
Cuando un usuario decía "me salió un error" en la historia clínica, la agenda, contactos o conversaciones, no había forma de saber qué pasó ni de reproducirlo:
- No había Sentry, ni
error.tsx, ni filtro global de excepciones en Nest. - Las Server Actions se tragaban el error.
contact.tsdevolvíanullychat.ts{ ok: false }.health.tsdevolvíawrite_failed, pero el código de Postgres, el mensaje y el stack solo quedaban en el log del contenedor.
El usuario pidió un aviso por correo con la traza y la severidad, un histórico consultable que se
borre a los tres meses, destinatarios configurables en una tabla (el .env solo para secretos) y,
sobre todo, que el registro nunca interfiera con la app ni la haga fallar.
Decisión
- Una tabla propia,
platform_error_events, nocore_alerts.core_alertses la bandeja de un tenant: el tenant es obligatorio, cada fila declara un permiso y la resuelve recepción. Un error técnico es para el equipo, puede no tener tenant (un fallo en el login) y necesita severidad, huella y stack. - El backend es el único destino. El navegador, las Server Actions y el servidor de Next
reportan a
POST /api/errors/report, y los 5xx del propio backend pasan por unAPP_FILTERglobal. Así la limpieza, la severidad, la huella y el SMTP viven en un solo sitio. - La severidad la decide el backend, no quien reporta. El navegador solo dice qué clase de
fallo fue (
kind) y dónde (module). La historia clínica siempre es crítica; sin sesión nunca pasa de media, para que nadie pueda inundar el correo desde fuera. - Un correo por error distinto. Los errores se agrupan por huella (módulo, acción, mensaje sin
ids ni números y primer frame del stack). La primera ocurrencia avisa al momento. Si el error se
repite, se vuelve a avisar como mucho cada
repeat_email_minutes, con el conteo de repeticiones. Hay un tope de correos por hora. - Configuración en
platform_settings['errors.reporting'], editable desde/admin/settings/platform: destinatarios, severidad mínima, intervalo, tope y retención. Para enviar se reutiliza el SMTP global (SMTP_*), sin variables nuevas. - Lo que se intentaba guardar va tal cual, con PHI. El usuario lo decidió el 2026-09-23:
para reproducir un error hay que ver los datos reales (la ficha, la evolución, la cita, el
contacto). Por eso:
- Cada Server Action manda sus argumentos en
context.input. - El filtro del backend manda el cuerpo de la petición.
- No se enmascaran cédulas, nombres, diagnósticos ni teléfonos.
- Solo se quitan las credenciales: contraseñas, tokens, JWT y claves como
supabaseKeyoapi_key. No ayudan a reproducir nada y abren una puerta si el correo se filtra. Los clientes de Supabase no se mandan como argumento. - El tamaño se controla en origen y otra vez en el backend. La traza va hasta 8 KB y el
mensaje hasta 2 KB. Cada texto se corta en 4000 caracteres y los arrays en 50 elementos. Hay
100 claves y 6 niveles como máximo, y el contexto entero se queda en ~30 KB. Si no cabe,
queda una vista previa marcada
truncated. Los archivos se describen (nombre, tamaño, tipo), no se mandan. - La tabla está cerrada a
anonyauthenticated: solo la lee y la escribeservice_role. - El correo lleva PHI. Los destinatarios deben ser personas autorizadas a ver historias clínicas y un buzón del dominio de la empresa.
- Cada Server Action manda sus argumentos en
- Retención: un scheduler diario del backend borra lo más viejo que
retention_days(90 por defecto, mínimo 7).
No interferencia
Es la regla que más pesa. Cada punto tiene su prueba:
- Nada lanza. Todas las funciones de reporte van dentro de
try/catch, también ante entradas circulares o basura. - Nada bloquea.
reportActionErrores síncrona y deja elfetchcorriendo aparte, con un plazo de 3 s.report()del backend se agenda consetImmediate. El correo sale fuera del ciclo de la petición, con plazos cortos de SMTP. - Nada cambia. Las acciones devuelven lo mismo que antes. El filtro delega en
BaseExceptionFilter, así que la respuesta es la misma. Del proceso solo se observauncaughtExceptionMonitor, que no cambia si el proceso sigue o termina. - No hay bucles. Un fallo al guardar o al enviar solo deja una línea de log. Además hay límites por IP y por usuario, deduplicación y un tope de reportes por pestaña.
- Se puede apagar sin desplegar con
enabled: false. Si la configuración no se puede leer, se guarda pero no se envía correo.
Consecuencias
- Hay un código de referencia (el
iddel evento) que el usuario ve en la pantalla de error y que se busca directo en la tabla. - El
42501se reporta como alto. Es ambiguo: puede ser un usuario sin permiso (algo esperado) o un GRANT roto que deja la agenda vacía para todos, que ya pasó. - Los errores de
onRequestErrorde Next llegan sin sesión: el hook no tiene la cookie a mano. Se cruzan con el reporte del navegador por eldigest. - Los límites por IP y por usuario viven en memoria, por instancia. Basta para frenar una tormenta; no es un límite global exacto.