Módulo 7: Blameless Postmortems And Runbooks
6. Manos a la obra: el primer runbook real de Andes Cargo
Descripción
La lección 5 estableció qué es un runbook y qué no es. Esta lección construye el primero que Andes Cargo tiene en toda su historia: runbooks/manifest-processor-error-rate.md, el procedimiento exacto para cuando andes-cargo-manifest-error-budget-burn-rate —la alarma real construida en el Módulo 4, lección 5— dispara. Cada comando awslocal está etiquetado con su razón exacta; el árbol de decisión y los pasos de mitigación son el documento real, verificado con la misma disciplina que cada entregable de esta guía.
Conexión con el módulo
Este es el action item #3 de POSTMORTEM.md (lección 3), con dueña asignada (Ana) en la lección 4: "Write runbooks/manifest-processor-error-rate.md, Andes Cargo's first operational runbook [...] Root cause 1 and 2 — no documented, mechanical response path independent of who is on call." Este runbook opera sobre la alarma exacta del Módulo 4, lección 5 (threshold = 0.001, metric math errors / invocations), reutiliza el formato exacto de logs que el Módulo 3, lección 4 ya confirmó para este mismo Lambda, y su rama de escalación (Rama C, Paso 5) invoca INCIDENT-RESPONSE-PLAN.md y el patrón de declaración del Módulo 6, lección 4 sin reescribir ninguno.
Paso 1 — Por qué este runbook reutiliza datos ya confirmados, en vez de inventar un escenario nuevo
Antes del documento completo, una decisión de diseño que vale la pena explicar: el escenario de ejemplo de este runbook —tres invocaciones fallidas de veinte, con tres razones de validación distintas— es exactamente el mismo batch que el Módulo 3, lección 3 construyó, y que el Módulo 4, lección 5, Ejercicio 1 ya confirmó que dispara esta alarma exacta (3/20 = 15%, muy por encima de 0,1%). Esta lección no inventa un incidente sintético nuevo para ilustrar el runbook — reutiliza un dato ya verificado, ya citado, ya trazable hasta una lección anterior. Es la misma disciplina "nunca random" que gobierna toda esta guía, aplicada aquí de una forma específica: si un ejemplo ya existe y ya está verificado, reutilizarlo es más honesto que fabricar uno nuevo solo para que el runbook "se vea distinto".
Paso 2 — El documento completo
En la raíz de andes-cargo-infra/, crea el directorio runbooks/ y, dentro, manifest-processor-error-rate.md:
# manifest-processor-error-rate.md — Andes Cargo Runbook
**Alarm:** `andes-cargo-manifest-error-budget-burn-rate` (`observability.tf`, Module 4, lesson 5)
**Fires when:** `process-shipment-manifest`'s error ratio (`errors / invocations`) crosses `0.001`
(0.1%, `SLO.md`'s allowed error rate) over a 1-hour window.
**Severity if unclassified:** this alarm alone maps to **SEV3** in `INCIDENT-RESPONSE-PLAN.md`'s
severity matrix (Ticket tier, burn rate >= 1.0x and < 6.0x). Step 2 below may reveal a worse
condition — re-classify per the matrix, do not assume the alarm's own tier caps the real severity.
**Who runs this:** whoever is Operations Lead on call (`oncall/schedule.py`, Module 5).
**Related documents:** `SLO.md` (Module 2), `observability.tf` (Module 4),
`INCIDENT-RESPONSE-PLAN.md` (Module 5), `POSTMORTEM.md` (Module 7, lesson 3, action item #3 — this
runbook is that action item, delivered).
## Before you start
- This alarm measures a **ratio**, not a raw count. One error out of five invocations breaches
`threshold = 0.001` exactly as surely as three hundred errors out of a hundred thousand — always
read `Invocations` alongside `Errors` (Step 2) before deciding how urgent this actually is.
- Every `awslocal` command below is labeled **representative** or **literal**. Representative means
reconstructed field by field from real HCL and LocalStack's confirmed API coverage, not executed
against a live container in this repository's authoring environment — no `LOCALSTACK_AUTH_TOKEN`
is exported here, the same limit named since this guide's Module 1. `jq` commands over a saved
log file are literal: the binary ran, in this environment, to produce that output.
## Step 1 — Confirm the alarm actually fired
```bash
awslocal cloudwatch describe-alarms \
--alarm-names andes-cargo-manifest-error-budget-burn-rate \
--query 'MetricAlarms[0].{State:StateValue,Reason:StateReason,Updated:StateUpdatedTimestamp}'
```
**What to expect (representative — reconstructed from the real alarm definition, Module 4, lesson
5; no `LOCALSTACK_AUTH_TOKEN` in this environment):**
```json
{
"State": "ALARM",
"Reason": "Threshold Crossed: 1 datapoint [0.15 (14/08/26 15:00:00)] was greater than or equal to the threshold (0.001).",
"Updated": "2026-08-14T15:05:00.000Z"
}
```
If `State` is not `ALARM` (for example, it already recovered to `OK`, or `INSUFFICIENT_DATA`
because the container just came up), stop here — Step 6 already applies, there is nothing further
to mitigate.
## Step 2 — Pull the raw numbers behind the ratio
```bash
awslocal cloudwatch get-metric-statistics \
--namespace AWS/Lambda --metric-name Invocations \
--dimensions Name=FunctionName,Value=process-shipment-manifest \
--start-time 2026-08-14T14:00:00Z --end-time 2026-08-14T15:00:00Z \
--period 3600 --statistics Sum
awslocal cloudwatch get-metric-statistics \
--namespace AWS/Lambda --metric-name Errors \
--dimensions Name=FunctionName,Value=process-shipment-manifest \
--start-time 2026-08-14T14:00:00Z --end-time 2026-08-14T15:00:00Z \
--period 3600 --statistics Sum
```
**What to expect (representative — same reason as Step 1; the shape matches Module 3, lesson 3's
already-confirmed format for this exact metric pair):**
```
Invocations (Sum): 20.0
Errors (Sum): 3.0
```
`3 / 20 = 0.15` (15%) — well above the `0.001` threshold, consistent with the `ALARM` state from
Step 1. This is the same batch shape Module 4, lesson 5's Exercise 1 already confirmed would
breach this alarm: a small sample with three errors reads as a severe ratio, even though the
absolute count (three) sounds minor on its own — exactly why Step 2 never skips reading
`Invocations`.
## Step 3 — Pull the failing invocations themselves
```bash
awslocal logs filter-log-events \
--log-group-name /aws/lambda/process-shipment-manifest \
--filter-pattern '?"Invalid manifest" ?"Status: error"' \
--start-time 2026-08-14T14:00:00Z --end-time 2026-08-14T15:00:00Z
```
**What to expect (representative — same reason as Steps 1-2; identical in shape to the log events
Module 3, lesson 4 already confirmed for this Lambda). Save this output as
`observability/manifest-log-events.json` before continuing:**
```json
{
"events": [
{ "message": "Invalid manifest [...]/05-shipment-4471-manifest.txt: ['missing required field: weightKg']", "...": "..." },
{ "message": "REPORT RequestId: a47f3e21-8b6a-4c9d-9f12-3d8e7b1a2c44\t[...]Status: error[...]", "...": "..." },
{ "message": "Invalid manifest [...]/12-shipment-4472-manifest.txt: ['missing required field: carrier']", "...": "..." },
{ "message": "REPORT RequestId: f3c91a08-2e4d-4b7f-8a3c-5e9d1f6b8a72\t[...]Status: error[...]", "...": "..." },
{ "message": "Invalid manifest [...]/17-shipment-4473-manifest.txt: ['weightKg must be numeric']", "...": "..." },
{ "message": "REPORT RequestId: c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58\t[...]Status: error[...]", "...": "..." }
]
}
```
Now group the failure reasons — this is the step that decides which branch of Step 4 applies, and
`jq` runs for real from here on, against the file you just saved:
```bash
jq -r '.events[] | select(.message | contains("Invalid manifest")) | .message
| capture(": .(?<reason>[^]]+).") | .reason' observability/manifest-log-events.json \
| sort | uniq -c | sort -rn
```
**What to expect (literal — `jq` ran in this environment against the reconstructed file above):**
```
1 'weightKg must be numeric'
1 'missing required field: weightKg'
1 'missing required field: carrier'
```
## Step 4 — The decision tree
```text
MANIFEST-PROCESSOR-ERROR-RATE -- DECISION TREE
Step 3's grouped reasons
│
┌─────────┼──────────────────────────────┐
│ │
▼ ▼
ONE reason dominates Reasons are scattered,
(3+ failures, same no single reason has more
validation rule, e.g. than 1-2 occurrences
all "missing required (the case in this run:
field: X") three DIFFERENT reasons)
│ │
▼ ▼
BRANCH A Check Step 2's Invocations
Likely a recent deploy against the normal baseline
changed the manifest for this hour of day
schema, or a data │
producer upstream ┌─────┴─────┐
started sending a │ │
malformed field ▼ ▼
│ Invocations Invocations
▼ near normal far below
Go to Step 5, Branch A BRANCH B normal
Background BRANCH C
noise from Possible
malformed infra-level
uploads -- failure --
normal, no Go to Step 5,
code change Branch C
needed
```
The run in Step 3 lands in **Branch B**: three failures, three different validation reasons, no
single rule repeating — the signature of ordinary malformed-manifest noise (the same kind of fixed,
deliberate malformation Module 3, lesson 3 used to build its own batch), not a systemic regression
in the validation code itself. A dominant, repeated reason across most or all failures is the
signal that points to Branch A instead.
## Step 5 — Mitigation by branch
**Branch A — deploy-caused regression.** Confirm the timing: does the failure window in Step 1's
`Reason` line start shortly after the last deploy of `process-shipment-manifest` or of whatever
system produces manifests upstream? If yes, revert that deploy through the normal pipeline path
(`cicd-and-gitops-on-aws-guide`'s CI/CD flow, not a manual `awslocal` edit against the running
Lambda) and re-run Step 1-2 once the revert completes to confirm the ratio drops back under
`0.001`.
**Branch B — background noise (this run's case).** No code change. Log this occurrence in the
team's weekly reliability review as a normal data-quality event, not an incident. If this pattern
repeats with increasing frequency week over week, that trend — not any single occurrence — is
itself worth raising as a new `POSTMORTEM.md`-style investigation, but a single Ticket-tier alarm
firing on isolated malformed uploads does not, by itself, warrant escalation.
**Branch C — possible infrastructure failure.** This is no longer a runbook-scale problem — declare
an incident following `INCIDENT-RESPONSE-PLAN.md`: assign Incident Commander, Operations Lead, and
Communications Lead per the on-call rotation; write the declaration message following the pattern
Module 6, lesson 4 already built (severity, roles, first known state, relative timestamps from a
new `T+0`); start a new `TIMELINE.md` for this incident. This runbook's job ends at the point where
"follow these steps" stops being sufficient and "coordinate a response" begins.
## Step 6 — Verify resolution
```bash
awslocal cloudwatch describe-alarms \
--alarm-names andes-cargo-manifest-error-budget-burn-rate \
--query 'MetricAlarms[0].StateValue'
```
**What to expect (representative, same reason as Step 1):**
```
"OK"
```
If the state is still `ALARM` after the mitigation in Step 5, do not repeat the same branch a
second time on the assumption it "just needs more time" — re-run Step 2-3 to confirm the branch
classification was correct in the first place. A wrong branch, repeated, wastes exactly the kind of
time this runbook exists to save.
## Step 7 — After the alarm clears
If Branch A or B applied, no formal postmortem is required — log the resolution and move on. If
Branch C required declaring an incident, that incident's own postmortem (following the same
structure `POSTMORTEM.md`, Module 7, lesson 3 already used) is the next document, not this runbook.
## Known limitations
| Step | Status | Why |
|---|---|---|
| 1, 2, 3, 6 (`awslocal cloudwatch`/`logs`) | Representative | No `LOCALSTACK_AUTH_TOKEN` exported in this repository's authoring environment — the same limit named since Module 1. Both `cloudwatch` and `logs` are confirmed on LocalStack's Hobby plan (Module 3, Source #8); output is reconstructed field by field from that confirmed behavior, never invented. |
| 3 (`jq` commands) | Literal | `jq` does not depend on LocalStack — it ran, in this environment, against the saved log file. |
| Decision tree (Step 4) | Structural, not statistical | The three branches are the reasonable classification for the failure signatures this Lambda can produce (a fixed set of `validate_manifest()` rules, Module 3, lesson 3) — not a machine-learned or dynamically-tuned classifier. A new failure mode not covered by these three branches would need this runbook itself updated, per the "flexible and adaptable" property Module 7, lesson 5 already named.
This runbook is Andes Cargo's first — the earlier absence of any runbook at all is exactly what
`POSTMORTEM.md`'s root cause #1 and #2 (Module 7, lesson 3) named as part of why the Claude Code
incident had no documented, mechanical response path to fall back on.
Paso 3 — Verificando el documento
wc -l manifest-processor-error-rate.md
grep -c '^## ' manifest-processor-error-rate.md
Qué esperar (literal):
220
9
Doscientas veinte líneas, nueve secciones (Before you start, Step 1 a Step 7, Known limitations) — un documento del tamaño correcto para un runbook: lo suficientemente completo para no dejar ninguna decisión sin cubrir, lo suficientemente acotado para leerse de principio a fin en el tiempo que toma una alarma en dispararse.
Paso 4 — Corriendo jq de verdad, con una pregunta distinta a la del Módulo 3
Fíjate en algo deliberado del Paso 3 (dentro del documento): el comando jq de este runbook no repite exactamente el que el Módulo 3, lección 4 ya corrió sobre el mismo archivo —ese extraía los tres requestId y las tres razones por separado—. Este runbook hace una pregunta operativa distinta, la que de verdad importa para decidir la Rama del árbol: ¿las razones se repiten, o están dispersas? sort | uniq -c | sort -rn agrupa y cuenta, y el resultado —tres razones, cada una con exactamente una ocurrencia— es la evidencia directa de que ninguna domina, la condición exacta que la Rama B del Paso 4 necesita para justificarse. Reutilizar el mismo archivo de datos con una pregunta nueva, en vez de repetir la pregunta anterior, es la razón por la que este runbook, aunque construido sobre el mismo dato del Módulo 3, no es una copia de esa lección — extrae una conclusión operativa distinta que esa lección nunca necesitó sacar.
Errores comunes
Escribir el runbook con la severidad "obvia" en la cabeza, sin verificar contra la matriz real de INCIDENT-RESPONSE-PLAN.md (de asumir en vez de citar). Qué pasa: alguien, al ver que una alarma de errores disparó, asume automáticamente que "esto es grave" sin confirmar en qué nivel de la matriz cae. Cómo detectarlo: si tu versión del runbook no menciona explícitamente qué severidad corresponde a esta alarma específica. Cómo corregirlo: el encabezado del documento del Paso 2 lo deja explícito — esta alarma, por sí sola, mapea a SEV3 (Ticket), la severidad más baja de la matriz con página automática; solo si el Paso 2 revela un patrón peor (por ejemplo, Invocations cercano a cero, indicando que casi nada se está procesando) la severidad real podría ser más alta, y el runbook lo advierte con precisión en vez de asumir.
Seguir la Rama A (revertir un despliegue) sin confirmar primero, con el Paso 4, que las razones realmente se agrupan en un solo patrón dominante (de saltar directo a la mitigación más "activa"). Qué pasa: alguien, bajo presión, ve una alarma disparada y asume que la respuesta correcta siempre es "revertir el último cambio", sin correr primero el diagnóstico del Paso 3. Cómo detectarlo: si tu respuesta a una alarma disparada empieza con una acción de mitigación, no con un diagnóstico. Cómo corregirlo: el árbol de decisión del Paso 4 existe exactamente para prevenir esto — este runbook clasifica antes de actuar, y el ejemplo trabajado de esta lección termina en la Rama B (ruido normal, sin acción de código), no en la Rama A, precisamente porque el diagnóstico reveló razones dispersas, no un patrón dominante. Actuar sin diagnosticar primero puede revertir un despliegue completamente inocente, sin resolver el problema real.
Tratar la etiqueta "representativo" de los comandos awslocal como una razón para no confiar en el runbook completo (repetido, en el contexto específico de un documento operativo, del error ya nombrado en el Módulo 3). Qué pasa: alguien, al ver que los Pasos 1, 2, 3 y 6 están marcados representativos, concluye que el runbook entero es menos confiable que uno "completamente real". Cómo detectarlo: si tu evaluación del runbook trata la sección "Known limitations" como una debilidad del documento en vez de información operativa honesta. Cómo corregirlo: la tabla "Known limitations" del Paso 2 distingue con precisión qué es representativo (los comandos awslocal, reconstruidos campo por campo a partir de comportamiento ya confirmado) de qué es literal (los comandos jq, que sí corrieron) — la estructura del árbol de decisión, la lógica de las tres ramas, y los pasos de mitigación son el documento real, verificado con la misma disciplina que cualquier otro artefacto de esta guía; lo representativo es exclusivamente la salida específica de comandos que dependen de un contenedor que este entorno de escritura no puede levantar.
Ejercicios
Ejercicio 1 — Corre tú mismo el comando jq del Paso 3, sobre tu propia copia de observability/manifest-log-events.json, y confirma que obtienes exactamente el mismo resultado agrupado.
Ver solución
Copiando el JSON exactamente como aparece en el Paso 2 y corriendo jq -r '.events[] | select(.message | contains("Invalid manifest")) | .message | capture(": .(?<reason>[^]]+).") | .reason' observability/manifest-log-events.json | sort | uniq -c | sort -rn, el resultado es el mismo: tres líneas, cada una con conteo 1, para 'weightKg must be numeric', 'missing required field: weightKg' y 'missing required field: carrier' (el orden exacto entre las tres puede variar según cómo sort desempate conteos iguales, pero los tres conteos de 1 no cambian). Esta verificación confirma que el diagnóstico de la Rama B —razones dispersas, ninguna dominante— es reproducible por cualquier persona que siga el runbook, no una afirmación sin respaldo.
Ejercicio 2 — Modifica, en prosa (sin ejecutar nada), el archivo observability/manifest-log-events.json del Paso 2 para que el mismo comando jq del Paso 3 arroje un resultado que clasifique en la Rama A del árbol de decisión, en vez de la Rama B.
Ver solución
Habría que cambiar el contenido de los tres mensajes "Invalid manifest" para que los tres compartan exactamente la misma razón de validación —por ejemplo, las tres líneas terminando en : ['missing required field: weightKg'], en vez de las tres razones distintas actuales—. Con ese cambio, el comando jq del Paso 3 agruparía las tres ocurrencias en una sola fila: 3 'missing required field: weightKg', un patrón claramente dominante que el árbol de decisión del Paso 4 clasificaría en la Rama A —sugiriendo que un despliegue reciente probablemente eliminó ese campo de la generación de manifiestos en algún punto anterior en la cadena, en vez de que se trate de ruido disperso de manifiestos malformados individuales—.
Ejercicio 3 — Explica por qué el runbook del Paso 2 incluye una fila explícita para "¿qué pasa si State ya no es ALARM?" al final del Paso 1, en vez de asumir que quien sigue el runbook siempre lo hace mientras la alarma está activa.
Ver solución
Porque una alarma puede recuperarse por sí sola —un pico de tráfico transitorio, o un lote de manifiestos malformados que ya terminó de procesarse— entre el momento en que alguien recibe la notificación y el momento en que efectivamente abre el runbook para seguirlo, especialmente si la notificación llegó minutos u horas antes de que la persona de guardia pudiera atenderla. Sin esa verificación explícita al final del Paso 1, alguien podría ejecutar los Pasos 2 a 5 completos —incluyendo, en el peor caso, revertir un despliegue en la Rama A— sobre una condición que ya no existe, generando trabajo y riesgo innecesarios. La misma disciplina que la lección 5 de este módulo ya nombró como propiedad de un buen runbook —"claro y simple"— incluye anticipar el caso más común de falso positivo, no solo el camino feliz donde la alarma sigue exactamente como la notificación la describió.
Resumen y siguiente paso
Esta lección construyó runbooks/manifest-processor-error-rate.md: el primer runbook operativo de Andes Cargo, con siete pasos numerados, un árbol de decisión de tres ramas, y una tabla de límites conocidos que distingue con precisión qué es representativo (los comandos awslocal, sin LOCALSTACK_AUTH_TOKEN) de qué es literal (los comandos jq, que corrieron de verdad sobre datos ya confirmados en el Módulo 3). Verificaste el documento con el mismo patrón determinista de siempre —220 líneas, 9 secciones—, y viste, con evidencia ejecutada, cómo el diagnóstico del Paso 3 clasifica el escenario de ejemplo en la Rama B (ruido normal), no en la Rama A (regresión de despliegue).
Antes de avanzar deberías poder: recitar las siete secciones del runbook en orden; explicar cómo el resultado de jq decide entre las tres ramas del árbol de decisión; y defender por qué las partes representativas del runbook no lo vuelven menos confiable como documento operativo.
La lección 7 cierra el círculo del action item #4 de POSTMORTEM.md: el intento honesto de backup y restauración de Shipments contra LocalStack, con el mismo estándar de verdad que este runbook ya aplicó.
Recursos
- Este mismo repositorio, Módulo 4, lección 5 (
05-hands-on-a-real-cloudwatch-alarm-on-the-lambda.md) — la alarma real (andes-cargo-manifest-error-budget-burn-rate) que este runbook opera. - Este mismo repositorio, Módulo 3, lecciones 3 y 4 — el origen del dataset y del formato de logs reutilizados en el Paso 3 de este documento.
- Este mismo repositorio, Módulo 5, lección 8 (
08-project-andes-cargos-incident-response-plan.md) —INCIDENT-RESPONSE-PLAN.md, la matriz de severidad y los roles que la Rama C de este runbook invoca. - Este mismo repositorio, Módulo 7, lección 5 (
05-what-a-runbook-is-and-is-not.md) — las propiedades de un buen runbook, aplicadas aquí. - jqlang/jq — Manual — referencia de
capture,selecty las expresiones usadas en el Paso 3.