Módulo 5: Security Scanning y Rollback Inteligente

Diagnóstico Inteligente Post-Rollback

Diagnóstico Inteligente Post-Rollback

Descripción

Esta es la cápsula que cierra el ciclo de resiliencia: cuando el rollback automático se ejecuta (cápsula 04), Claude Code genera un diagnóstico detallado de lo que pasó. Sin diagnóstico, el rollback solo evita el daño inmediato — pero no genera aprendizaje. Con diagnóstico, cada incident se convierte en información estructurada que el equipo puede usar para evitar la próxima.

Aprendes a generar diagnósticos automáticos que: identifican el commit/PR que introdujo el problema, correlacionan métricas con cambios de código, sugieren fix probable, producen un postmortem draft, y notifican al equipo con contexto rico — no solo "rollback executed".

Al terminar, vas a tener un sistema donde un incident en producción se convierte automáticamente en un postmortem draft listo para revisión, con root cause analysis, timeline reconstruido, y plan de acción.


El Diagnóstico que Importa

SIN DIAGNÓSTICO (rollback básico):

🚨 Rollback ejecutado
- Métrica: error_rate
- Valor: 5.2%
- Threshold: 2%

→ El equipo sabe QUE algo se rompió
→ Empieza a investigar desde cero
→ Pierde 30-60 minutos identificando root cause

CON DIAGNÓSTICO (este módulo):

🚨 Rollback ejecutado + Análisis

ROOT CAUSE PROBABLE:
El commit a3b5c8d (PR #847 "Update payment validation")
modificó la función validate_amount() en payment_service.py:42.
El cambio invierte la condición original — antes rechazaba
montos negativos, ahora los permite.

EVIDENCIA:
- Error rate subió 2 minutos después del deploy
- Errors concentrados en el endpoint /api/payments
- Stack traces apuntan a payment_service.validate_amount()
- Diff del commit muestra: `if amount < 0` cambió a `if amount > 0`

FIX SUGERIDO:
Revertir el cambio de la línea 42 a la condición original
`if amount < 0`. Re-aplicar lógica deseada después del deploy
con tests que cubran el caso de monto negativo.

POSTMORTEM DRAFT:
[link al draft generado]

→ El equipo tiene el contexto completo en 5 minutos
→ Decisión informada sobre qué hacer next
→ Postmortem draft listo para review

La diferencia es ~45 minutos de tiempo del equipo por incident. Multiplicalo por 5-10 incidents/año = mucho tiempo recuperado.


Las 5 Piezas del Diagnóstico

1. CAUSE IDENTIFICATION
   ¿Qué commit/PR introdujo el problema?
   - Time correlation: deploy time vs error onset
   - Diff analysis: qué cambió en ese PR
   - Author + reviewer

2. EVIDENCE TRAIL
   ¿Qué señales muestran el problema?
   - Métricas que cruzaron threshold (con valores)
   - Endpoints/paths afectados
   - Stack traces relevantes
   - Logs de error patterns

3. ROOT CAUSE ANALYSIS
   ¿Por qué falla el código?
   - Lógica errónea en el cambio
   - Side effect no anticipado
   - Falta de validación
   - Race condition introducida

4. FIX SUGGESTION
   ¿Cómo arreglarlo?
   - Revertir el cambio (corto plazo, ya hecho)
   - Re-aplicar con corrección (mediano plazo)
   - Mejora estructural para prevenir clase de bug (largo plazo)

5. POSTMORTEM DRAFT
   Documento listo para review humano
   - Timeline reconstruido
   - Impact assessment
   - Action items propuestos

Cada pieza es valiosa por separado. Juntas convierten "rollback" en "incident learning".


El Workflow Extendido

# Continuación del workflow de la cápsula 04
# Adicional al rollback, agrega diagnóstico

diagnose-and-document:
  needs: monitor-and-rollback
  if: needs.monitor-and-rollback.outputs.rollback_needed == '1'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with: { fetch-depth: 0 }
    
    - uses: actions/setup-python@v5
      with: { python-version: '3.11' }
    
    - run: pip install anthropic requests
    
    - name: Generate diagnosis
      env:
        ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        GITHUB_REPOSITORY: ${{ github.repository }}
        METRICS_API_URL: ${{ secrets.METRICS_API_URL }}
        METRICS_API_TOKEN: ${{ secrets.METRICS_API_TOKEN }}
        DEPLOY_SHA: ${{ needs.deploy.outputs.new_release }}
        PREVIOUS_SHA: ${{ needs.deploy.outputs.previous_release }}
      run: python scripts/diagnose_incident.py
    
    - name: Create postmortem issue
      if: always()
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        GITHUB_REPOSITORY: ${{ github.repository }}
      run: python scripts/create_postmortem_issue.py
    
    - name: Upload diagnosis
      uses: actions/upload-artifact@v4
      with:
        name: incident-diagnosis
        path: |
          diagnosis.json
          postmortem_draft.md

El Script de Diagnóstico

"""scripts/diagnose_incident.py

Genera diagnóstico completo del incident con context de:
- Commits del deploy
- Métricas durante la degradación
- Logs de error (si accesibles)
"""
import json
import os
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from anthropic import Anthropic
import requests


def get_deploy_commits(deploy_sha: str, previous_sha: str) -> list[dict]:
    """Obtener commits incluidos en el deploy."""
    result = subprocess.run(
        ["git", "log", f"{previous_sha}..{deploy_sha}",
         "--format=%H||%an||%s", "--no-merges"],
        capture_output=True, text=True, check=True,
    )
    
    commits = []
    for line in result.stdout.strip().split("\n"):
        if not line:
            continue
        parts = line.split("||")
        if len(parts) >= 3:
            commits.append({
                "sha": parts[0],
                "author": parts[1],
                "message": parts[2],
            })
    return commits


def get_commit_diff(sha: str) -> str:
    """Obtener diff completo de un commit (truncado)."""
    result = subprocess.run(
        ["git", "show", "--stat", "--format=", sha],
        capture_output=True, text=True, check=True,
    )
    stat = result.stdout
    
    # Diff completo (truncado)
    diff_result = subprocess.run(
        ["git", "show", "--format=", sha],
        capture_output=True, text=True, check=True,
    )
    diff = diff_result.stdout[:5000]  # truncar para no saturar context
    
    return f"STATS:\n{stat}\n\nDIFF (truncated):\n{diff}"


def get_pr_for_commit(sha: str, repo: str, token: str) -> dict | None:
    """Encontrar PR asociado al commit."""
    headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
    url = f"https://api.github.com/repos/{repo}/commits/{sha}/pulls"
    r = requests.get(url, headers=headers)
    if r.ok and r.json():
        pr = r.json()[0]
        return {
            "number": pr["number"],
            "title": pr["title"],
            "url": pr["html_url"],
            "body": pr.get("body", "")[:1000],
        }
    return None


def get_metrics_during_incident(
    deploy_time: str,
    rollback_time: str,
    api_url: str | None,
    api_token: str | None,
) -> dict:
    """Obtener métricas durante el periodo del incident."""
    if not api_url:
        # Mock para demo
        return {
            "error_rate_max": 5.2,
            "error_rate_baseline": 0.5,
            "latency_p95_max_ms": 2500,
            "latency_p95_baseline_ms": 800,
            "affected_endpoints": ["/api/payments", "/api/orders"],
            "error_samples": [
                "ValueError: Invalid amount",
                "ValidationError: amount must be positive",
            ],
        }
    
    # En producción: query a Prometheus/Datadog
    # ...
    return {}


def generate_diagnosis_with_claude(
    commits: list[dict],
    pr_info: dict | None,
    metrics: dict,
    rollback_reason: dict,
) -> dict:
    """Llamar a Claude para análisis estructurado."""
    
    # Construir contexto rico
    commits_summary = "\n".join(
        f"- {c['sha'][:8]} ({c['author']}): {c['message']}"
        for c in commits
    )
    
    prompt = f"""Analiza este incident de producción y genera un diagnóstico estructurado.

CONTEXTO DEL INCIDENT:
- Rollback ejecutado por: {rollback_reason.get('description', 'unknown')}
- Métrica disparadora: {rollback_reason.get('metric')}
- Valor: {rollback_reason.get('current_value')}
- Threshold: {rollback_reason.get('threshold')}

COMMITS DEPLOYADOS:
{commits_summary}

{f'PR ASOCIADO: #{pr_info["number"]} — {pr_info["title"]}' if pr_info else 'No hay PR asociado claro'}
{f'Description: {pr_info["body"][:500]}' if pr_info else ''}

DIFF DEL COMMIT MÁS RELEVANTE:
{get_commit_diff(commits[0]['sha']) if commits else 'No commits'}

MÉTRICAS DURANTE INCIDENT:
{json.dumps(metrics, indent=2)}

Genera un diagnóstico JSON con esta estructura:

{{
  "root_cause": {{
    "likely_commit": "sha del commit responsable",
    "explanation": "explicación clara de por qué este cambio causó el problema",
    "confidence": "high|medium|low"
  }},
  "evidence": [
    "evidencia 1 (correlación temporal, stack trace, etc.)",
    "evidencia 2",
    "..."
  ],
  "fix_suggestion": {{
    "short_term": "qué hacer ahora (típicamente: revisar el rollback)",
    "medium_term": "cómo re-aplicar el cambio sin el bug",
    "long_term": "mejora estructural para prevenir esta clase de bug"
  }},
  "lessons_learned": [
    "lección 1 (qué falló en el proceso)",
    "lección 2",
    "..."
  ],
  "action_items": [
    {{"item": "...", "priority": "high|medium|low", "owner": "team|individual"}}
  ]
}}

Sé específico — usa referencias a archivos, líneas, valores reales.
Devuelve SOLO el JSON.
"""
    
    client = Anthropic()
    response = client.messages.create(
        model="claude-sonnet-5",  # Sonnet para razonamiento profundo
        max_tokens=4000,
        messages=[{"role": "user", "content": prompt}],
    )
    
    text = response.content[0].text.strip()
    if text.startswith("```"):
        text = "\n".join(text.split("\n")[1:-1])
    
    return json.loads(text)


def generate_postmortem_draft(diagnosis: dict, commits: list, pr_info: dict | None,
                              rollback_reason: dict, metrics: dict) -> str:
    """Generar postmortem draft en markdown."""
    
    incident_id = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
    rc = diagnosis["root_cause"]
    fix = diagnosis["fix_suggestion"]
    
    md = f"""# Incident Postmortem: {incident_id}

**Status:** Draft (auto-generated)  
**Severity:** Auto-rolled back

## Summary

Auto-rollback ejecutado en producción debido a degradación de **{rollback_reason.get('metric')}** ({rollback_reason.get('current_value')} vs threshold {rollback_reason.get('threshold')}).

Likely root cause: commit `{rc['likely_commit'][:8]}`{f' (PR #{pr_info["number"]})' if pr_info else ''}

## Timeline

- **T+0**: Deploy a producción ({commits[0]['sha'][:8] if commits else 'unknown'})
- **T+~2min**: Métricas empiezan a degradar
- **T+~5min**: Threshold cruzado, rollback automático ejecutado
- **T+~7min**: Servicio estabilizado en versión anterior

## Impact

- Affected endpoints: {', '.join(metrics.get('affected_endpoints', []))}
- Error rate peak: {metrics.get('error_rate_max', 'unknown')}% (baseline: {metrics.get('error_rate_baseline', 'unknown')}%)
- Latency p95 peak: {metrics.get('latency_p95_max_ms', 'unknown')}ms (baseline: {metrics.get('latency_p95_baseline_ms', 'unknown')}ms)
- Estimated user impact: [TBD by on-call]

## Root Cause

**{rc['explanation']}**

Confidence: {rc['confidence']}

### Evidence

"""
    for ev in diagnosis["evidence"]:
        md += f"- {ev}\n"
    
    md += f"""
## Resolution

### Short-term (already done)
{fix['short_term']}

### Medium-term
{fix['medium_term']}

### Long-term
{fix['long_term']}

## Lessons Learned

"""
    for lesson in diagnosis["lessons_learned"]:
        md += f"- {lesson}\n"
    
    md += "\n## Action Items\n\n| Priority | Action | Owner |\n|----------|--------|-------|\n"
    for ai in diagnosis["action_items"]:
        md += f"| {ai['priority']} | {ai['item']} | {ai['owner']} |\n"
    
    md += f"""
## Commits Deployed

"""
    for c in commits:
        md += f"- `{c['sha'][:8]}` ({c['author']}): {c['message']}\n"
    
    md += f"""
---
*Auto-generated postmortem. Review and update with human insights before publishing.*
*Generated by Claude Code at {datetime.utcnow().isoformat()}*
"""
    return md


def main() -> int:
    deploy_sha = os.environ["DEPLOY_SHA"]
    previous_sha = os.environ["PREVIOUS_SHA"]
    repo = os.environ["GITHUB_REPOSITORY"]
    token = os.environ["GITHUB_TOKEN"]
    
    # 1. Cargar rollback reason (del script de monitoring)
    rollback_reason = json.loads(Path("rollback_reason.json").read_text())
    
    # 2. Obtener commits del deploy
    commits = get_deploy_commits(deploy_sha, previous_sha)
    print(f"Commits in deploy: {len(commits)}")
    
    # 3. PR info del commit más reciente
    pr_info = get_pr_for_commit(commits[0]["sha"], repo, token) if commits else None
    
    # 4. Métricas durante incident
    metrics = get_metrics_during_incident(
        deploy_time=rollback_reason.get("timestamp"),
        rollback_time=datetime.utcnow().isoformat(),
        api_url=os.environ.get("METRICS_API_URL"),
        api_token=os.environ.get("METRICS_API_TOKEN"),
    )
    
    # 5. Diagnóstico con Claude
    print("Generando diagnóstico con Claude...")
    diagnosis = generate_diagnosis_with_claude(commits, pr_info, metrics, rollback_reason)
    
    # 6. Postmortem draft
    postmortem = generate_postmortem_draft(diagnosis, commits, pr_info, rollback_reason, metrics)
    
    # Save
    Path("diagnosis.json").write_text(json.dumps(diagnosis, indent=2))
    Path("postmortem_draft.md").write_text(postmortem)
    
    print(f"\nRoot cause: {diagnosis['root_cause']['explanation']}")
    print(f"Confidence: {diagnosis['root_cause']['confidence']}")
    print(f"Fix sugerido: {diagnosis['fix_suggestion']['medium_term']}")
    
    return 0


if __name__ == "__main__":
    sys.exit(main())

Crear Issue de Postmortem

"""scripts/create_postmortem_issue.py

Crea un GitHub Issue con el postmortem draft.
"""
import json
import os
import sys
from pathlib import Path
import requests


def main() -> int:
    repo = os.environ["GITHUB_REPOSITORY"]
    token = os.environ["GITHUB_TOKEN"]
    
    postmortem_file = Path("postmortem_draft.md")
    diagnosis_file = Path("diagnosis.json")
    
    if not postmortem_file.exists():
        print("ERROR: postmortem_draft.md no encontrado", file=sys.stderr)
        return 1
    
    body = postmortem_file.read_text()
    
    # Diagnóstico para construir título
    diagnosis = json.loads(diagnosis_file.read_text()) if diagnosis_file.exists() else {}
    rc = diagnosis.get("root_cause", {})
    likely_commit = rc.get("likely_commit", "unknown")[:8]
    
    title = f"Incident postmortem: rollback automático de {likely_commit}"
    
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/vnd.github+json",
    }
    
    payload = {
        "title": title,
        "body": body,
        "labels": ["incident", "postmortem", "auto-generated"],
    }
    
    url = f"https://api.github.com/repos/{repo}/issues"
    r = requests.post(url, headers=headers, json=payload)
    
    if r.status_code == 201:
        issue = r.json()
        print(f"Issue creado: {issue['html_url']}")
        return 0
    
    print(f"ERROR creando issue: {r.status_code} {r.text}", file=sys.stderr)
    return 1


if __name__ == "__main__":
    sys.exit(main())

Notificación con Diagnóstico

Agregas al script de notificación de la cápsula 04 el contexto del diagnóstico:

# Update a scripts/notify_rollback.py
def main() -> int:
    reason = json.loads(Path("rollback_reason.json").read_text())
    
    # Cargar diagnóstico si está disponible
    diagnosis = None
    if Path("diagnosis.json").exists():
        diagnosis = json.loads(Path("diagnosis.json").read_text())
    
    # Generar mensaje con/sin diagnosis
    if diagnosis:
        rc = diagnosis["root_cause"]
        message = f"""🚨 **Auto-rollback executed** (with diagnosis)

**Trigger:** {reason['description']}

**Root cause (likely):** {rc['explanation'][:200]}...
**Confidence:** {rc['confidence']}

**Fix sugerido (medium-term):** {diagnosis['fix_suggestion']['medium_term']}

📋 Postmortem draft creado como GitHub Issue.
On-call: revisar incidente y validar diagnóstico.
"""
    else:
        message = generate_basic_notification(reason)
    
    # ... resto del send a Slack

Calibrar la Confianza del Diagnóstico

Claude no siempre acierta el root cause. La estructura del diagnóstico debe incluir nivel de confianza y el equipo debe tratar el output como draft, no verdad absoluta.

HIGH CONFIDENCE (~70% de los casos):
- Un solo commit en el deploy
- PR description menciona el área que falló
- Diff del commit muestra cambio claramente relacionado

MEDIUM CONFIDENCE (~25% de los casos):
- Múltiples commits, varios podrían ser causa
- Métricas correlacionadas pero no concluyentes
- Cambio podría ser causa secundaria

LOW CONFIDENCE (~5% de los casos):
- Cambios indirectos (ej. update de dependency)
- Bug que estaba latente y se activa por load
- Race condition o timing-specific bug

Importante: el equipo nunca debe confiar 100% en el diagnóstico automático. Es un starting point, no un veredicto. La validación humana es parte del proceso de postmortem.


Trampas Comunes

Error 1: Tratar el diagnóstico como verdad final

Síntoma: Equipo aplica el "fix sugerido" sin validar y rompe algo más.

Por qué pasa: Confianza ciega en el output del modelo.

Cómo corregir: Siempre flagear diagnosis como "draft" o "automated suggestion". Validación humana antes de actuar.

Error 2: Diagnóstico sin contexto suficiente

Síntoma: El diagnóstico es genérico ("verificar el último commit") porque el script no pasó info útil al modelo.

Por qué pasa: Pasar solo "rollback executed" sin diff, métricas, PR info.

Cómo corregir: Pasar contexto rico: commits, diffs, métricas, PR descriptions, stack traces si accesibles.

Error 3: Postmortem sin action items concretos

Síntoma: El postmortem dice "review process" — no es accionable.

Por qué pasa: Prompt no enfatiza specificity en action items.

Cómo corregir: Pedir explícitamente: "action items concretos con owner y prioridad".

Error 4: Crear issue automático sin review

Síntoma: Issues con info incorrecta llenan el repo, equipo deja de leerlos.

Por qué pasa: Trust ciego en el auto-generation.

Cómo corregir: Issues marcados claramente como "auto-generated, requires review". Asignar a alguien específico para validar antes de circular.

Error 5: No incluir info de previous incidents similares

Síntoma: Cada incident se trata como nuevo, no aprovechamos patrones de incidents previos.

Por qué pasa: El sistema no busca incidents similares en el historial.

Cómo corregir: Antes del análisis, query GitHub Issues con label incident para encontrar similares. Pasarlos como contexto al modelo.


Diagnóstico

Pregunta 1: ¿Tu sistema genera diagnóstico automático del rollback o solo notifica?

Solo notificación = equipo invierte 30+ min identificando root cause. Diagnóstico = equipo arranca con contexto.

Pregunta 2: ¿El diagnóstico incluye nivel de confianza?

Sin confidence, el equipo puede aplicar fixes incorrectos confiando ciegamente. Con confidence, sabe cuándo validar más.

Pregunta 3: ¿Generas un postmortem draft o solo un mensaje a Slack?

Slack se pierde. Issue de GitHub = persistente, accionable, trackeable.

Pregunta 4: ¿Tu diagnóstico tiene action items concretos?

Action items vagos = no se hacen. Concretos con owner = se ejecutan.

Pregunta 5: ¿El equipo entiende que el diagnóstico es draft, no verdad final?

Si confían 100%, los falsos positivos del modelo causan más daño. Treat as starting point.


Ejercicios

Ejercicio 1: Generación básica de diagnóstico (Medio)

Implementa diagnose_incident.py que:

  1. Carga rollback_reason.json
  2. Lista commits del deploy
  3. Llama a Claude con contexto básico
  4. Genera diagnosis.json estructurado

Verifica con un rollback simulado.

Ejercicio 2: Postmortem draft (Medio)

Agrega generación de postmortem markdown desde el diagnosis JSON. El postmortem debe tener:

  • Summary
  • Timeline
  • Root cause + evidence
  • Resolution (short/medium/long term)
  • Action items con priorities

Ejercicio 3: Issue automático con review workflow (Difícil)

Implementa:

  1. Creación automática del Issue con postmortem draft
  2. Asignación a un on-call según schedule (cron o config)
  3. Label requires-human-review
  4. Auto-comment después de 24h si nadie validó: "Este issue necesita review"

Resumen

  • Diagnóstico transforma rollback en aprendizaje — el equipo arranca con contexto, no desde cero
  • 5 piezas del diagnóstico: cause, evidence, root cause analysis, fix, postmortem
  • Confidence levels comunican incertidumbre del modelo al equipo
  • Postmortem draft es starting point, no veredicto — siempre review humano
  • Action items concretos con owner = se ejecutan; vagos = se ignoran
  • Issue persistente > mensaje Slack efímero
  • Patrones de incidents previos mejoran el diagnóstico futuro

Próxima cápsula: Módulo 6 — Proyecto Integrador: Pipeline CI/CD Completo. Combinas todo lo aprendido en módulos 1-5 en un pipeline end-to-end production-ready. Es el cierre de la guía y el portfolio piece más grande.


Recursos Adicionales

  1. Site Reliability Engineering: Postmortem Culture — Capítulo del libro de Google sobre cultura de postmortems
  2. Etsy: Code as Craft - Blameless PostMortems — Filosofía de postmortems sin blame
  3. Atlassian: Incident Management — Framework de incident management
  4. PagerDuty: Postmortem Template — Template estándar de postmortem
  5. Honeycomb: Observability for Incidents — Cómo observability acelera diagnóstico
  6. Claude Code root cause analysis use cases — Patterns aplicables