Saltar al contenido principal

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.ts devolvía null y chat.ts { ok: false }. health.ts devolvía write_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​

  1. Una tabla propia, platform_error_events, no core_alerts. core_alerts es 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.
  2. 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 un APP_FILTER global. Así la limpieza, la severidad, la huella y el SMTP viven en un solo sitio.
  3. 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.
  4. 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.
  5. 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.
  6. 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 supabaseKey o api_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 anon y authenticated: solo la lee y la escribe service_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.
  7. 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. reportActionError es síncrona y deja el fetch corriendo aparte, con un plazo de 3 s. report() del backend se agenda con setImmediate. 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 observa uncaughtExceptionMonitor, 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 id del evento) que el usuario ve en la pantalla de error y que se busca directo en la tabla.
  • El 42501 se 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 onRequestError de Next llegan sin sesión: el hook no tiene la cookie a mano. Se cruzan con el reporte del navegador por el digest.
  • 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.