Módulo 8: Capstone The Andes Cargo Reliability Package
3. Manos a la obra: introduciendo un incidente sintético determinista
Descripción
Esta lección construye el único dato genuinamente nuevo de todo el módulo: observability/upload_synthetic_incident_batch.py, una secuencia fija de 25 manifiestos subidos al bucket real —el mismo trigger real de S3 que el Módulo 3 ya estableció, nunca lambda invoke—, con 10 malformados a propósito, todos por la misma razón: carrier omitido. Nunca random. La lección 4 va a tomar los dos números que este batch produce —el conteo agregado y el burn rate de dos ventanas— y correrlos, sin ningún cambio de código, contra los tres motores de alerta del Módulo 4.
Conexión con el módulo
Este batch es deliberadamente distinto del batch del Módulo 3, lección 3 en dos formas concretas, ninguna accidental: el tamaño (25, no 20), y sobre todo la forma del fallo. El Módulo 3 dispersó tres razones distintas en tres posiciones aisladas (5, 12, 17) — el patrón de "ruido de fondo" que el runbook del Módulo 7 clasificó como Rama B (sin acción de código necesaria). Este batch concentra diez fallos consecutivos, todos con la misma razón exacta, en las últimas diez posiciones — la firma de un cambio de código que rompió algo, la Rama A del mismo árbol de decisión, la que el Módulo 7 nunca llegó a demostrar con un ejemplo trabajado. La lección 5 de este módulo va a confirmar esa clasificación corriendo el runbook real.
Paso 1 — Por qué la posición del fallo, no solo su cantidad, cuenta la historia
Antes del código, la decisión de diseño. Un batch con diez manifiestos malformados dispersos entre veinticinco, en posiciones aleatorias, se leería igual que el batch del Módulo 3, solo que con más ruido — la misma Rama B, un poco más grande. Este batch no hace eso: las primeras quince posiciones son manifiestos completos y válidos, cíclicos entre los tres envíos ya conocidos (4471, 4472, 4473); a partir de la posición 16, cada uno de los diez manifiestos restantes tiene exactamente el mismo campo faltante, carrier, sin ninguna excepción. Esa concentración —limpio, limpio, limpio... y de golpe, diez fallos idénticos seguidos— es la firma real de un despliegue que cambió algo en el sistema que genera manifiestos, en algún punto entre la posición 15 y la 16, y no de manifiestos individuales que, cada uno por su cuenta, llegaron mal formados.
DOS FORMAS DE FALLAR -- LA MISMA CANTIDAD, DOS HISTORIAS DISTINTAS
MODULO 3, LECCION 3 (20 manifiestos) ESTE BATCH (25 manifiestos)
──────────────────────────────── ─────────────────────────
Posiciones 5, 12, 17: rotas Posiciones 16-25: rotas
Cada una por una razon DISTINTA Las 10 por la MISMA razon
Dispersas entre manifiestos buenos Concentradas al final, en bloque
│ │
▼ ▼
Firma: ruido de manifiestos Firma: un cambio de codigo que
individuales malformados rompio algo, en un punto exacto
(Runbook M7.6, Rama B) (Runbook M7.6, Rama A)
Paso 2 — El script completo
Crea observability/upload_synthetic_incident_batch.py en andes-cargo-infra/:
# upload_synthetic_incident_batch.py
# A FIXED, deterministic sequence of 25 shipment manifests uploaded to
# andes-cargo-shipment-docs, through the real S3 trigger path (never a manual
# `lambda invoke`) -- the same discipline Module 3, lesson 3 established.
#
# Different from Module 3, lesson 3's batch on purpose: positions 1-15 are
# well-formed (shipments 4471/4472/4473, cycling); positions 16-25 (10 of 25)
# are ALL missing the same field, carrier -- a concentrated, single-reason
# failure signature, not scattered noise. No random, no datetime.now().
import subprocess
from pathlib import Path
BUCKET = "andes-cargo-shipment-docs"
KEY_PREFIX = "manifests/year=2026/month=03/incident-drill"
OUT_DIR = Path("manifests-synthetic-incident")
SHIPMENTS = {
"4471": {"originCountry": "Peru", "destinationCountry": "Chile", "carrier": "AndesExpress", "weightKg": "120"},
"4472": {"originCountry": "Colombia", "destinationCountry": "Ecuador", "carrier": "AndesExpress", "weightKg": "85"},
"4473": {"originCountry": "Chile", "destinationCountry": "Peru", "carrier": "RutaSur", "weightKg": "200"},
}
GOOD_CYCLE = ["4471", "4472", "4473"]
# Positions 1-15: well-formed. Positions 16-25: ALL missing "carrier" -- the
# concentrated failure signature this lesson needs (contrast with Module 3,
# lesson 3's three scattered, single-position failures).
BREAK_STARTS_AT = 16
TOTAL = 25
def manifest_lines(shipment_id, skip_field=None):
fields = {"shipmentId": shipment_id, **SHIPMENTS[shipment_id]}
if skip_field:
fields.pop(skip_field, None)
return "\n".join(f"{k}={v}" for k, v in fields.items()) + "\n"
def build_batch():
batch = []
for i in range(1, TOTAL + 1):
shipment_id = GOOD_CYCLE[(i - 1) % 3]
if i >= BREAK_STARTS_AT:
text = manifest_lines(shipment_id, skip_field="carrier")
batch.append((i, "bad", shipment_id, text))
else:
text = manifest_lines(shipment_id)
batch.append((i, "good", shipment_id, text))
return batch
def upload_one(index, outcome, shipment_id, text):
OUT_DIR.mkdir(parents=True, exist_ok=True)
filename = f"{index:02d}-shipment-{shipment_id}-manifest.txt"
filepath = OUT_DIR / filename
filepath.write_text(text)
key = f"{KEY_PREFIX}/{filename}"
subprocess.run(
["awslocal", "s3api", "put-object", "--bucket", BUCKET, "--key", key, "--body", str(filepath)],
check=True,
)
return key
if __name__ == "__main__":
for index, outcome, shipment_id, text in build_batch():
key = upload_one(index, outcome, shipment_id, text)
print(f"[{index:02d}/25] {outcome:>4} shipment={shipment_id} -> s3://{BUCKET}/{key}")
La única diferencia estructural con upload_manifest_batch.py (Módulo 3) está en build_batch(): en vez de un diccionario MALFORMED con posiciones dispersas y razones distintas, una sola constante (BREAK_STARTS_AT = 16) decide, con una comparación simple (i >= BREAK_STARTS_AT), a partir de qué posición todo se rompe de la misma forma. Es menos código, no más — la simplicidad del script refleja la simplicidad de la historia que cuenta: un solo cambio, en un solo punto, con el mismo efecto repetido diez veces.
Paso 3 — Corriendo la subida
python3 observability/upload_synthetic_incident_batch.py
Qué esperar (representativo — mismo patrón exacto que el Módulo 3, lección 3 ya confirmó para s3api put-object disparando process-shipment-manifest de forma asíncrona; recorte de las posiciones donde cambia el patrón):
[01/25] good shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/01-shipment-4471-manifest.txt
[02/25] good shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/02-shipment-4472-manifest.txt
...
[15/25] good shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/15-shipment-4473-manifest.txt
[16/25] bad shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/16-shipment-4471-manifest.txt
[17/25] bad shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/17-shipment-4472-manifest.txt
[18/25] bad shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/18-shipment-4473-manifest.txt
[19/25] bad shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/19-shipment-4471-manifest.txt
[20/25] bad shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/20-shipment-4472-manifest.txt
[21/25] bad shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/21-shipment-4473-manifest.txt
[22/25] bad shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/22-shipment-4471-manifest.txt
[23/25] bad shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/23-shipment-4472-manifest.txt
[24/25] bad shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/24-shipment-4473-manifest.txt
[25/25] bad shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=03/incident-drill/25-shipment-4471-manifest.txt
Quince líneas good, diez líneas bad en bloque continuo — exactamente el patrón visual que el Paso 1 anticipó.
Paso 4 — Dos ventanas, dos lecturas del mismo batch
Este es el paso central de la lección. El batch, por sí solo, ocurre en un intervalo corto —los últimos minutos de una hora que, hasta ese momento, había tenido tráfico completamente sano—. Igual que BAD_WEEK en el Módulo 2 necesitó una ventana corta (el último día) y una larga (la semana completa) para revelar algo que el saldo final por sí solo no mostraba, este incidente necesita las mismas dos ventanas:
Ventana corta — el batch en sí (los últimos minutos, awslocal cloudwatch get-metric-statistics con --period 300):
Invocations (Sum): 25.0
Errors (Sum): 10.0
Ventana larga — la hora completa (--period 3600; incluye 375 invocaciones sanas de tráfico normal ya registradas antes de que este batch se subiera, sumadas a las 25 de este batch):
Invocations (Sum): 400.0
Errors (Sum): 10.0
Qué esperar (representativo — misma razón declarada desde el Módulo 3: sin LOCALSTACK_AUTH_TOKEN en este entorno de escritura, el contenedor de LocalStack no arranca; las 25 invocaciones del batch son la aritmética directa del Paso 3, las 375 de tráfico normal previo son tráfico ya en curso esa hora, no re-subido en esta lección):
--- Sintetico M8.3 -- ventana corta (el batch, ~5 min) ---
error_rate = 10 / 25 = 0.4000 (40.00%)
burn_rate = 0.4000 / 0.001 = 400.00x
--- Sintetico M8.3 -- ventana larga (la hora completa) ---
error_rate = 10 / 400 = 0.0250 (2.50%)
burn_rate = 0.0250 / 0.001 = 25.00x
0.001 es la misma tasa_de_error_permitida de SLO.md que gobierna cada cálculo de burn rate de esta guía desde el Módulo 2. Ambos números —400,00x en la ventana corta, 25,00x en la larga— están, sin ambigüedad, muy por encima del umbral más urgente de la Tabla 5-8 (Page (fast), 14,4x). Este batch no es un caso límite diseñado para rozar un umbral — es, deliberadamente, un caso claro, para que la lección 4 pueda concentrarse en si la máquina reacciona correctamente, no en si el caso mismo es ambiguo.
Paso 5 — Por qué el número de la ventana larga (25,00x) no es una coincidencia con el de CloudWatch
Fíjate en algo que la lección 4 va a usar directamente: error_rate = 10 / 400 = 2.5% en la ventana larga es exactamente la misma proporción que aws_cloudwatch_metric_alarm.manifest_error_budget_burn_rate (Módulo 4, lección 5) va a evaluar con su propio metric_query de errors / invocations sobre el mismo período de una hora — esa alarma, declarada en observability.tf, opera con una sola ventana, exactamente la ventana larga de este cálculo. El número de la ventana corta (400,00x), en cambio, solo es visible para los motores que sí implementan dos ventanas —scripts/burn_rate_evaluator.py y Prometheus/Alertmanager—, la misma limitación real de CloudWatch que el Módulo 4, lección 5 ya declaró con honestidad. Esta coincidencia no es casualidad: los tres motores calculan la misma matemática sobre los mismos datos, con cobertura de ventanas distinta — exactamente el mismo patrón que ALERTING-POLICY.md ya probó con bad_week y normal.
Errores comunes
Diseñar el batch de esta lección con posiciones dispersas, "para que se vea distinto" del Módulo 3, sin darse cuenta de que eso repite la misma firma de fallo (de cambiar el número sin cambiar la historia). Qué pasa: alguien, al construir su propia versión de este batch, elige diez posiciones al azar (aunque fijas) en vez de un bloque continuo, pensando que basta con que el tamaño sea distinto. Cómo detectarlo: si tu versión del batch, corrida por el runbook del Módulo 7 en la lección 5, cae en la misma Rama B que el ejemplo original, en vez de la Rama A que esta lección busca demostrar. Cómo corregirlo: el Paso 1 de esta lección es explícito — lo que hace a este batch genuinamente distinto no es el tamaño (25 en vez de 20), es la concentración del fallo en un bloque continuo, con una sola razón repetida. Un batch disperso, sin importar cuántas posiciones tenga, sigue siendo una variación del mismo caso del Módulo 3.
Calcular el burn rate de la ventana larga usando solo las 25 invocaciones del batch, ignorando las 375 de tráfico normal ya registradas esa hora (de olvidar que la ventana larga no es "solo lo nuevo"). Qué pasa: alguien, al leer el Paso 4, usa 10 / 25 también para la fila "ventana larga", en vez de 10 / 400. Cómo detectarlo: si tus dos filas del Paso 4 muestran el mismo número. Cómo corregirlo: la ventana corta y la ventana larga miden períodos de tiempo distintos por definición —la corta es el batch reciente; la larga es la hora completa, que incluye tráfico sano anterior al batch—. Si ambas ventanas dieran el mismo número, no habría ninguna razón para tener dos ventanas — la distinción completa de esta lección, y del patrón multi-ventana del Módulo 4 en general, depende de que sean genuinamente distintas.
Asumir que un burn rate de 400x en la ventana corta significa que el incidente es "cuatrocientas veces peor" que uno de 14,4x (de leer el multiplicador sin la escala que lo acompaña). Qué pasa: alguien compara 400,00x directamente contra 14,4x y concluye que este incidente es proporcionalmente mucho más grave que cualquier caso límite de la Tabla 5-8. Cómo detectarlo: si tu descripción del incidente usa una comparación de magnitud ("28 veces peor que el umbral de Page") sin conectarla con lo que eso significa en minutos reales del presupuesto. Cómo corregirlo: el Módulo 5, lección 5, Ejercicio 2 ya trabajó esta distinción con el caso de 1.000x — más allá de cierto punto, lo que importa no es cuánto más alto es el multiplicador, sino que cualquier valor por encima del umbral más urgente ya justifica la respuesta más urgente posible; el número exacto (400,00x frente a 25,00x) es información útil para diagnosticar la forma del incidente, no un indicador de que la respuesta debe ser "cuatrocientas veces más urgente".
Ejercicios
Ejercicio 1 — Calcula a mano qué habría mostrado la ventana corta de esta lección si, en vez de 10 de 25 manifiestos malformados, el batch hubiera tenido solo 1 de 25 malformado (con la misma razón, carrier faltante).
Ver solución
error_rate = 1 / 25 = 0.04 (4%). burn_rate = 0.04 / 0.001 = 40.0x. Incluso con un solo manifiesto malformado de 25, el burn rate de la ventana corta seguiría estando muy por encima del umbral más urgente de la Tabla 5-8 (14,4x) — la razón es que la ventana corta, por definición, es un lote pequeño: cualquier fracción de error mide, proporcionalmente, mucho más alto en una muestra chica que en una grande. Es la misma lección que el runbook del Módulo 7 ya advirtió: "one error out of five invocations breaches threshold = 0.001 exactly as surely as three hundred errors out of a hundred thousand" — siempre hace falta leer Invocations junto con Errors, nunca uno sin el otro.
Ejercicio 2 — Explica por qué build_batch() de esta lección usa una sola constante (BREAK_STARTS_AT) en vez del diccionario MALFORMED que upload_manifest_batch.py (Módulo 3) usó. ¿Qué le costaría a este script si quisieras, en cambio, reproducir exactamente el patrón disperso del Módulo 3?
Ver solución
BREAK_STARTS_AT funciona porque este batch tiene una propiedad que el del Módulo 3 no tenía: todas las posiciones rotas comparten la misma razón (carrier faltante) y forman un rango continuo — una sola comparación (i >= BREAK_STARTS_AT) basta para decidir, sin ninguna estructura de datos adicional. Reproducir el patrón disperso del Módulo 3 —posiciones no consecutivas, cada una con una razón distinta— sí necesitaría volver al diccionario MALFORMED de aquella lección (posición → razón específica), porque no hay ninguna regla simple de comparación que capture "posiciones 5, 12 y 17, cada una rota de una forma distinta". La simplicidad del código de esta lección es un reflejo directo de la simplicidad de la historia que el batch cuenta, no una casualidad de implementación.
Ejercicio 3 — El Paso 5 conecta el error_rate = 2.5% de la ventana larga de esta lección con la alarma real de CloudWatch del Módulo 4. Sin ejecutar nada, predice: ¿la alarma andes-cargo-manifest-error-budget-burn-rate pasaría a estado ALARM con estos números?
Ver solución
Sí, con margen amplio. La alarma de observability.tf dispara cuando errors / invocations >= threshold (0.001), con comparison_operator = "GreaterThanOrEqualToThreshold". Con error_ratio = 10 / 400 = 0.025 (2,5%), muy por encima del 0,001 (0,1%) que el umbral exige, la condición se cumple sin ambigüedad — la alarma pasaría de INSUFFICIENT_DATA/OK a ALARM en el siguiente ciclo de evaluación. La lección 4 de este módulo confirma exactamente esto, junto con los otros dos motores.
Resumen y siguiente paso
Esta lección construyó y corrió observability/upload_synthetic_incident_batch.py: un batch fijo de 25 manifiestos, quince buenos y diez rotos en bloque continuo (posiciones 16-25), todos por la misma razón exacta —carrier omitido—, subidos a través del trigger real de S3. Calculaste, a partir de ese batch, dos ventanas de burn rate: una corta (el batch en sí, 400,00x) y una larga (la hora completa incluyendo tráfico sano previo, 25,00x), ambas muy por encima del umbral más urgente de la Tabla 5-8. Confirmaste que la ventana larga (2,5% de tasa de error) es exactamente el número que la alarma de CloudWatch, sin ningún cambio, va a evaluar en la lección 4.
Antes de avanzar deberías poder: explicar por qué la concentración del fallo, no solo su cantidad, es lo que distingue este batch del Módulo 3; calcular a mano el burn rate de cualquier ventana dado su conteo de invocaciones/errores; y predecir, sin ejecutar nada, si una alarma de umbral 0,001 dispararía con un error_ratio dado.
La lección 4 toma estos dos números —400,00x y 25,00x— y los corre, sin ningún cambio de código, contra los tres motores de alerta del Módulo 4: el evaluador Python, la regla real de Alertmanager, y la alarma real de CloudWatch. Si los tres coinciden en que esto dispara, la máquina completa acaba de superar su primera prueba con datos que nunca vio antes.
Recursos
- Este mismo repositorio, Módulo 3, lección 3 (
03-hands-on-real-metrics-from-the-inherited-lambda.md) — el precedente directo del patrón de subida vía S3, y el batch con el que este contrasta deliberadamente. - Este mismo repositorio, Módulo 2, lección 6 (
06-hands-on-burn-rate-not-just-the-balance.md) — el patrón de dos ventanas (corta/larga) que esta lección aplica a un incidente nuevo. - Este mismo repositorio, Módulo 7, lección 6 (
06-hands-on-the-first-real-andes-cargo-runbook.md) — el árbol de decisión (Rama A/B/C) que la lección 5 de este módulo va a aplicar a este mismo batch. - Google SRE Workbook — Alerting on SLOs — la fuente de la fórmula de burn rate aplicada en el Paso 4.