Módulo 8: Proyecto Integrador — Production-Ready AI System
7. Runbook Operacional
Descripción
Son las 3am. Una alerta disparó. Tu servicio está degradado. El runbook es la diferencia entre que tú resuelvas el problema en 5 minutos o que pases 2 horas intentando recordar cómo funciona el sistema bajo presión. Un runbook no es documentación de arquitectura — es tu guía de acción inmediata que responde "¿qué hago ahora?" para cada tipo de incidente. En esta cápsula tú vas a crear el runbook completo para tu Production AI System, con comandos listos para copiar y árboles de decisión que puedes seguir sin pensar. Cuando termines, vas a tener un documento que te salva tiempo real cuando algo sale mal — porque en un incidente, tu capacidad de pensar claramente está reducida, y el runbook piensa por ti.
Qué tiene un buen runbook (y qué no)
❌ Runbook que no sirve de nada a las 3am:
"El sistema usa clean architecture con DI..."
"La reliability layer está compuesta por..."
"El modelo de datos es..."
✅ Runbook que sí sirve:
INCIDENTE: Latencia alta (requests tardando > 10 segundos)
SÍNTOMAS TÍPICOS:
- Alertas de p99 > 15s
- Usuarios reportan "spinner infinito"
- Health /ready retorna 200 pero requests lentos
DIAGNÓSTICO (ejecutar en este orden):
1. curl http://app/health/deps → ¿OpenAI responde?
2. jq 'select(.duration_ms > 10000)' logs/app.json | tail -5 → ¿qué endpoints?
3. Revisar https://status.openai.com → ¿hay incidente?
RESOLUCIÓN:
- Si es incidente de OpenAI → comunicar a usuarios, esperar
- Si el circuit está abierto → el fallback debería activarse (verificar degraded=true en responses)
- Si no hay incidente pero es lento → revisar prompt length, max_tokens config
ROLLBACK: Reducir max_tokens en .env si prompts son muy largos, redeploy
RUNBOOK.md completo
# Runbook Operacional — Production AI System
Versión: 1.0
Última actualización: [FECHA]
---
## Información de contacto y escalación
| Nivel | Cuándo escalar | Contacto |
|-------|----------------|----------|
| L1: On-call | Primer respondedor | [Slack canal #alerts] |
| L2: Senior eng | Problema persiste > 15min | [PagerDuty] |
| L3: Lead | Impacto > 50% de usuarios, costo > $200 | [Teléfono directo] |
**SLO target**: < 1% de requests con error en ventanas de 5 minutos
---
## Comandos de diagnóstico rápido
\`\`\`bash
# Estado general del sistema:
curl -s http://APP_URL/health/deps | python -m json.tool
# Últimos 20 errores:
tail -200 logs/app.json | jq 'select(.level == "error")' | tail -20
# Latencia última hora:
tail -1000 logs/app.json | jq 'select(.event == "request_completed") | .duration_ms' | awk '{s+=$1; n++} END {print "avg:", s/n, "ms"}'
# Costo del día de hoy:
today=$(date +%Y-%m-%d)
jq --arg d "$today" 'select(.timestamp | startswith($d)) | .cost_usd' logs/app.json | awk '{s+=$1} END {print "Total: $" s}'
# Circuit breaker status:
curl -s http://APP_URL/health/deps | jq '.checks.circuit_breakers'
# Fallback activation en última hora:
tail -200 logs/app.json | jq 'select(.event == "fallback_provider_used")' | wc -l
\`\`\`
---
## Incidente 1: Alta Latencia
**Severidad**: Media-Alta
**SLO impact**: p99 > 15s por > 5 minutos
**Síntomas:**
- Alertas de latencia p99 > 15,000ms
- Usuarios reportan "carga infinita"
- health/live retorna 200, pero requests son lentos
**Diagnóstico (en orden):**
\`\`\`bash
# Paso 1: Verificar si OpenAI está lento
curl -s http://APP_URL/health/deps | jq '.checks.openai'
# → "latency_ms": 8000 # OpenAI está lento
# → "status": "error" # OpenAI está caído
# Paso 2: Verificar si el circuit breaker está activado
curl -s http://APP_URL/health/deps | jq '.checks.circuit_breakers'
# → Si algún circuit muestra "state": "open", el fallback debe activarse
# Paso 3: Ver los requests más lentos
tail -500 logs/app.json | jq 'select(.event == "request_completed") | {duration_ms, request_id}' | jq -s 'sort_by(.duration_ms) | reverse | .[0:5]'
# Paso 4: Revisar si hay retries (señal de errores transitorios)
tail -200 logs/app.json | jq 'select(.event == "llm_retry_attempt")' | wc -l
\`\`\`
**Resolución:**
| Causa | Acción |
|-------|--------|
| Incidente OpenAI | Comunicar a usuarios. Verificar fallback activo. Esperar. |
| Circuit abierto pero sin fallback | Ver incidente 3 (errores 5xx) |
| Prompts muy largos | Reducir `max_tokens` en config, redeploy |
| Rate limit | Ver incidente 5 (rate limit) |
**Comunicación a usuarios** (si > 5 min):
> "Estamos experimentando latencia alta en el servicio de análisis. El equipo está investigando. ETA para resolución: 15-30 min."
---
## Incidente 2: Costo Anormalmente Alto
**Severidad**: Alta (impacto financiero directo)
**Trigger típico**: Alerta de costo/día > $X
**Síntomas:**
- Alerta del budget limiter o del billing de OpenAI
- Muchos tokens por request en logs
- Posible abuse (muchos requests desde una fuente)
**Diagnóstico:**
\`\`\`bash
# Paso 1: Ver costo por request
tail -100 logs/app.json | jq 'select(.cost_usd != null) | .cost_usd' | sort -n | tail -20
# Paso 2: Identificar requests más caros
tail -500 logs/app.json | jq 'select(.cost_usd > 0.05) | {request_id, cost_usd, input_tokens, output_tokens}' | head -20
# Paso 3: Ver si hay muchos requests de una fuente (abuse)
tail -1000 logs/app.json | jq -r '.client_ip // "unknown"' | sort | uniq -c | sort -rn | head -10
# Paso 4: Ver total del día
today=$(date +%Y-%m-%d)
jq --arg d "$today" 'select(.timestamp | startswith($d)) | .cost_usd // 0' logs/app.json | awk '{s+=$1} END {print "Today: $" s}'
\`\`\`
**Resolución:**
| Causa | Acción |
|-------|--------|
| Bug en prompt (genera tokens extra) | Revisar último deploy, rollback si necesario |
| Abuse (muchos requests) | Rate limit por IP, bloquear fuente si aplica |
| Input muy largo (muchos tokens) | Añadir truncación de input antes del LLM |
| max_tokens muy alto innecesariamente | Reducir max_tokens en config |
**Si el costo sigue subiendo y no se puede detener:**
1. Activar `USE_MOCK_PROVIDER=true` temporalmente (el servicio degrada a mock, cero costo)
2. O desactivar el endpoint afectado hasta resolver
---
## Incidente 3: Errores 5xx (Tasa > 1%)
**Severidad**: Alta
**SLO impact**: error rate > 1% por > 5 minutos
**Síntomas:**
- Usuarios reportan "Error del servidor"
- Alertas de error rate
- health/ready puede retornar 503
**Diagnóstico:**
\`\`\`bash
# Paso 1: Health check completo
curl -v http://APP_URL/health/ready
curl -v http://APP_URL/health/deps
# Paso 2: Ver errores recientes con context
tail -200 logs/app.json | jq 'select(.level == "error") | {event, error_type, error_message: .error_message[:200], request_id}' | head -10
# Paso 3: Buscar un request fallido específico para trazar
FAILED_REQUEST_ID="abc123"
grep "$FAILED_REQUEST_ID" logs/app.json | jq .
# Paso 4: Ver estado del circuit breaker
curl -s http://APP_URL/health/deps | jq '.checks.circuit_breakers'
# Paso 5: Ver si el fallback se está activando (señal de que el primary falla)
tail -100 logs/app.json | jq 'select(.event == "fallback_provider_used")' | wc -l
\`\`\`
**Árbol de decisión:**
\`\`\`
¿health/deps retorna error en openai?
├── SÍ (OpenAI caído):
│ ├── ¿circuit breaker está OPEN? → el fallback debe activarse
│ │ ├── SÍ y fallback funciona → los requests degradan pero no fallan → OK
│ │ └── SÍ pero fallback también falla → incidente crítico, escalar
│ └── NO (circuit abierto pero OpenAI dice que está bien) → race condition, esperar
└── NO (OpenAI dice que está bien pero hay errores):
├── ¿Los errores son de parse? (event: "json_parse_completely_failed")
│ → Prompt issue, revisar último cambio de prompt
└── ¿Los errores son de auth? (event: "llm_call_failed", error_type: AuthenticationError)
→ API key issue, verificar OPENAI_API_KEY en env
\`\`\`
---
## Incidente 4: Guardrails Bloqueando Inputs Legítimos (Falsos Positivos)
**Severidad**: Media
**Síntomas:**
- Usuarios reportan "Mi mensaje fue rechazado"
- Muchos logs de `guardrail_activated` para requests que parecen legítimos
**Diagnóstico:**
\`\`\`bash
# Ver qué inputs están siendo bloqueados
tail -200 logs/app.json | jq 'select(.event == "input_guardrail_blocked") | {reason, text_preview, request_id}'
# Tasa de activación de guardrails
total=$(tail -1000 logs/app.json | jq 'select(.event == "request_completed")' | wc -l)
blocked=$(tail -1000 logs/app.json | jq 'select(.event == "input_guardrail_blocked")' | wc -l)
echo "Guardrail activation rate: $blocked / $total"
\`\`\`
**Resolución:**
| Causa | Acción |
|-------|--------|
| Pattern muy agresivo | Ajustar `injection_sensitivity` en config: `medium` → `low` |
| Nuevo falso positivo común | Añadir excepciones en el guardrail |
| Bug en actualización de guardrails | Rollback del deploy |
**Nota**: No desactivar guardrails completamente. Ajustar el threshold o añadir excepciones específicas.
---
## Incidente 5: Rate Limit de OpenAI (429 Masivos)
**Severidad**: Media
**Síntomas:**
- Muchos `llm_retry_attempt` con error_type `RateLimitError` en logs
- Latencia aumenta (por backoff)
- Circuit puede empezar a abrirse
**Diagnóstico:**
\`\`\`bash
# Ver frecuencia de rate limit errors
tail -500 logs/app.json | jq 'select(.event == "llm_retry_attempt" and .exception_type == "RateLimitError")' | wc -l
# Ver si el rate limiter client-side está ayudando
tail -200 logs/app.json | jq 'select(.event == "client_rate_limit_rejected")' | wc -l
# Si este número es alto, el rate limiter está funcionando pero insuficiente
# Ver la tasa de requests por minuto
tail -1000 logs/app.json | jq '.timestamp' | cut -c1-16 | sort | uniq -c | sort -rn | head -10
\`\`\`
**Resolución:**
| Causa | Acción |
|-------|--------|
| Spike de tráfico | Rate limiter debería estar throttleando. Si no: ajustar `MAX_REQUESTS_PER_MINUTE` |
| Rate limit muy alto en settings | Reducir `MAX_REQUESTS_PER_MINUTE` al 60% del límite real de OpenAI |
| Tier change en OpenAI | Verificar límites actuales en platform.openai.com/limits |
---
## Incidente 6: Deploy Roto (Post-Deploy Issues)
**Severidad**: Crítica
**Síntomas:**
- health/live devuelve 500 (proceso no arrancó)
- health/ready devuelve 503 (startup checks fallaron)
- Errores de configuración en los primeros logs
**Diagnóstico:**
\`\`\`bash
# Ver los primeros logs al arrancar
head -20 logs/app.json | jq .
# Buscar errores de startup
grep "startup\|RuntimeError\|ValueError" logs/app.json | head -10
# Verificar configuración
python -c "from src.config import get_settings; get_settings()"
\`\`\`
**Resolución inmediata:**
\`\`\`bash
# Rollback al deploy anterior (Kubernetes):
kubectl rollout undo deployment/production-ai-system
kubectl rollout status deployment/production-ai-system
# Verificar que el rollback funcionó:
curl http://APP_URL/health/live
python scripts/post_deploy_check.py --no-monitor
\`\`\`
---
## Procedimiento de rollback completo
\`\`\`bash
# 1. Identificar la versión anterior:
kubectl rollout history deployment/production-ai-system
# 2. Hacer rollback:
kubectl rollout undo deployment/production-ai-system
# 3. Verificar que está corriendo:
kubectl rollout status deployment/production-ai-system
# 4. Smoke test:
curl http://APP_URL/health/live
curl http://APP_URL/health/ready
curl -X POST http://APP_URL/api/v1/analyze \
-H "Content-Type: application/json" \
-d '{"text": "test"}'
# 5. Si el smoke test pasa, el rollback fue exitoso.
# 6. Si falla: escalar a L3 y/o desactivar el servicio temporalmente.
\`\`\`
---
## Post-Incident: ¿Qué hacer después de resolver un incidente?
En las próximas 24 horas:
-
Escribir un post-mortem breve (5 min — no una novela):
- ¿Qué pasó?
- ¿Cuánto tiempo duró?
- ¿Por qué pasó? (causa raíz, no síntoma)
- ¿Qué evitará que pase de nuevo?
-
Añadir el nuevo tipo de incidente a este runbook si no estaba
-
Si hubo impacto en usuarios, comunicar resolución
Template de post-mortem mínimo:
INCIDENTE: [fecha y hora]
DURACIÓN: X minutos
IMPACTO: X% de usuarios afectados
CAUSA RAÍZ: [una oración]
ACCIONES:
- [X] Arreglado en el deploy de [fecha]
- [ ] TODO: [mejora preventiva] — asignado a [nombre]
---
## Ejercicios
### Ejercicio 1: Simular el diagnóstico de Incidente 1
En tu sistema local, añade un mock que retarda la respuesta 8 segundos y verifica que:
1. Ves los logs de latencia alta
2. Sabes qué comando usar para encontrarlos
3. El circuit breaker eventualmente abre
<details>
<summary>Ver guía</summary>
```python
# En mock_provider.py, añadir un delay:
import time
class SlowMockProvider:
def complete(self, messages, **kwargs) -> str:
time.sleep(8) # Simular latencia alta
return '{"sentiment": "positive", "score": 0.8, "confidence": 0.9}'
# En dependencies.py (temporalmente):
if settings.use_slow_mock:
return SlowMockProvider()
# Comando para ver los requests lentos:
tail -50 logs/app.json | jq 'select(.duration_ms > 5000) | {duration_ms, request_id}'
Ejercicio 2: Escribir un post-mortem de un incidente simulado
Simula el siguiente escenario: a las 14:30, el error rate subió al 8% durante 12 minutos. La causa fue que alguien cambió la API key en .env a un valor inválido durante un deploy. El fallback se activó correctamente y sirvió respuestas degradadas.
Escribe el post-mortem usando el template de esta cápsula. Incluye: timeline, causa raíz, impacto real, y al menos 2 acciones preventivas.
Ver solución
## Post-Mortem: Error Rate Spike — 2026-03-08
**INCIDENTE**: 2026-03-08 14:30 UTC
**DURACIÓN**: 12 minutos (14:30 - 14:42)
**IMPACTO**: 8% error rate. ~60% de requests sirvieron respuestas degradadas (fallback activo).
Usuarios recibieron sentiment="unknown" en vez del análisis real.
**SEVERIDAD**: Media (servicio degradado, no caído)
### Timeline
- 14:25 — Deploy a producción con nuevo .env
- 14:30 — Alertas de error rate > 3%
- 14:31 — On-call revisa health/deps → OpenAI muestra "status: error, error: AuthenticationError"
- 14:33 — Circuit breaker abre. Fallback provider activo. Error rate baja a 2% (solo los
requests entre el deploy y la apertura del circuit fallaron)
- 14:35 — On-call identifica que la API key en .env es inválida (se copió mal del vault)
- 14:38 — Rollback del .env al valor anterior
- 14:40 — Redeploy con API key correcta
- 14:42 — health/deps muestra OpenAI "status: ok". Circuit breaker cierra. Servicio normal.
### Causa raíz
La API key de OpenAI se copió incorrectamente durante la rotación de secrets.
El valor en .env tenía un carácter extra al final (un newline invisible).
### ¿Qué funcionó bien?
- El circuit breaker abrió en ~2 minutos, limitando el blast radius
- El fallback provider sirvió respuestas degradadas — los usuarios no vieron errores 500
- Los logs mostraron claramente "AuthenticationError" con request_id para correlacionar
### Acciones preventivas
- [x] Añadir test de validación de API key en pre_launch_validation.py:
hacer un request de prueba a OpenAI con la key antes de deployar
- [ ] Automatizar la rotación de secrets desde el vault (en vez de copiar manual)
- [ ] Añadir un check en startup.py que haga un LLM call de prueba y falle
si la key es inválida (fail-fast en vez de descubrirlo en producción)
La clave de un buen post-mortem es que sea honesto, específico, y termine con acciones concretas. No es para culpar a nadie — es para que el sistema mejore.
Ejercicio 3: Añadir un nuevo tipo de incidente al runbook
Tu sistema ahora tiene un nuevo endpoint /api/v1/batch-analyze que procesa hasta 50 textos en un solo request. Escribe la entrada de runbook para el incidente: "Batch endpoint consume demasiados tokens y dispara costos".
Incluye: severidad, síntomas, diagnóstico (con comandos), resolución, y comunicación a usuarios.
Ver solución
## Incidente 7: Batch Endpoint — Consumo Excesivo de Tokens
**Severidad**: Alta (impacto financiero)
**Trigger típico**: Alerta de costo/hora > $X, o batch requests con > 10,000 tokens de input
**Síntomas:**
- Costo por request del batch endpoint > $0.50
- Alertas del budget limiter
- Logs muestran `input_tokens > 10000` para requests al batch endpoint
**Diagnóstico:**
\```bash
# Paso 1: Ver los requests batch más caros
tail -500 logs/app.json | jq '
select(.event == "request_completed" and .endpoint == "/api/v1/batch-analyze")
| {request_id, cost_usd, input_tokens, output_tokens, items_count}
' | jq -s 'sort_by(.cost_usd) | reverse | .[0:5]'
# Paso 2: Ver distribución de tamaños de batch
tail -1000 logs/app.json | jq '
select(.endpoint == "/api/v1/batch-analyze") | .items_count
' | sort -n | uniq -c | sort -rn
# Paso 3: Ver si un cliente específico está abusando
tail -500 logs/app.json | jq '
select(.endpoint == "/api/v1/batch-analyze") | .client_ip
' | sort | uniq -c | sort -rn | head -5
# Paso 4: Costo total del batch endpoint hoy
today=$(date +%Y-%m-%d)
jq --arg d "$today" '
select(.timestamp | startswith($d))
| select(.endpoint == "/api/v1/batch-analyze")
| .cost_usd // 0
' logs/app.json | awk '{s+=$1} END {print "Batch cost today: $" s}'
\```
**Resolución:**
| Causa | Acción |
|-------|--------|
| Textos demasiado largos en el batch | Añadir truncación por item (max 500 chars) |
| Batch size sin límite | Añadir validación: max 20 items por batch |
| Abuse desde un cliente | Rate limit por IP para el batch endpoint |
| Bug que duplica items | Revisar último deploy del batch handler |
**Si el costo sigue subiendo:**
1. Desactivar temporalmente el batch endpoint (retornar 503 "maintenance")
2. Mantener el endpoint individual activo
3. Investigar y corregir antes de reactivar
Al añadir nuevos endpoints, siempre añade la entrada de runbook correspondiente antes del deploy. Es más fácil escribirla cuando el diseño está fresco que después de un incidente a las 3am.
Ejercicio 4: Crear un script de diagnóstico automático
Crea scripts/diagnose.py que ejecute automáticamente los primeros 3 pasos de diagnóstico de cada incidente y genere un reporte. El script debe:
- Verificar health endpoints
- Contar errores en los últimos 100 logs
- Verificar estado del circuit breaker
- Mostrar un resumen con recomendación de qué incidente investigar
Ver solución
# scripts/diagnose.py
"""
Diagnóstico automático del Production AI System.
Ejecuta los checks más comunes y sugiere qué investigar.
Uso:
python scripts/diagnose.py
python scripts/diagnose.py --url http://staging.example.com
"""
import json
import urllib.request
import urllib.error
import sys
import argparse
from pathlib import Path
def check_health(base_url: str) -> dict:
"""Verifica los health endpoints."""
results = {}
for endpoint in ["/health/live", "/health/ready", "/health/deps"]:
try:
resp = urllib.request.urlopen(f"{base_url}{endpoint}", timeout=10)
body = json.loads(resp.read())
results[endpoint] = {"status": resp.status, "body": body}
except urllib.error.HTTPError as e:
results[endpoint] = {"status": e.code, "error": str(e)}
except Exception as e:
results[endpoint] = {"status": 0, "error": str(e)}
return results
def analyze_recent_logs(log_file: str = "logs/app.json", n_lines: int = 100) -> dict:
"""Analiza los logs más recientes."""
log_path = Path(log_file)
if not log_path.exists():
return {"error": f"Log file not found: {log_file}"}
lines = log_path.read_text().strip().split("\n")[-n_lines:]
errors = 0
slow_requests = 0
fallbacks = 0
rate_limits = 0
for line in lines:
try:
entry = json.loads(line)
if entry.get("level") == "error":
errors += 1
if entry.get("duration_ms", 0) > 5000:
slow_requests += 1
if entry.get("event") == "fallback_provider_used":
fallbacks += 1
if "RateLimitError" in str(entry.get("exception_type", "")):
rate_limits += 1
except json.JSONDecodeError:
continue
return {
"total_analyzed": len(lines),
"errors": errors,
"slow_requests": slow_requests,
"fallbacks": fallbacks,
"rate_limits": rate_limits,
}
def suggest_investigation(health: dict, logs: dict) -> list[str]:
"""Basado en los datos, sugiere qué investigar."""
suggestions = []
live = health.get("/health/live", {})
ready = health.get("/health/ready", {})
if live.get("status") != 200:
suggestions.append("🔴 CRÍTICO: /health/live falla → el proceso no está corriendo. Ver Incidente 6 (deploy roto).")
if ready.get("status") != 200:
suggestions.append("🟡 ALERTA: /health/ready falla → startup checks no pasaron. Verificar API key y config.")
if isinstance(logs, dict) and "error" not in logs:
if logs["errors"] > 5:
suggestions.append(f"🟡 {logs['errors']} errores en últimos {logs['total_analyzed']} logs → Ver Incidente 3 (errores 5xx).")
if logs["slow_requests"] > 3:
suggestions.append(f"🟡 {logs['slow_requests']} requests lentos (>5s) → Ver Incidente 1 (alta latencia).")
if logs["fallbacks"] > 2:
suggestions.append(f"🟡 {logs['fallbacks']} activaciones de fallback → El primary provider tiene problemas.")
if logs["rate_limits"] > 0:
suggestions.append(f"🟡 {logs['rate_limits']} rate limit errors → Ver Incidente 5 (rate limit OpenAI).")
if not suggestions:
suggestions.append("✅ No se detectaron problemas obvios. El sistema parece saludable.")
return suggestions
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Diagnóstico automático")
parser.add_argument("--url", default="http://localhost:8000")
parser.add_argument("--logs", default="logs/app.json")
args = parser.parse_args()
print("\n🔍 Ejecutando diagnóstico automático...\n")
print("1. Health checks:")
health = check_health(args.url)
for endpoint, result in health.items():
status = result.get("status", "?")
icon = "✅" if status == 200 else "❌"
print(f" {icon} {endpoint} → {status}")
print("\n2. Análisis de logs recientes:")
logs = analyze_recent_logs(args.logs)
if "error" in logs:
print(f" ⚠️ {logs['error']}")
else:
print(f" Logs analizados: {logs['total_analyzed']}")
print(f" Errores: {logs['errors']}")
print(f" Requests lentos: {logs['slow_requests']}")
print(f" Fallbacks: {logs['fallbacks']}")
print(f" Rate limits: {logs['rate_limits']}")
print("\n3. Recomendaciones:")
suggestions = suggest_investigation(health, logs)
for s in suggestions:
print(f" {s}")
print()
Este script es tu "primera línea de defensa" cuando algo parece mal. En vez de ejecutar 6 comandos de diagnóstico manualmente, ejecutas uno solo y recibes sugerencias de qué incidente investigar en el runbook. Es especialmente útil a las 3am cuando tu capacidad cognitiva está reducida.
Troubleshooting
Problema: Los comandos de jq del runbook fallan con "parse error"
Síntomas:
jq: error (at <stdin>:1): Expected value- Los comandos del runbook no retornan nada o dan error de parsing
Causa más probable: Tu archivo de logs no es JSON Lines válido. Cada línea debe ser un objeto JSON independiente. Si structlog no está configurado para JSON output, los logs pueden ser texto plano.
Solución:
# Verificar que los logs son JSON válido:
head -1 logs/app.json | python -m json.tool
# Si falla, revisar la configuración de structlog:
# En logging_config.py, asegúrate de que uses JSONRenderer:
# structlog.configure(
# processors=[..., structlog.processors.JSONRenderer()]
# )
# Si los logs son texto plano, usa grep en vez de jq:
grep "error" logs/app.json | tail -10
Problema: El circuit breaker nunca abre a pesar de errores continuos
Síntomas:
- Los logs muestran muchos
llm_call_failedseguidos - Pero el circuit breaker sigue en estado "closed"
- El fallback nunca se activa
Causa más probable: El circuit breaker se está instanciando nuevo en cada request (no es singleton). Si se crea uno nuevo cada vez, nunca acumula suficientes failures para abrirse.
Solución:
# En dependencies.py, el circuit breaker debe vivir FUERA de la función:
# ❌ Incorrecto: nuevo circuit breaker en cada request
def build_llm_provider():
cb = CircuitBreaker(failure_threshold=5, reset_timeout=60) # Nuevo cada vez
return CircuitBreakerProvider(OpenAIProvider(...), cb)
# ✅ Correcto: singleton que persiste entre requests
_circuit_breaker = CircuitBreaker(failure_threshold=5, reset_timeout=60)
def build_llm_provider():
return CircuitBreakerProvider(OpenAIProvider(...), _circuit_breaker)
Problema: Los comandos del runbook usan paths que no existen en tu sistema
Síntomas:
tail logs/app.json→ "No such file or directory"curl http://APP_URL/health/deps→ "Connection refused"
Causa más probable: Los paths y URLs en el runbook template son placeholders que necesitas adaptar a tu entorno.
Solución: Antes de usar el runbook en un incidente real, personalízalo:
# 1. Reemplazar APP_URL con tu URL real:
sed -i 's|APP_URL|localhost:8000|g' docs/RUNBOOK.md # desarrollo
sed -i 's|APP_URL|api.myapp.com|g' docs/RUNBOOK.md # producción
# 2. Verificar que el path de logs existe:
ls -la logs/app.json
# Si tus logs están en otro lugar, actualizar el runbook
# 3. Verificar que los health endpoints existen:
curl http://localhost:8000/health/live
curl http://localhost:8000/health/ready
El runbook debe probarse en calma, no durante un incidente. Ejecuta cada comando al menos una vez mientras todo funciona para verificar que los paths y URLs son correctos.
Problema: El post-mortem template no se siente útil para tu equipo
Síntomas:
- El template es demasiado formal o demasiado informal
- Nadie los escribe después de los incidentes
- Los post-mortems no generan acciones concretas
Causa más probable: El template no se adapta a la cultura de tu equipo. Un template largo y formal desmotiva; uno demasiado corto no captura lo importante.
Solución: Adapta el template al tamaño de tu equipo:
# Para equipos de 1-3 personas (solo lo esencial):
**Qué pasó**: [1 oración]
**Cuánto duró**: [X min]
**Por qué**: [causa raíz]
**Qué vamos a hacer**: [1-2 acciones con fecha]
# Para equipos más grandes (añadir):
**Timeline detallado**: minuto a minuto
**Impacto en métricas**: error rate, usuarios afectados
**Comunicación**: qué se comunicó y cuándo
**Revisión del runbook**: ¿el runbook cubrió este caso?
La regla más importante: el post-mortem se escribe en las primeras 24 horas. Después de eso, los detalles se olvidan y pierde valor.
Resumen
- El runbook es para acción, no para comprensión: en un incidente, el objetivo es resolver, no aprender
- Orden importa: los pasos de diagnóstico van de más rápido a más lento, de general a específico
- Comandos listos para copiar: no debería requerir pensar qué comando usar
- Escenarios reales: cubrir los 6 incidentes más comunes para AI apps
- Post-mortem rápido: documentar para no repetir
Recursos adicionales
- Google SRE Book — Incident Management — El estándar de la industria
- PagerDuty Incident Response Guide — Guía práctica
- Postmortem Template (Google) — Cómo hacer post-mortems útiles
- jq Manual — Para los comandos de consulta de logs