Saltar a contenido

ADR-019 · Error taxonomy + logging spec (structlog JSON, sampling, retention)

Fecha: 2026-04-29 Source: /srv/projects/cis/cis-plan/DECISIONS.md (do not edit here — re-split desde la fuente)


Contexto

CIS opera 16 servicios live, cada uno con su propia política de logging. Algunos usan print() (cis-isometrico CLI), otros structlog parcial (cis-platform, cis-mailer), otros logging stdlib en plain text (cis-claudia v1, cis-admin legacy). No hay correlación de errores cross-service: un fallo en checkout que toca cis-pagoscis-platformcis-mailer produce 3 logs sin request_id compartido, lo que hace imposible reconstruir el flujo cuando un user reporta "no me llegó el mail".

Adicionalmente, el bug del P0.2 (except ImportError: pass en cis-core que ocultó la caída de MCP tools por 8 días) demuestra que la prohibición debe ser explícita en el ADR canónico: silenciar excepciones sin loguearlas es código inseguro en cualquier servicio T0–T2.

La constitución (§10.4 y §10.5) menciona structlog JSON pero no fija las keys obligatorias, las políticas de sampling ni la retención. Este ADR cierra el gap.

Alternativas consideradas

  1. (a) Plain text + journalctl, sin estandarización
  2. Pro: cero overhead, journalctl es el default systemd.
  3. Contra: imposible parsear cross-service. No se puede correlacionar request_id. No hay queryability estructurada.

  4. (b) Structlog JSON + keys canónicas + clasificación de excepcionespropuesta

  5. Pro: cada log es una row JSON queryable. journalctl -o json + jq permite búsqueda cross-service. Clasificación de excepciones permite alerting selectivo (ServerError → page; ClientError → log nada más).
  6. Contra: cada servicio debe adoptar la configuración. Costo inicial ~1h por servicio.

  7. (c) Stack ELK / OpenTelemetry / Sentry

  8. Pro: features avanzadas (tracing distribuido, alerting nativo, dashboards).
  9. Contra: complejidad operacional alta para CIS (otro servicio que mantener). Costo $$. journalctl + cis-monitoreo ya cubre 80% del use case con $0.

Decisión

Logging structlog JSON con keys canónicas obligatorias para todo servicio Python:

{
  "timestamp": "2026-04-29T19:23:11.234Z",
  "level": "INFO",
  "service": "cis-pagos",
  "event": "order_created",
  "request_id": "req_<uuid>",
  "user_id": "<authentik_uuid>",
  "session_id": "<authentik_session_id>",
  "error_code": null,
  "error_class": null,
  "duration_ms": 142,
  ...payload específico
}

Keys obligatorias en cada log: timestamp, level, service, event, request_id. Keys condicionales: user_id y session_id si la request pasó por OIDC; error_code y error_class si level >= WARN.

Sampling: - DEBUG — 1% en producción (sample por request_id hash). 100% en staging/dev. - INFO — 100% siempre. - WARN, ERROR, CRITICAL — 100% siempre.

Retention: - journalctl local (vps-cis): 90 días default (SystemMaxUse=2G, rotación auto). - Archive externo (output de journalctl --output=json | gzip cron diario): 365 días en /srv/log-archive/<service>/<yyyy-mm>.json.gz. Compresión típica ~95%. - Estimación: 16 servicios × ~50MB/día comprimidos → ~24GB/año. Cabe en el VPS.

Taxonomía de excepciones:

class CISError(Exception):
    """Base. Nunca raisearla directamente."""
    error_code: str  # 'CLIENT.AUTH.EXPIRED', 'SERVER.DB.CONN_LOST', etc.

class ClientError(CISError):
    """Causada por input/estado del cliente. HTTP 4xx. No alertable."""

class ServerError(CISError):
    """Causada por bug interno. HTTP 5xx. Alerta a cis-inbox vía cis-monitoreo."""

class IntegrationError(CISError):
    """Servicio externo (SII, Resend, Flow, Google) caído o rechazó. HTTP 502. Alerta + retry."""

Mapping HTTP en core/py-common/errors.py (ADR-016): ClientError → 400, auth-related ClientError → 401/403, not-found ClientError → 404, ServerError → 500, IntegrationError → 502/503.

Prohibición canon: except ImportError: pass, except Exception: pass, y cualquier except sin al menos log.error(...) o log.exception(...) están prohibidos en código de servicios. Ruff TRY203/TRY004/BLE001 enforcing en pre-commit (ADR-018). Excepción única documentada: try: ... except (KeyboardInterrupt, SystemExit): raise — el silencio es semántico ahí.

Consecuencias

  • Positivo: cross-service tracing posible vía request_id (generado en gateway cis-platform y propagado por header X-Request-ID). Alerting selectivo vía error_class reduce ruido. La prohibición explícita captura el bug del P0.2 a futuro.
  • Negativo: cada servicio debe adoptar core/py-common/logging.py y propagar request_id. Costo P0.4 + adopción gradual.
  • Riesgo: keys obligatorias mal pobladas (user_id ausente en logs autenticados) reducen utilidad. Mitigación: dependency injection de logging en routers FastAPI vía Depends(get_logger) que ya recibe el JWT decoded.
  • Migración: servicios existentes migran cuando toquen app/main.py o app/logging.py. T0 bloqueante en P0.4. T2 (cis-admin, cis-inbox, cis-pagos, cis-claudia, cis-monitoreo) durante Bloque F. T3 cuando estabilizen.
  • Compatibilidad: journalctl -o json ya funciona; cualquier herramienta de inspección actual sigue válida. La key event es estable (no se renombra) — si alguna parsing externa la consume, queda intacto.
  • Dependencia de ADR-016: core/py-common/logging.py es el módulo único que configura structlog. Sin esa lib, cada servicio reimplementa y diverge.