Observabilidad de errores — plan de implementación¶
Fecha: 2026-07-28 · Última actualización: 2026-08-10
Contexto: README.md — diagnóstico (P1–P9) y estado deseado.
Seguimiento: historia US #278 (DevOps), tareas #311–#318.
Uso: este documento es el mapa de trabajo. Cada fase es entregable por sí sola y deja el sistema mejor que antes. Marcar los checkboxes a medida que se completa; anotar decisiones/desvíos en la sección 7.
Vista general¶
| Fase | Nombre | Resuelve | Esfuerzo | Depende de |
|---|---|---|---|---|
| 1 | Un solo request_id de punta a punta | P2, P9 | ~1 día | — |
| 2 | Política de captura y contexto en GlitchTip | P1, P3, P4 | ~1-2 días | F1 (para taggear el ID) |
| 3 | Errores con causa + helper de background jobs | P5, P6 | incremental | F2 |
| 4 | Logs estructurados + Loki | P7 | ~2-3 días | F1 (request_id en logs) |
| 5 | Alertas y cierre del loop con el usuario | P8, UX | ~1 día | F2 |
Orden recomendado: 1 → 2 → 5 → 4 → 3. Las fases 1+2 dan el 70 % del valor; la 5 es barata y visible; la 4 es infra; la 3 es limpieza incremental que puede correr en paralelo.
Fase 1 — Un solo request_id de punta a punta¶
Objetivo: un único ID por request que el usuario puede reportar y el equipo puede buscar.
Diseño:
- El frontend genera el ID (crypto.randomUUID()) en el interceptor de request de Axios y lo envía como header X-Request-Id. Así el mismo ID existe aunque el request muera antes de llegar al backend (timeout, CORS, red).
- El backend acepta el header si viene, o genera uno si no (requests externos, Swagger, API key). Lo guarda en RequestContextService (AsyncLocalStorage ya existente) para que cualquier capa lo lea sin pasarlo por parámetro.
- El backend siempre devuelve X-Request-Id en la respuesta (éxito y error) y lo usa como referenceId en el JSON de error. El Math.random() del filtro se elimina.
Tareas backend:
- [x] common/request-context/request-id.middleware.ts (nuevo): acepta/genera el ID, lo deja en req.requestId y lo copia al ALS vía RequestContextInterceptor. (Se implementó como middleware express puro, no en el interceptor: los exception filters pueden correr fuera del contexto ALS, así que la fuente de verdad es req.requestId.)
- [x] sentry-scope.interceptor.ts: lee el ID del middleware; mantiene el tag request_id.
- [x] Header de respuesta X-Request-Id en toda respuesta (mismo middleware).
- [x] sap-error.filter.ts: generateReferenceId() eliminado; referenceId = request_id en las 4 ramas. La rama HttpException agrega referenceId al body sin romper el contrato. UnauthorizedExceptionFilter también lo incluye.
- [x] main.ts / CORS: exposedHeaders: ['X-Request-Id'] + registro del middleware primero.
- [x] sap-client.module.ts: prefijo [rid:…] en los logs de los interceptores y header X-Request-Id hacia SAP.
Tareas frontend:
- [x] omsBackend.ts (interceptor de request): genera y adjunta X-Request-Id a todos los requests, incluidos los de auth.
- [x] omsBackend.ts (interceptor de response/error): getErrorReferenceId() extrae body → header → header enviado (cubre errores de red donde el backend nunca respondió).
- [x] Tag request_id + breadcrumb en el GlitchTip del frontend al fallar un request.
Criterio de aceptación: provocar un error de SAP desde la UI → el JSON de error, el header de respuesta, el log del backend y el evento GlitchTip (backend y frontend) comparten el mismo ID.
Fase 2 — Política de captura y contexto en GlitchTip¶
Objetivo: todo error que un usuario percibe llega a GlitchTip, con contexto SAP completo y agrupado por tipo real de problema.
Diseño — qué se envía y qué no:
| Tipo de error | Acción | Nivel |
|---|---|---|
SapBusinessException / error de negocio SAP (4xx) |
enviar | warning |
| 5xx, unhandled, AxiosError sin respuesta (timeout/red) | enviar | error |
BadRequestException de ValidationPipe (DTO inválido) |
descartar | — |
| 401 / 403 / 404 | descartar | — |
Tareas:
- [x] instrument.ts: beforeSend reescrito. (Implementación final: descarta 4xx sin errorCode propio — cubre validación, 401/403/404 — sin importar código de Nest en instrument.ts, que debe cargar antes que todo. La captura principal es explícita en el filtro; beforeSend queda como red de seguridad.)
- [x] sap-error.filter.ts: captura explícita con contexto (decorador @SentryExceptionCaptured() eliminado):
Sentry.captureException(sapException, (scope) => {
scope.setLevel(status >= 500 ? 'error' : 'warning');
scope.setContext('sap', {
endpoint, // método + URL del Service Layer
sapCode, sapMessage, // extraídos del body de error
responseBody, // truncado ~4 KB
requestBody, // truncado ~4 KB, redactado (ver abajo)
durationMs,
});
scope.setTag('sap_error_code', String(sapCode));
scope.setFingerprint([errorCode, request.route?.path ?? request.url]);
return scope;
});
common/sentry/sensitive-data.util.ts (toSafePayload): redacta por clave (password/token/secret/card/cvv/pin/…), trunca a ~4 KB, nunca lanza.
- [x] Fingerprint: [errorCode, "MÉTODO ruta"] en todas las capturas del filtro.
- [x] SapBusinessException acepta cause; fromSapError() conserva el AxiosError original y el filtro extrae de ahí endpoint/bodies/duración.
- [ ] Verificar en GlitchTip (staging) que los eventos se agrupan por fingerprint → tarea #318.
Criterio de aceptación: tres errores de negocio distintos (ej. stock insuficiente, cliente bloqueado, período contable cerrado) generan tres issues separados en GlitchTip, cada uno con contexto sap completo y tag request_id. Un 400 de validación de DTO no genera evento.
Fase 3 — Errores con causa + background jobs (incremental)¶
Objetivo: eliminar el boilerplate de ~90 catch que pierden el error original, y hacer visibles los errores de schedulers/listeners.
Diseño:
- Regla nueva para servicios: no envolver a mano. Ante un error de SAP, o (a) no atrapar y dejar que SapErrorFilter lo maneje (caso más común: el catch actual solo re-formatea), o (b) si hace falta mensaje de dominio, lanzar SapBusinessException.fromSapError(error, codigo) — nunca new BadRequestException(interpolación).
- Para los catch que degradan a warning y continúan (ej. inventory.service.ts:97-103): mantener el comportamiento pero agregar Sentry.captureMessage(..., 'warning') con tag del flujo, para que dejen de ser invisibles.
Tareas:
- [x] Helper captureJobError(jobName, error, extra?) en common/sentry/capture-job-error.ts → tag job:<nombre>, fingerprint [job, nombre, tipo], contexto; aplicado en:
- [x] services/bancard/bancard-qr.cancel.scheduler.ts (2 catch)
- [x] notifications/transfer-notifications.listener.ts — no tiene catch propios: sus errores suben como unhandled rejection y el SDK ya los captura; sin cambios.
- [x] notifications/activity-notifications.listener.ts (3 catch, con tag del evento)
- [x] services/algolia/indexing.service.ts (cron, evento e incremental)
- [x] commands/reindex-customers.ts — además ahora importa ./instrument (el SDK no se inicializaba en el proceso CLI) y hace Sentry.flush() antes del exit.
- [x] Regla documentada en CLAUDE.md raíz (sección "Error Handling (backend)").
- [ ] Migración incremental de los ~90 catch, por módulo y por orden de volumen de reportes:
- [ ] orders → [ ] inventory → [ ] payments → [ ] invoices → [ ] delivery-notes → [ ] items → [ ] resto.
- Cada módulo migrado: verificar que sus specs sigan pasando y que el mensaje al usuario no empeore (el filtro ya muestra el mensaje real de SAP).
Criterio de aceptación: un error lanzado dentro del scheduler de Bancard aparece en GlitchTip con tag job:bancard-qr-cancel. En los módulos migrados, los eventos muestran el stack del error original de Axios como cause.
Fase 4 — Logs estructurados + Loki¶
⏸ POSPUESTA (decisión del 2026-08-10): queda solo documentada; no se implementa en esta etapa. El alcance actual es todo lo relacionado a GlitchTip (F1, F2, F3, F5). Mientras tanto, el prefijo
[rid:…]en los logs ya permite grep por request_id endocker logs.
Objetivo: poder reconstruir todo lo que pasó en un request buscando por request_id, con retención ≥ 30 días.
Diseño:
- nestjs-pino como logger de la app (reemplaza el Logger default vía app.useLogger(); los new Logger(X) existentes siguen funcionando sin cambios — pino los intercepta). Salida JSON con request_id, userId, branch, context, durationMs.
- Grafana Loki + Grafana para agregación y búsqueda. Encaja con la infra actual (compose centralizado en el repo infra; Loki y Grafana como servicios nuevos; el driver de logs de Docker o Promtail/Alloy enviando los logs de los contenedores backend/frontend).
- Los logs de diagnóstico del sap-client (sap-client.module.ts:216-242) pasan a ser objetos estructurados ({ sapEndpoint, sapStatus, durationMs, sapCode, sapMessage, requestBody }) en vez de strings interpolados.
Tareas:
- [ ] Backend: instalar y configurar nestjs-pino (+ pino-http), con genReqId leyendo X-Request-Id (coherente con Fase 1), redacción de headers sensibles (authorization, cookie) vía redact.
- [ ] Convertir los logs del sap-client a estructurados.
- [ ] Infra (repo infra / compose): servicios loki y grafana, shipping de logs de los contenedores (recomendado: Grafana Alloy o Promtail leyendo el journal de Docker), retención 30 días.
- [ ] Dashboard mínimo en Grafana: (a) búsqueda por request_id, (b) tasa de errores SAP por endpoint, (c) p95 de duración de llamadas SAP.
- [ ] Documentar en el repo infra cómo levantar y cómo buscar (runbook de 10 líneas).
Criterio de aceptación: con un request_id de un error real, una sola query en Grafana devuelve todas las líneas del request: entrada HTTP, llamadas a SAP con duración, error con body, respuesta al cliente.
Nota: si montar Loki se demora, un paso intermedio barato es solo nestjs-pino + docker logs con jq — ya habilita búsqueda por request_id en la máquina, sin retención.
Fase 5 — Alertas y cierre del loop con el usuario¶
Objetivo: enterarse antes que el usuario, y que el usuario reporte con un dato útil.
Tareas:
- [ ] GlitchTip: configurar alertas del proyecto backend — issue nuevo, y umbral de frecuencia (ej. > 10 eventos/hora del mismo issue). Destino: email del equipo o webhook (evaluar canal real del equipo: correo TI vs. webhook a algún chat).
- [ ] GlitchTip: misma configuración para el proyecto frontend.
- [x] Frontend — toast de error global (omsBackend.ts): muestra Ref: <requestId>, acción "Copiar detalles" (ref + error + página + fecha + endpoint) y la Ref va también en el reporte por WhatsApp existente.
- [ ] Guía de 1 página para soporte/usuarios clave: "si ves un error, mandá la Ref" + cómo el equipo la busca (GlitchTip por tag request_id, Grafana por el mismo ID).
Criterio de aceptación: provocar un error nuevo en staging → llega la alerta al canal elegido en < 5 min. El toast muestra la Ref y el botón copia un texto pegable en un ticket.
6. Variables de entorno nuevas / tocadas¶
| Variable | Fase | Nota |
|---|---|---|
GLITCHTIP_DSN (backend/frontend) |
existente | sin cambios |
LOKI_* / config de Alloy-Promtail |
4 | vive en el repo infra, no en los .env de las apps |
| — | — | Recordar el tech-debt ya conocido de env vars dispersas: cualquier variable nueva se agrega también al inventario/1Password según la práctica que se adopte. |
7. Bitácora de decisiones y desvíos¶
Completar durante la implementación. Formato: fecha — decisión — motivo.
- 2026-07-28 — Se decide mantener GlitchTip (no migrar a Sentry SaaS): los problemas son de instrumentación; el fingerprint manual compensa la agrupación débil.
- 2026-07-28 — El request_id nace en el frontend, no en el backend: cubre errores de red/timeout donde el backend nunca responde.
- 2026-07-28 — Tracing de performance (
tracesSampleRate > 0) queda fuera de alcance hasta terminar F1–F5. - 2026-08-10 — Fase 4 (Loki) pospuesta: el alcance de esta etapa es solo GlitchTip. Queda documentada para retomar.
- 2026-08-10 — F1, F2, F3 y la parte frontend de F5 implementadas (tareas Taiga #311–#316). Pendientes: alertas en la UI de GlitchTip (#317) y verificación end-to-end en staging (#318).
- 2026-08-10 — El request_id vive en
req.requestId(middleware express) y no solo en AsyncLocalStorage: los exception filters pueden ejecutarse fuera del contexto ALS y el ID debe existir siempre. - 2026-08-10 —
beforeSendfiltra por duck-typing (getStatus+ sinerrorCode) en vez deinstanceof:instrument.tsno puede importar código de Nest porque debe cargar antes que todo. - 2026-08-10 — Hallazgo: el
SentryGlobalFilterdeapp.module.tsnunca se ejecuta para HTTP (elSapErrorFilteres catch-all y lo tapa). Se deja registrado porque cubre contextos no-HTTP, pero la captura HTTP es 100 % delSapErrorFilter. - 2026-08-10 — Suites de test preexistentes rotas (27 en backend: DI de Continental/Firebase/SSO, expectativas viejas de cookies; 1 en frontend: order.mapper). No relacionadas a estos cambios; los specs del área tocada (
sap-business.exception,sap-client-error-handling) pasan.