Módulo 4: Alerting On Error Budget Burn Rate

4. Manos a la obra: la regla real en Alertmanager

Descripción

La lección 3 prototipó la decisión de alerta en Python puro, sin ninguna infraestructura alrededor. Esta lección construye el segundo motor de este módulo: la misma decisión —las tres severidades de la Tabla 5-8, los mismos dos escenarios ("mala semana", "normal")— pero esta vez evaluada de verdad por Prometheus, contra métricas scrapeadas de un exportador real, con las alertas enrutadas a un receptor real por Alertmanager. Todo lo que sigue corrió de verdad, en Docker, en este mismo entorno: Prometheus v3.13.2 y Alertmanager v0.33.1, levantados con docker compose, evaluando una regla de alerta real, entregando una notificación real a un receptor real.

Conexión con el módulo

Esta lección extiende observability/docker-compose.yml (Módulo 3, lección 6) con un tercer servicio, alertmanager — hermano de prometheus y grafana, ya en ese mismo archivo. Reutiliza la métrica manifest_invocations_total/manifest_errors_total del Módulo 3, lección 6 solo como precedente de patrón (el exportador de esta lección expone una métrica nueva, manifest_burn_rate, con los números que la lección 3 de este módulo ya produjo). La lección 5 hace, con CloudWatch, lo que esta lección hace con Prometheus.


Paso 1 — El exportador: los cuatro números de la lección 3, como Gauges reales de Prometheus

# burn_rate_exporter.py
# Exposes the SAME four literal burn-rate numbers that burn_rate_evaluator.py (M4.3) already
# computed, as real Prometheus Gauges, for BOTH scenarios at once (label "scenario"), each
# with its short-window and long-window value (label "window"). One real exporter, one real
# Prometheus scrape, one real Alertmanager evaluation deciding both cases side by side.
# Deterministic: the four values below are copy-pasted from M4.3's literal stdout -- set once
# at startup, never random, never datetime.now().

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)

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")
    while True:
        time.sleep(3600)

Un Gauge (no un Counter, como el exportador del Módulo 3, lección 6) porque burn rate es un valor que sube y baja libremente, no un total acumulado — la misma distinción que el Ejercicio 2 de aquella lección ya trabajó. La etiqueta scenario (bad_week/normal) permite que un único exportador, en un único puerto, exponga los dos escenarios de este módulo a la vez; la etiqueta window (short/long) permite que la regla de alerta pida ambos valores por separado, exactamente como el patrón de dos ventanas de la lección 2 exige.


Paso 2 — Extendiendo docker-compose.yml, prometheus.yml, y las reglas de alerta

observability/docker-compose.yml (Módulo 3, lección 6 dejó jaeger, prometheus, grafana; esta lección agrega alertmanager y monta un archivo nuevo de reglas):

services:
  prometheus:
    image: prom/prometheus:v3.13.2
    container_name: andes-cargo-prometheus
    extra_hosts:
      - "host.docker.internal:host-gateway"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./alert_rules.yml:/etc/prometheus/alert_rules.yml:ro
    ports:
      - "9090:9090"

  alertmanager:
    image: prom/alertmanager:v0.33.1
    container_name: andes-cargo-alertmanager
    extra_hosts:
      - "host.docker.internal:host-gateway"
    volumes:
      - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
    ports:
      - "9093:9093"

observability/prometheus.yml, con dos bloques nuevos (alerting, rule_files) y un job_name nuevo apuntando al exportador de esta lección:

global:
  scrape_interval: 15s

alerting:
  alertmanagers:
    - static_configs:
        - targets: ["alertmanager:9093"]

rule_files:
  - "alert_rules.yml"

scrape_configs:
  - job_name: "prometheus"
    static_configs:
      - targets: ["localhost:9090"]

  - job_name: "manifest-burn-rate"
    static_configs:
      - targets: ["host.docker.internal:8001"]

observability/alert_rules.yml, las tres severidades de la Tabla 5-8, como reglas PromQL reales:

groups:
  - name: manifest-burn-rate
    rules:
      - alert: ManifestErrorBudgetBurnRatePageFast
        expr: |
          manifest_burn_rate{window="short"} >= 14.4
          and ignoring(window)
          manifest_burn_rate{window="long"} >= 14.4
        for: 0m
        labels:
          severity: page
        annotations:
          summary: "process-shipment-manifest burn rate >= 14.4x (1h/5m window), scenario={{ $labels.scenario }}"

      - alert: ManifestErrorBudgetBurnRatePageSlow
        expr: |
          manifest_burn_rate{window="short"} >= 6
          and ignoring(window)
          manifest_burn_rate{window="long"} >= 6
        for: 0m
        labels:
          severity: page
        annotations:
          summary: "process-shipment-manifest burn rate >= 6x (6h/30m window), scenario={{ $labels.scenario }}"

      - alert: ManifestErrorBudgetBurnRateTicket
        expr: |
          manifest_burn_rate{window="short"} >= 1
          and ignoring(window)
          manifest_burn_rate{window="long"} >= 1
        for: 0m
        labels:
          severity: ticket
        annotations:
          summary: "process-shipment-manifest burn rate >= 1x (3d/6h window), scenario={{ $labels.scenario }}"

La pieza que hace todo esto funcionar es and ignoring(window). manifest_burn_rate{window="short"} >= 14.4 selecciona, de las cuatro series que el exportador expone, solo las de ventana corta que cruzan el umbral; el operador and de PromQL, por defecto, exige que todas las etiquetas coincidan entre los dos lados de la comparación — pero el lado izquierdo tiene window="short" y el derecho window="long", etiquetas que nunca van a coincidir. ignoring(window) le dice a PromQL que ignore específicamente esa etiqueta al hacer la coincidencia, y empareje por lo que sí debe coincidir: scenario. El resultado es, para cada escenario, "¿la ventana corta cruza el umbral, y la ventana larga del mismo escenario también?" — la condición AND de dos ventanas, expresada en PromQL puro, sin ninguna lógica externa a Prometheus.

observability/alertmanager.yml, con un receptor webhook real:

route:
  receiver: "andes-cargo-reliability-team"
  group_by: ["alertname", "scenario"]
  group_wait: 5s
  group_interval: 30s
  repeat_interval: 1h

receivers:
  - name: "andes-cargo-reliability-team"
    webhook_configs:
      - url: "http://host.docker.internal:9099/alerts"
        send_resolved: true

Y el receptor mismo — un servidor HTTP real, en Python puro, que sustituye al canal de Slack/PagerDuty que un equipo real conectaría aquí:

# alert_webhook_receiver.py
# A tiny, real HTTP server standing in for the "team channel" -- Alertmanager POSTs its alert
# payload here, in the same JSON shape it would send to Slack/PagerDuty/Opsgenie webhooks.
# $0, no external dependency. Prints each alert group it receives, so the loop
# Prometheus -> Alertmanager -> receiver is verifiable end to end, not just described.

import json
from http.server import BaseHTTPRequestHandler, HTTPServer


class AlertHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        body = json.loads(self.rfile.read(length))
        for alert in body.get("alerts", []):
            print(
                f"[alert_webhook_receiver] status={alert['status']:<8} "
                f"alertname={alert['labels'].get('alertname')} "
                f"scenario={alert['labels'].get('scenario')} "
                f"severity={alert['labels'].get('severity')}",
                flush=True,
            )
        self.send_response(200)
        self.end_headers()

    def log_message(self, fmt, *args):
        pass


if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 9099), AlertHandler)
    print("alert_webhook_receiver listening on :9099/alerts")
    server.serve_forever()

Paso 3 — Levantando todo, de verdad

python3 observability/burn_rate_exporter.py &
python3 observability/alert_webhook_receiver.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
alert_webhook_receiver listening on :9099/alerts
cd observability
docker compose up -d prometheus alertmanager

Qué esperar (literal — verificado en este entorno):

 Container andes-cargo-prometheus  Started
 Container andes-cargo-alertmanager  Started
docker exec andes-cargo-alertmanager alertmanager --version
curl -s http://localhost:9090/-/ready
curl -s http://localhost:9093/-/ready

Qué esperar (literal):

alertmanager, version 0.33.1 (branch: HEAD, revision: 2c8da51e03f3dbbed24f9711ca2d76aab4eef9c5)
Prometheus Server is Ready.
OK

Paso 4 — Confirmando el scraping, con PromQL real

curl -s 'http://localhost:9090/api/v1/query?query=manifest_burn_rate'

Qué esperar (literal — las cuatro series, exactamente los cuatro valores del exportador):

{
  "status": "success",
  "data": {
    "resultType": "vector",
    "result": [
      {"metric": {"scenario": "bad_week", "window": "short"}, "value": [1786739904.286, "17.54"]},
      {"metric": {"scenario": "bad_week", "window": "long"},  "value": [1786739904.286, "43.41"]},
      {"metric": {"scenario": "normal",   "window": "short"}, "value": [1786739904.286, "0"]},
      {"metric": {"scenario": "normal",   "window": "long"},  "value": [1786739904.286, "0.9"]}
    ]
  }
}

Cuatro series (el timestamp Unix de cada value es tu valor variable; las etiquetas y los números son literales), confirmando que Prometheus scrapeó de verdad el exportador de esta lección.

curl -s -G http://localhost:9090/api/v1/query \
  --data-urlencode 'query=manifest_burn_rate{window="short"} >= 14.4 and ignoring(window) manifest_burn_rate{window="long"} >= 14.4'

Qué esperar (literal — el resultado de la expresión completa de la severidad más urgente):

{
  "status": "success",
  "data": {
    "resultType": "vector",
    "result": [
      {"metric": {"scenario": "bad_week", "window": "short"}, "value": [1786739926.172, "17.54"]}
    ]
  }
}

Un único resultado: scenario="bad_week". La misma consulta, evaluada sobre normal, devolvería una lista vacía (0,90x nunca cruza 14,4x en ninguna ventana) — la confirmación, en PromQL puro, del mismo contraste que la lección 3 ya mostró en Python.


Paso 5 — El ciclo completo: la regla dispara, Alertmanager la recibe, el receptor la registra

curl -s http://localhost:9090/api/v1/rules

Qué esperar (literal, tras el primer ciclo de evaluación de reglas de Prometheus, ~1 minuto después de levantar los contenedores):

ManifestErrorBudgetBurnRatePageFast  state=firing  alerts=[('bad_week', 'firing')]
ManifestErrorBudgetBurnRatePageSlow  state=firing  alerts=[('bad_week', 'firing')]
ManifestErrorBudgetBurnRateTicket    state=firing  alerts=[('bad_week', 'firing')]

Las tres reglas pasan a firing, y las tres tienen exactamente un escenario activo: bad_week. Ningún resultado con normal aparece en ninguna de las tres — la misma conclusión de la lección 3, esta vez producida por el motor de reglas real de Prometheus, no por una función de Python.

curl -s http://localhost:9093/api/v2/alerts

Qué esperar (literal):

ManifestErrorBudgetBurnRatePageFast bad_week page active
ManifestErrorBudgetBurnRateTicket   bad_week ticket active
ManifestErrorBudgetBurnRatePageSlow bad_week page active

Prometheus le entregó las tres alertas a Alertmanager (confirmado con curl http://localhost:9090/api/v1/alertmanagers, que lista alertmanager:9093 como destino activo), y Alertmanager las tiene registradas como active, agrupadas por alertname+scenario según group_by de alertmanager.yml.

Y, cerrando el ciclo, el receptor —el sustituto de un canal de Slack/PagerDuty real— las recibió de verdad:

cat observability/webhook.log

Qué esperar (literal — el registro completo, línea por línea, de lo que Alertmanager de verdad entregó):

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

Tres líneas, una por severidad, todas con scenario=bad_week — ninguna con scenario=normal. Este es el resultado completo, de punta a punta: Prometheus evaluó la regla → decidió disparar solo para bad_week → Alertmanager la recibió y la agrupó → el receptor la registró, sin ningún paso simulado en prosa.

   EL CICLO COMPLETO DE ESTA LECCION, VERIFICADO DE PUNTA A PUNTA

   burn_rate_exporter.py (Gauge, 4 valores fijos)
          |
          | scrape cada 15s
          v
   Prometheus (evalua alert_rules.yml cada ~1min)
          |
          | POST /api/v2/alerts (solo scenario=bad_week cruza el umbral)
          v
   Alertmanager (agrupa por alertname+scenario, enruta)
          |
          | POST http://host.docker.internal:9099/alerts
          v
   alert_webhook_receiver.py -- registra 3 lineas, todas bad_week

Errores comunes

Omitir ignoring(window) en la expresión PromQL, y no entender por qué la regla nunca dispara (el error central de esta lección). Qué pasa: alguien escribe manifest_burn_rate{window="short"} >= 14.4 and manifest_burn_rate{window="long"} >= 14.4, sin ignoring(window), y la regla se queda en inactive para siempre, aunque los números del exportador sean correctos. Cómo detectarlo: si tu consulta directa en /api/v1/query de la expresión completa devuelve una lista vacía, pese a que ambas mitades por separado sí devuelven resultados. Cómo corregirlo: el operador and de PromQL exige coincidencia de todas las etiquetas por defecto — como el lado izquierdo tiene window="short" y el derecho window="long", nunca hay coincidencia sin decirle explícitamente a PromQL qué etiqueta ignorar al emparejar. ignoring(window) es, literalmente, la implementación en PromQL de "ambas ventanas del mismo escenario", no un detalle cosmético.

Levantar docker compose up antes de arrancar el exportador, y esperar que el scraping falle para siempre (repetido del Módulo 3, lección 6, con la misma solución). Qué pasa: alguien levanta Prometheus/Alertmanager primero, y el primer intento de scraping del job manifest-burn-rate falla porque el puerto 8001 todavía no responde. Cómo detectarlo: curl http://localhost:9090/api/v1/targets muestra el target en health: "unknown" o con un error de conexión. Cómo corregirlo: no es un error permanente — con scrape_interval: 15s, Prometheus reintenta automáticamente, y el target pasa a up en el siguiente ciclo, en cuanto el exportador responde. Esta lección presenta el orden correcto (exportador primero) precisamente para evitar esta espera innecesaria.

Esperar que la regla dispare en el instante exacto en que docker compose up termina (de no contar con el intervalo de evaluación de Prometheus). Qué pasa: alguien corre curl http://localhost:9090/api/v1/rules inmediatamente después de levantar los contenedores y ve state: "inactive" en las tres reglas, y concluye que algo falló. Cómo detectarlo: si tu verificación ocurre segundos, no minutos, después de docker compose up. Cómo corregirlo: Prometheus evalúa las reglas de alerta en su propio intervalo (por defecto, 1 minuto, independiente del scrape_interval de 15 segundos) — la primera evaluación puede ocurrir antes de que el exportador siquiera haya sido scrapeado una vez. Espera al menos un ciclo completo de evaluación (~1 minuto) antes de confirmar el estado de las reglas, exactamente lo que esta lección hizo para producir la salida del Paso 5.


Ejercicios

Ejercicio 1 — Reescribe la expresión PromQL de la fila Ticket (umbral 1x) usando el mismo patrón and ignoring(window) de esta lección, sin mirar el archivo. Verifica mentalmente que, aplicada a los cuatro valores del exportador, dispare solo para bad_week.

Ver solución
manifest_burn_rate{window="short"} >= 1
and ignoring(window)
manifest_burn_rate{window="long"} >= 1

Aplicada a los valores del exportador: para bad_week, ventana corta 17,54x ≥ 1 (cierto) y ventana larga 43,41x ≥ 1 (cierto) → dispara. Para normal, ventana corta 0,00x ≥ 1 (falso) → la condición and ya falla en el primer término, sin necesidad de evaluar el segundo → no dispara. Mismo patrón exacto que las otras dos severidades, solo con el umbral y el nombre de la alerta distintos.

Ejercicio 2 — Explica qué pasaría con las tres reglas de esta lección si el exportador solo expusiera el valor de la ventana larga (window="long"), sin ningún valor de ventana corta. ¿Alguna de las tres alertas podría llegar a disparar?

Ver solución

Ninguna de las tres alertas dispararía nunca. Cada expresión exige que ambos lados del and ignoring(window) devuelvan al menos un resultado que cumpla su condición — si no existe ninguna serie con window="short", el lado izquierdo de la expresión (manifest_burn_rate{window="short"} >= X) siempre devuelve un vector vacío, sin importar qué tan alto esté el valor de la ventana larga. Un vector vacío en cualquier lado de un and hace que el resultado completo sea también vacío — la regla se queda permanentemente en inactive. Esto ilustra, de forma muy concreta, por qué la ventana corta no es opcional: sin ella, la regla completa deja de poder disparar, sin importar qué tan grave sea la situación real.

Ejercicio 3 — Explica la diferencia entre el estado de una alerta en Prometheus (firing) y su estado en Alertmanager (active). ¿Son sinónimos, o representan capas distintas del mismo ciclo?

Ver solución

Representan dos capas distintas, secuenciales, del mismo ciclo. firing en Prometheus (/api/v1/rules) es el estado de la regla de alerta: la condición PromQL se cumplió, evaluada por el motor de reglas de Prometheus. active en Alertmanager (/api/v2/alerts) es el estado de la notificación: Alertmanager recibió esa alerta (vía POST /api/v2/alerts, que Prometheus envía automáticamente cuando una regla pasa a firing) y la tiene registrada como pendiente de enrutar o ya enrutada a un receptor. Una alerta puede estar firing en Prometheus sin que Alertmanager la haya recibido todavía (si la conexión entre ambos falla, por ejemplo) — son dos sistemas separados, con dos responsabilidades distintas: uno decide si algo debe alertar, el otro decide a quién avisar y cómo agrupar esa alerta con otras relacionadas.


Resumen y siguiente paso

Esta lección construyó y corrió, de punta a punta, el segundo motor de este módulo: un exportador Python real exponiendo burn rate como Gauges de Prometheus, una regla de alerta real con las tres severidades de la Tabla 5-8 (usando and ignoring(window) para expresar la condición de dos ventanas en PromQL puro), y Alertmanager entregando esas alertas a un receptor webhook real. El resultado, verificado en cada capa: las tres severidades disparan para scenario="bad_week", ninguna para scenario="normal" — Prometheus lo confirma en /api/v1/rules, Alertmanager en /api/v2/alerts, y el receptor lo registra en su propio log, tres capas, el mismo resultado.

Antes de avanzar deberías poder: explicar qué hace ignoring(window) y por qué es necesario; describir las cuatro capas del ciclo completo (exportador → Prometheus → Alertmanager → receptor); y reproducir, corriendo tu propia copia de este entorno, el mismo resultado de esta lección.

La lección 5 construye el tercer motor de este módulo: la misma decisión, esta vez como una alarma real de CloudWatch, declarada en Terraform sobre el Lambda real de Andes Cargo.

Recursos

  1. Prometheus — Alerting rules — referencia oficial de la sintaxis usada en alert_rules.yml.
  2. Prometheus — Querying: Operators — referencia oficial de and/ignoring(), el operador central de esta lección.
  3. Alertmanager — Configuration — referencia oficial de route, group_by, y webhook_configs.
  4. Este mismo repositorio, Módulo 3, lección 6 (06-hands-on-prometheus-and-grafana-the-stack-the-market-asks-for.md) — el docker-compose.yml y el patrón de exportador que esta lección extiende.
  5. Este mismo repositorio, Módulo 4, lección 3 (03-hands-on-the-burn-rate-evaluator.md) — el prototipo en Python de la misma decisión que esta lección reimplementa en PromQL.