Módulo 8: Capstone The Andes Cargo Reliability Package
4. Recorrido end-to-end: la alerta dispara, el incidente se declara
Descripción
Esta es la lección donde la máquina completa se pone a prueba de verdad. Los dos números de la lección 3 —burn rate de 400,00x en la ventana corta, 25,00x en la larga, sobre el escenario synthetic_incident— entran a los tres motores del Módulo 4 sin que ninguno de los tres cambie una sola línea de su propia lógica. Si los tres coinciden en que esto dispara, y ninguno se equivoca con normal (que sigue sin disparar), la máquina generaliza. Con esa confirmación, esta lección declara el incidente formalmente, con severidad clasificada por la matriz real y roles asignados de una semana de la rotación que ningún módulo anterior de esta guía usó todavía.
Conexión con el módulo
Esta lección extiende observability/burn_rate_exporter.py (Módulo 4, lección 4) con un tercer valor de scenario, sin tocar observability/alert_rules.yml ni observability.tf — la prueba central de esta lección es, precisamente, que ninguno de los dos necesita cambiar. La declaración del incidente sigue el patrón exacto que el Módulo 6, lección 4 ya estableció para el incidente Claude Code, aplicado aquí, por primera vez, a un caso donde la severidad SEV1 se justifica por el criterio de burn rate de la matriz —no por el criterio independiente de pérdida de datos que el incidente Claude Code necesitó, porque ahí no había ningún burn rate medible—.
Paso 1 — Extendiendo el exportador con un tercer escenario, sin tocar nada más
# burn_rate_exporter.py (Module 4, lesson 4) -- extended with a third scenario.
# ONLY new lines below: two new .labels(...).set(...) calls, copy-pasted literally
# from Module 8, lesson 3's own computed output. No change to the Gauge
# definition, no change to the HTTP server, no change to any other line.
import time
from prometheus_client import Gauge, start_http_server
manifest_burn_rate = Gauge(
"manifest_burn_rate",
"Burn rate of process-shipment-manifest's error budget, per scenario and window",
["scenario", "window"],
)
# From scripts/burn_rate_evaluator.py (M4.3), literal output:
manifest_burn_rate.labels(scenario="bad_week", window="short").set(17.54)
manifest_burn_rate.labels(scenario="bad_week", window="long").set(43.41)
manifest_burn_rate.labels(scenario="normal", window="short").set(0.00)
manifest_burn_rate.labels(scenario="normal", window="long").set(0.90)
# NEW in this lesson -- from Module 8, lesson 3's own computed output, Step 4:
manifest_burn_rate.labels(scenario="synthetic_incident", window="short").set(400.00)
manifest_burn_rate.labels(scenario="synthetic_incident", window="long").set(25.00)
if __name__ == "__main__":
start_http_server(8001)
print("burn_rate_exporter listening on :8001/metrics")
print("bad_week: short=17.54x long=43.41x | normal: short=0.00x long=0.90x")
print("synthetic_incident: short=400.00x long=25.00x")
while True:
time.sleep(3600)
Dos líneas nuevas, nada más. observability/alert_rules.yml no aparece en este paso porque no cambia — sus tres reglas ya seleccionan por window, nunca por un valor fijo de scenario, así que cualquier etiqueta nueva de scenario que el exportador exponga entra automáticamente al mismo cálculo, sin que nadie tenga que anticiparla.
# Detener el exportador anterior (Ctrl+C en su terminal) y arrancar la version extendida
python3 observability/burn_rate_exporter.py &
Qué esperar (literal):
burn_rate_exporter listening on :8001/metrics
bad_week: short=17.54x long=43.41x | normal: short=0.00x long=0.90x
synthetic_incident: short=400.00x long=25.00x
Prometheus, con scrape_interval: 15s ya configurado desde el Módulo 4, lección 4, recoge el nuevo escenario en su siguiente ciclo de scraping — sin reiniciar ningún contenedor, sin editar prometheus.yml.
Paso 2 — Confirmando el scraping, con las seis series ahora visibles
curl -s 'http://localhost:9090/api/v1/query?query=manifest_burn_rate'
Qué esperar (literal — seis series ahora, dos por cada uno de los tres escenarios; el timestamp Unix de cada value es tu valor variable, las etiquetas y los números son literales):
{
"status": "success",
"data": {
"resultType": "vector",
"result": [
{"metric": {"scenario": "bad_week", "window": "short"}, "value": [1789084804.113, "17.54"]},
{"metric": {"scenario": "bad_week", "window": "long"}, "value": [1789084804.113, "43.41"]},
{"metric": {"scenario": "normal", "window": "short"}, "value": [1789084804.113, "0"]},
{"metric": {"scenario": "normal", "window": "long"}, "value": [1789084804.113, "0.9"]},
{"metric": {"scenario": "synthetic_incident", "window": "short"}, "value": [1789084804.113, "400"]},
{"metric": {"scenario": "synthetic_incident", "window": "long"}, "value": [1789084804.113, "25"]}
]
}
}
Paso 3 — Los tres motores, corridos otra vez, sin ningún cambio de código
Motor 1 — scripts/burn_rate_evaluator.py (Módulo 4, lección 3), con una tercera llamada agregada a su bloque __main__:
# Agregado al final del bloque if __name__ == "__main__": del Modulo 4, leccion 3.
# Ningun cambio a evaluate(), evaluate_scenario(), ni a TIERS.
evaluate_scenario("synthetic incident (M8.3)", short_burn_rate=400.00, long_burn_rate=25.00)
python3 scripts/burn_rate_evaluator.py
Qué esperar (literal — las dos primeras secciones, sin cambios, ya las viste en el Módulo 4; la tercera es nueva):
--- mala semana (M2.7) (short=17.54x, long=43.41x) ---
Page (fast) >= 14.4x (1h/5m): DISPARA
Page (slow) >= 6.0x (6h/30m): DISPARA
Ticket >= 1.0x (3d/6h): DISPARA
--- normal (M2.4) (short=0.00x, long=0.90x) ---
Page (fast) >= 14.4x (1h/5m): no dispara
Page (slow) >= 6.0x (6h/30m): no dispara
Ticket >= 1.0x (3d/6h): no dispara
--- synthetic incident (M8.3) (short=400.00x, long=25.00x) ---
Page (fast) >= 14.4x (1h/5m): DISPARA
Page (slow) >= 6.0x (6h/30m): DISPARA
Ticket >= 1.0x (3d/6h): DISPARA
Motor 2 — Prometheus + Alertmanager, con alert_rules.yml sin ninguna línea modificada:
curl -s http://localhost:9090/api/v1/rules
Qué esperar (literal, tras el primer ciclo de evaluación tras el nuevo scraping — ~1 minuto):
ManifestErrorBudgetBurnRatePageFast state=firing alerts=[('bad_week', 'firing'), ('synthetic_incident', 'firing')]
ManifestErrorBudgetBurnRatePageSlow state=firing alerts=[('bad_week', 'firing'), ('synthetic_incident', 'firing')]
ManifestErrorBudgetBurnRateTicket state=firing alerts=[('bad_week', 'firing'), ('synthetic_incident', 'firing')]
curl -s http://localhost:9093/api/v2/alerts
Qué esperar (literal — seis alertas activas, dos escenarios, tres severidades cada uno):
ManifestErrorBudgetBurnRatePageFast bad_week page active
ManifestErrorBudgetBurnRatePageFast synthetic_incident page active
ManifestErrorBudgetBurnRatePageSlow bad_week page active
ManifestErrorBudgetBurnRatePageSlow synthetic_incident page active
ManifestErrorBudgetBurnRateTicket bad_week ticket active
ManifestErrorBudgetBurnRateTicket synthetic_incident ticket active
cat observability/webhook.log
Qué esperar (literal — el registro completo del receptor, ahora con synthetic_incident presente):
alert_webhook_receiver listening on :9099/alerts
[alert_webhook_receiver] status=firing alertname=ManifestErrorBudgetBurnRatePageFast scenario=bad_week severity=page
[alert_webhook_receiver] status=firing alertname=ManifestErrorBudgetBurnRateTicket scenario=bad_week severity=ticket
[alert_webhook_receiver] status=firing alertname=ManifestErrorBudgetBurnRatePageSlow scenario=bad_week severity=page
[alert_webhook_receiver] status=firing alertname=ManifestErrorBudgetBurnRatePageFast scenario=synthetic_incident severity=page
[alert_webhook_receiver] status=firing alertname=ManifestErrorBudgetBurnRateTicket scenario=synthetic_incident severity=ticket
[alert_webhook_receiver] status=firing alertname=ManifestErrorBudgetBurnRatePageSlow scenario=synthetic_incident severity=page
En ningún punto de este paso apareció scenario=normal — ni en /api/v1/rules, ni en /api/v2/alerts, ni en el log del receptor. La regla and ignoring(window), escrita en el Módulo 4 antes de que synthetic_incident existiera, la evalúa correctamente de todas formas.
Motor 3 — CloudWatch Alarm, sin ningún cambio a observability.tf:
awslocal cloudwatch describe-alarms \
--alarm-names andes-cargo-manifest-error-budget-burn-rate \
--query 'MetricAlarms[0].{State:StateValue,Reason:StateReason}'
Qué esperar (representativo — misma razón declarada desde el Módulo 3: sin LOCALSTACK_AUTH_TOKEN, el contenedor de LocalStack no arranca en este entorno de escritura; reconstruido con el error_ratio = 0.025 de la lección 3, Paso 4, contra el threshold = 0.001 real de observability.tf):
{
"State": "ALARM",
"Reason": "Threshold Crossed: 1 datapoint [0.025 (17/03/26 15:00:00)] was greater than or equal to the threshold (0.001)."
}
TRES MOTORES, EL MISMO ESCENARIO NUEVO, EL MISMO VEREDICTO
Python (M4.3, extendido) Alertmanager (M4.4, CloudWatch (M4.5,
sin cambios) sin cambios)
────────────────────── ─────────────────── ─────────────────
synthetic_incident synthetic_incident error_ratio 2.5%
-> DISPARA x3 -> firing x3, entregada -> ALARM
al webhook
normal -> sigue sin disparar en NINGUNO de los tres, exactamente igual que en M4.4/M4.5
Paso 4 — Clasificando la severidad: el otro camino hacia SEV1
INCIDENT-RESPONSE-PLAN.md define SEV1 con dos criterios, unidos por "O": Page (fast), ≥ 14,4x — O pérdida de datos irreversible, sin importar el burn rate medido. El incidente Claude Code (Módulo 6) fue SEV1 por el segundo criterio — no había ningún burn rate medible, porque la infraestructura completa que lo hubiera generado ya no existía—. Este incidente sintético es el primer caso de toda la guía que es SEV1 por el primer criterio, el que la matriz mide directamente:
burn_rate (ventana corta) = 400.00x >= 14.4x (umbral Page fast) -> SEV1
Sin ambigüedad, sin necesitar el criterio de excepción — la matriz clasifica este caso con su mecanismo principal, el que ALERTING-POLICY.md construyó desde el Módulo 4 para ser el camino normal hacia una severidad, no la excepción.
Paso 5 — Los roles: una semana distinta de la rotación, sin discrepancia de calendario
El Módulo 6 tuvo que resolver una discrepancia real: el incidente Claude Code ocurrió el 26 de febrero de 2026, seis días antes de que la rotación formal de INCIDENT-RESPONSE-PLAN.md empezara (2 de marzo). Este incidente sintético no tiene ese problema — su fecha (17 de marzo de 2026, un martes) cae, genuinamente, dentro de la Semana 3 de la rotación ya generada por oncall/schedule.py:
Week Starts Primary Secondary
3 2026-03-16 Carla Diego <- semana usada para este incidente
Con la misma regla dura de INCIDENT-RESPONSE-PLAN.md para un SEV1/SEV2 (IC y OL nunca la misma persona) y el mismo patrón de dos personas que el Módulo 6 ya aplicó:
| Rol | Persona | Por qué |
|---|---|---|
| Incident Commander (IC) | Carla | Titular de guardia esta semana; coordina, no ejecuta cambios técnicos |
| Communications Lead (CL) | Carla (doble rol con IC) | Ambas son funciones de coordinación, no de modificar el sistema |
| Operations Lead (OL) | Diego | Respaldo de guardia esta semana; el único que ejecuta la mitigación |
Paso 6 — El mensaje de declaración
# INCIDENT DECLARED — SEV1
**Time:** T+~3min (relative to the alert crossing `Page (fast)` threshold — see Step 3 above)
**Declared by:** Carla (Incident Commander)
## Status
`process-shipment-manifest`'s error ratio crossed 40% in a short burst (10 of 25 recent
invocations failing with `missing required field: carrier`), and 2.5% sustained over the full
hour. All three alerting engines (Python evaluator, Alertmanager, CloudWatch) confirm: burn
rate 400.00x (short window) / 25.00x (long window), both above every tier in
`ALERTING-POLICY.md`'s Table 5-8.
## Severity
**SEV1** — burn rate 400.00x >= 14.4x, `INCIDENT-RESPONSE-PLAN.md`'s primary severity criterion
(not the independent data-loss exception the Claude Code incident required — this is the first
incident in this guide classified through the matrix's main mechanism).
## Roles
- **Incident Commander:** Carla — coordinates the response, owns this document, does not run
mitigation commands directly.
- **Operations Lead:** Diego — the only person taking mitigation actions during this incident.
- **Communications Lead:** Carla (dual role with IC, per `INCIDENT-RESPONSE-PLAN.md`'s
two-person guidance for a SEV1/SEV2).
## What we know right now (first known state)
- The failure pattern is concentrated, not scattered: all 10 failed invocations share the exact
same validation error (`missing required field: carrier`), starting abruptly at a specific
point in the batch — consistent with a recent change upstream, not isolated bad data.
- The alarm and all three alerting engines agree; no engine shows a conflicting result.
- Next update: within 15 minutes, or as soon as Module 8, lesson 5's runbook execution
identifies the specific branch of the decision tree that applies.
## What this declaration does not claim
Root cause is not analyzed here — Module 8, lesson 5 runs `runbooks/manifest-processor-error-
rate.md`'s decision tree against this exact data to classify the branch, then mitigates and
closes with a second postmortem. This declaration exists only to make the incident official,
assign roles, and record the first known state.
Errores comunes
Asumir que, porque los tres motores ya se probaron una vez en el Módulo 4, no hace falta confirmar de nuevo que disparan sobre el escenario nuevo (de dar por sentada la generalización sin verificarla). Qué pasa: alguien, al llegar a esta lección, asume que synthetic_incident va a disparar "porque los umbrales son los mismos de siempre", sin ejecutar realmente los comandos del Paso 3. Cómo detectarlo: si tu evidencia de esta lección es una afirmación en prosa en vez de la salida literal de /api/v1/rules, /api/v2/alerts, y describe-alarms. Cómo corregirlo: el valor de este módulo está, precisamente, en la verificación —correr los comandos de verdad, sobre datos que ningún motor vio antes, y confirmar que el resultado coincide con lo esperado—; una predicción sin ejecutar no es la misma evidencia que una salida real, aunque el resultado termine siendo el mismo.
Clasificar este incidente como SEV1 "por el mismo criterio que el incidente Claude Code" (de perder la distinción del Paso 4 entre los dos caminos hacia SEV1). Qué pasa: alguien, al escribir la sección "Severity" de la declaración, cita "pérdida de datos irreversible" como la razón, copiando el patrón de la declaración del Módulo 6 sin verificar cuál criterio aplica aquí. Cómo detectarlo: si tu declaración de este incidente menciona pérdida de datos en algún lugar. Cómo corregirlo: este incidente no perdió ningún dato — diez manifiestos fueron rechazados por validación, ninguna infraestructura se destruyó. La severidad SEV1 aquí viene, sin ambigüedad, del primer criterio de la matriz (Page (fast), ≥14,4x), medido directamente. Confundir los dos caminos hacia SEV1 sería perder exactamente la distinción que el Paso 4 de esta lección existe para señalar.
Fusionar Incident Commander con Operations Lead otra vez, "porque ya se hizo así en el Módulo 6 y funcionó" (de tratar una excepción de dos personas como si fuera la regla general). Qué pasa: alguien, al asignar roles para este incidente, copia mecánicamente el patrón de Ana/Bruno del Módulo 6 sin volver a verificar la regla dura contra INCIDENT-RESPONSE-PLAN.md. Cómo detectarlo: si tu asignación de roles no distingue entre "combinar IC y CL" (permitido) y "combinar IC y OL" (prohibido en SEV1/SEV2). Cómo corregirlo: el patrón de dos personas del Paso 5 sigue siendo válido aquí, pero por la misma razón exacta que en el Módulo 6 —ambos roles combinados (IC+CL) son de coordinación, nunca de ejecución—, no porque "ya funcionó antes". Con un equipo de cuatro personas, cualquier semana de la rotación tiene exactamente esta misma restricción: dos personas de guardia, tres roles, IC y OL siempre distintas.
Ejercicios
Ejercicio 1 — Un compañero propone agregar el escenario synthetic_incident directamente a alert_rules.yml, con una cuarta regla dedicada solo a ese escenario, "para que quede más explícito". ¿Por qué esa propuesta contradice el punto central de esta lección?
Ver solución
El punto central de esta lección es que las tres reglas ya existentes —escritas antes de que synthetic_incident existiera— disparan correctamente sobre ese escenario sin ningún cambio, porque seleccionan por la etiqueta window, nunca por un valor fijo de scenario. Agregar una cuarta regla dedicada rompería exactamente esa propiedad: convertiría un sistema que generaliza automáticamente a cualquier escenario nuevo en uno que necesita una regla nueva cada vez que aparece un caso distinto — el mismo problema de mantenimiento que un umbral estático (Módulo 4, lección 1) tiene frente a burn rate, ahora aplicado a la estructura de las reglas mismas en vez de al umbral.
Ejercicio 2 — Calcula, sin mirar el Paso 6, si Carla y Diego podrían haber sido asignados exactamente al revés (Diego como IC/CL, Carla como OL), usando solo la información de la tabla de rotación del Paso 5.
Ver solución
Técnicamente sí sería posible sin romper la regla dura —lo único que INCIDENT-RESPONSE-PLAN.md exige es que IC y OL sean personas distintas, no que el titular de guardia (Primary) sea necesariamente el IC—. Pero el patrón que esta lección sigue, igual que el Módulo 6 con Ana/Bruno, asigna el rol de mayor coordinación (IC) al titular (Primary, Carla) y el rol de ejecución (OL) al respaldo (Secondary, Diego) — una convención razonable, no una regla obligatoria: el titular de guardia es, por definición del rol de guardia, quien primero ve la alerta y quien tiene el contexto más fresco para coordinar, mientras que el respaldo está disponible para ejecutar sin haber sido el primer punto de contacto. Cualquier equipo real podría documentar la convención contraria si le sirviera mejor, siempre que la regla dura (IC ≠ OL) se mantenga.
Ejercicio 3 — Explica por qué el mensaje de declaración del Paso 6, en su sección "What this declaration does not claim", remite a la lección 5 en vez de intentar clasificar la Rama del árbol de decisión del runbook en esta misma lección.
Ver solución
Clasificar la Rama (A, B o C) del árbol de decisión del runbook requiere el diagnóstico completo de los Pasos 3 y 4 de runbooks/manifest-processor-error-rate.md —agrupar las razones de fallo con jq y confirmar si una domina o están dispersas—, un trabajo que esta lección todavía no hizo. Declarar una Rama sin haber corrido ese diagnóstico sería exactamente el error que el Módulo 7, lección 6 ya advirtió: "seguir la Rama A [...] sin confirmar primero [...] que las razones realmente se agrupan en un solo patrón dominante". La declaración de esta lección, con toda intención, se limita a lo que se sabe en el primer momento —el burn rate, la severidad, los roles—, y remite explícitamente el diagnóstico de causa a la lección donde de verdad se hace, con evidencia.
Resumen y siguiente paso
Esta lección corrió la máquina completa de alerta contra el incidente sintético de la lección 3, sin cambiar ninguna lógica ya construida: extendiste burn_rate_exporter.py con dos líneas nuevas, y los tres motores —el evaluador Python, Prometheus/Alertmanager, y la alarma de CloudWatch— dispararon correctamente sobre synthetic_incident, sin ningún falso positivo sobre normal. Clasificaste la severidad como SEV1 por el criterio principal de la matriz (burn rate ≥14,4x) —la primera vez en esta guía que ese camino, no la excepción de pérdida de datos, decide la severidad— y declaraste el incidente formalmente, con Carla como Incident Commander/Communications Lead y Diego como Operations Lead, la Semana 3 de la rotación real, sin ninguna discrepancia de calendario que resolver.
Antes de avanzar deberías poder: explicar por qué extender el exportador no requirió ningún cambio a alert_rules.yml ni a observability.tf; distinguir los dos caminos hacia SEV1 de la matriz de severidad; y defender por qué la declaración de esta lección no incluye todavía ninguna clasificación de Rama del runbook.
La lección 5 corre el runbook real, paso a paso, contra este incidente: diagnostica qué Rama del árbol de decisión aplica, mitiga, verifica que la alarma vuelve a OK, y cierra con un segundo postmortem sin culpa —más corto, pero con la misma disciplina completa.
Recursos
- Este mismo repositorio, Módulo 4, lecciones 3, 4 y 5 — los tres motores que esta lección corre, sin modificar, contra un escenario nuevo.
- Este mismo repositorio, Módulo 5, lección 5 (
05-hands-on-andes-cargos-severity-matrix.md) — la matriz de severidad y sus dos criterios para SEV1, aplicados aquí por primera vez con el criterio principal. - Este mismo repositorio, Módulo 5, lección 7 y Módulo 5, lección 8 —
oncall/schedule.pyyINCIDENT-RESPONSE-PLAN.md, la fuente de la Semana 3 y la regla de dos personas. - Este mismo repositorio, Módulo 6, lección 4 (
04-hands-on-declaring-the-incident.md) — el patrón exacto de mensaje de declaración que el Paso 6 de esta lección reutiliza. - Google SRE Workbook — Alerting on SLOs — la fuente de la Tabla 5-8 que gobierna cada umbral de esta lección.