Módulo 3: Observability As Sli Input

6. Manos a la obra: Prometheus y Grafana, el stack que pide el mercado

Descripción

Hasta esta lección, cada pilar de observabilidad de este módulo leyó el mismo batch fijo con una herramienta de AWS —CloudWatch (representativo) para métricas y logs, con Jaeger como alternativa open source solo para trazas (porque X-Ray no está disponible en el plan Hobby)—. Esta lección agrega una segunda alternativa completa, real y corriendo, para el pilar de métricas: Prometheus y Grafana, el stack que la investigación de mercado de este ecosistema encontró citado por nombre, con más frecuencia que CloudWatch nativo, en ofertas de trabajo reales.

Todo lo que sigue corrió de verdad: dos contenedores más (Prometheus v3.13.2, Grafana OSS 13.1.3) agregados al mismo docker-compose.yml que la lección 5 ya creó, un exportador Python real que expone el mismo batch de 20/3 como métricas de Prometheus, y un panel de Grafana, creado con la API real de Grafana, que consulta esas métricas y devuelve los mismos números que ya conoces de la lección 3.

Conexión con el módulo

La lección 2 dejó claro que las métricas son el único pilar que alimenta compute_sli() de forma directa. Esta lección construye una segunda fuente real de esas mismas métricas —no CloudWatch esta vez, sino Prometheus—, y la lección 7 va a usar exactamente estos números, extraídos con una consulta PromQL real, como la entrada de la calculadora del Módulo 2.


Paso 1 — La evidencia de mercado: por qué esta herramienta, no solo CloudWatch

La validación de mercado de este ecosistema (src/paths/aws-cloud-ecosystem/VALIDACION.md) es específica sobre esto, con cifras, no con una opinión:

"El mercado pide herramienta concreta de infraestructura en ~7 de 13 ofertas (MediaStream 'Prometheus, Grafana, and ELK stack'; Dev.Pro 'New Relic, Datadog'; Randstad 'CloudWatch'; EarnIn 'Datadog')."

De las cuatro herramientas nombradas explícitamente en esas ofertas, Prometheus/Grafana aparece una vez —igual que CloudWatch (Randstad)—, pero junto a New Relic y Datadog (Dev.Pro, EarnIn) confirma un patrón más amplio: casi ninguna oferta de este mercado espera que sepas usar solo la herramienta nativa de tu proveedor de nube. Saber leer una métrica en CloudWatch (lecciones 3 y 4 de este módulo) y saber leer la misma métrica en un stack open source portátil entre proveedores (esta lección) son dos habilidades que el mercado pide por separado, no una que reemplace a la otra.


Paso 2 — Extendiendo el docker-compose.yml

La lección 5 dejó observability/docker-compose.yml con un solo servicio, jaeger. Esta lección le agrega dos más:

services:
  jaeger:
    image: jaegertracing/jaeger:2.20.0
    container_name: andes-cargo-jaeger
    ports:
      - "16686:16686"   # UI
      - "4317:4317"     # OTLP gRPC
      - "4318:4318"     # OTLP HTTP

  prometheus:
    image: prom/prometheus:v3.13.2
    container_name: andes-cargo-prometheus
    extra_hosts:
      - "host.docker.internal:host-gateway"   # linux: mapea el host dentro del contenedor
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana:13.1.3
    container_name: andes-cargo-grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=andescargo

extra_hosts: host.docker.internal:host-gateway es la línea que hace que este docker-compose.yml funcione igual en Linux que en Docker Desktop (macOS/Windows) — sin ella, host.docker.internal solo resuelve automáticamente en Docker Desktop.

Crea también observability/prometheus.yml, el archivo que el volumen de arriba monta dentro del contenedor:

global:
  scrape_interval: 15s

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

  - job_name: "manifest-processor"
    static_configs:
      - targets: ["host.docker.internal:8000"]

El segundo job_name es el que importa: le dice a Prometheus que raspe (haga scraping de) un exportador corriendo en el puerto 8000 del host — exactamente el que construyes en el siguiente paso.


Paso 3 — El exportador: el mismo batch de 20/3, como métricas de Prometheus

# manifest_metrics_exporter.py
# Exposes the SAME fixed batch of process-shipment-manifest invocations from the M3.3
# CloudWatch walkthrough as real Prometheus metrics. Deterministic: no random, no datetime.now()
# driving any counted value -- the batch below is a fixed constant, replayed once at startup.

import time
from prometheus_client import Counter, start_http_server

# --- The fixed batch: 20 invocations, indices 5, 12 and 17 malformed on purpose. ---
# Same 20 events as observability/upload_manifest_batch.py (lesson 3.3) and the same 3
# failed request IDs as observability/manifest-log-events.json (lesson 3.4).
BATCH = [
    (1, "good"), (2, "good"), (3, "good"), (4, "good"), (5, "bad"),
    (6, "good"), (7, "good"), (8, "good"), (9, "good"), (10, "good"),
    (11, "good"), (12, "bad"), (13, "good"), (14, "good"), (15, "good"),
    (16, "good"), (17, "bad"), (18, "good"), (19, "good"), (20, "good"),
]

manifest_invocations_total = Counter(
    "manifest_invocations_total",
    "Total invocations of process-shipment-manifest observed by this exporter",
)
manifest_errors_total = Counter(
    "manifest_errors_total",
    "Invocations of process-shipment-manifest that ended in an unhandled exception",
)


def replay_batch():
    for _index, outcome in BATCH:
        manifest_invocations_total.inc()
        if outcome == "bad":
            manifest_errors_total.inc()


if __name__ == "__main__":
    start_http_server(8000)
    replay_batch()
    print("manifest_metrics_exporter listening on :8000/metrics")
    print(f"Replayed {len(BATCH)} invocations, {sum(1 for _, o in BATCH if o == 'bad')} errors.")
    while True:
        time.sleep(3600)

Este exportador no llama a awslocal ni depende de LocalStack en absoluto — es un servidor HTTP Python real, con dos contadores de Prometheus reales, que expone el mismo batch determinista de la lección 3 desde una fuente completamente distinta. Corre en el host, no dentro de Docker, precisamente para que Prometheus (dentro de Docker) lo raspe a través de host.docker.internal.


Paso 4 — Levantando todo, de verdad

pip install prometheus_client
python3 observability/manifest_metrics_exporter.py &

Qué esperar (literal):

manifest_metrics_exporter listening on :8000/metrics
Replayed 20 invocations, 3 errors.
cd observability
docker compose up -d

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

 Container andes-cargo-jaeger  Running
 Container andes-cargo-prometheus  Running
 Container andes-cargo-grafana  Started
curl -s http://localhost:9090/-/ready
curl -s http://localhost:3000/api/health

Qué esperar (literal):

Prometheus Server is Ready.
{"database":"ok","version":"13.1.3","commit":"45a27d64b64a82d666b06aa5c5bb3521587edb0d"}

Paso 5 — Confirmando el scraping, con PromQL real

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

Qué esperar (literal — el valor 1786737874.434 es el timestamp Unix de la consulta, tu valor variable; "20" es literal):

{
    "status": "success",
    "data": {
        "resultType": "vector",
        "result": [
            {
                "metric": {
                    "__name__": "manifest_invocations_total",
                    "instance": "host.docker.internal:8000",
                    "job": "manifest-processor"
                },
                "value": [1786737874.434, "20"]
            }
        ]
    }
}
curl -s 'http://localhost:9090/api/v1/query?query=manifest_errors_total/manifest_invocations_total'

Qué esperar (literal):

{
    "status": "success",
    "data": {
        "resultType": "vector",
        "result": [
            {
                "metric": {"instance": "host.docker.internal:8000", "job": "manifest-processor"},
                "value": [1786737874.51, "0.15"]
            }
        ]
    }
}

0.15 — 15% de tasa de error, exactamente 3/20, la misma proporción que Errors: 3.0 sobre Invocations: 20.0 ya dio en la lección 3, ahora confirmada con una consulta PromQL real contra una fuente completamente distinta de CloudWatch.


Paso 6 — El panel de Grafana, creado de verdad con la API

Primero, la fuente de datos:

curl -s -u admin:andescargo -X POST http://localhost:3000/api/datasources \
  -H "Content-Type: application/json" \
  -d '{"name": "Prometheus", "type": "prometheus", "url": "http://prometheus:9090", "access": "proxy", "isDefault": true}'

Qué esperar (literal, "id" y "uid" son asignados por Grafana al crear el recurso):

{"datasource": {"id": 1, "uid": "afv6sh2yl2ww0a", "name": "Prometheus", "type": "prometheus", "url": "http://prometheus:9090", "isDefault": true, ...}, "id": 1, "message": "Datasource added", "name": "Prometheus"}

Después, el panel —éxito/error del mismo batch, más un medidor de tasa de error—:

curl -s -u admin:andescargo -X POST http://localhost:3000/api/dashboards/db \
  -H "Content-Type: application/json" \
  -d '{
    "dashboard": {
      "uid": "andes-cargo-manifest-sli",
      "title": "Andes Cargo -- process-shipment-manifest SLI input",
      "panels": [
        {
          "id": 1, "type": "stat", "title": "Invocations vs errors (fixed batch)",
          "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
          "targets": [
            {"expr": "manifest_invocations_total - manifest_errors_total", "legendFormat": "good", "refId": "A"},
            {"expr": "manifest_errors_total", "legendFormat": "errors", "refId": "B"}
          ]
        },
        {
          "id": 2, "type": "gauge", "title": "Observed error rate",
          "gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
          "targets": [{"expr": "manifest_errors_total / manifest_invocations_total", "legendFormat": "error rate", "refId": "A"}],
          "fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1}}
        }
      ],
      "schemaVersion": 39
    },
    "overwrite": true
  }'

Qué esperar (literal):

{"folderUid": "", "id": 3189596175294464, "slug": "andes-cargo-process-shipment-manifest-sli-input", "status": "success", "uid": "andes-cargo-manifest-sli", "url": "/d/andes-cargo-manifest-sli/andes-cargo-process-shipment-manifest-sli-input", "version": 1}

"status": "success" — el dashboard se creó de verdad, en la instancia de Grafana real corriendo en este entorno, con dos paneles: uno mostrando "good" y "errors" como series separadas, otro mostrando la tasa de error como un medidor entre 0 y 1.

Confirmando el dato que el panel muestra, consultando la fuente de datos con el mismo camino que usa la propia UI de Grafana internamente:

curl -s -u admin:andescargo "http://localhost:3000/api/datasources/proxy/uid/afv6sh2yl2ww0a/api/v1/query?query=manifest_invocations_total%20-%20manifest_errors_total"

Qué esperar (literal):

{"status": "success", "data": {"resultType": "vector", "result": [{"metric": {"instance": "host.docker.internal:8000", "job": "manifest-processor"}, "value": [1786737907.6, "17"]}]}}

"17" — el panel "Invocations vs errors" de Grafana muestra exactamente el mismo número de eventos buenos que la lección 3 ya calculó a mano (20 - 3 = 17), esta vez leído de punta a punta a través de un exportador Python, un scrape de Prometheus, y una consulta de Grafana — tres capas, mismo dato.


Errores comunes

Olvidar extra_hosts: host.docker.internal:host-gateway en Linux (de asumir que host.docker.internal siempre funciona igual). Qué pasa: en Docker Desktop (macOS/Windows), host.docker.internal resuelve automáticamente sin ninguna configuración adicional; en Linux (Docker Engine puro), no. Cómo detectarlo: si Prometheus reporta el target manifest-processor en estado down, con un error de resolución de nombre, en una máquina Linux. Cómo corregirlo: la línea extra_hosts de docker-compose.yml mapea host.docker.internal a la puerta de enlace real del host de forma explícita, sin depender de que Docker Desktop lo haga por ti — es la razón por la que este docker-compose.yml la incluye desde el principio, en vez de asumir un solo sistema operativo.

Correr el exportador después de levantar Prometheus, y esperar que el scrape se recupere solo (de no entender el intervalo de scraping). Qué pasa: alguien levanta docker compose up primero, y solo después arranca manifest_metrics_exporter.py; el primer intento de scraping de Prometheus falla porque el puerto 8000 todavía no responde. Cómo detectarlo: si el primer curl a /api/v1/query no devuelve ningún resultado, o devuelve un valor vacío. Cómo corregirlo: no es un error permanente — con scrape_interval: 15s, Prometheus reintenta automáticamente en el siguiente ciclo, y el target pasa a up en cuanto el exportador responde—. Esta lección presenta el orden correcto (exportador primero, docker compose up después) precisamente para evitar esta espera.

Confundir el "id" que Grafana asigna a la fuente de datos con el "uid" al construir la URL del proxy (de mezclar dos identificadores). Qué pasa: alguien copia el número "id": 1 de la respuesta del Paso 6 y lo usa en la URL /api/datasources/proxy/uid/1/..., y el comando falla. Cómo detectarlo: si tu consulta al proxy de la fuente de datos devuelve un error de "datasource not found". Cómo corregirlo: Grafana asigna dos identificadores distintos a cada recurso —un "id" numérico interno y un "uid" alfanumérico, pensado para ser estable entre instancias—; la ruta /api/datasources/proxy/uid/<uid>/... necesita específicamente el segundo, no el primero. En esta lección, ese valor es afv6sh2yl2ww0a —el que la respuesta del primer comando del Paso 6 ya mostró.


Ejercicios

Ejercicio 1 — Sin ejecutar nada, escribe la expresión PromQL que mostraría el porcentaje de eventos buenos (no de errores) del batch. Verifica que, con los números de esta lección, dé 85 (si multiplicas por 100) o 0.85 (si lo dejas como fracción).

Ver solución
(manifest_invocations_total - manifest_errors_total) / manifest_invocations_total

Con manifest_invocations_total = 20 y manifest_errors_total = 3: (20 - 3) / 20 = 17/20 = 0.85. Multiplicado por 100 (o formateado con la unidad percentunit de Grafana, como en el panel "Observed error rate" de esta lección, aplicada aquí al complemento), sería 85% — la misma proporción, vista desde el lado positivo en vez del lado del error.

Ejercicio 2 — Explica por qué este exportador usa Counter (un contador que solo sube) y no Gauge (un valor que puede subir o bajar) para manifest_invocations_total. ¿Qué pasaría si, por error, se usara Gauge en su lugar?

Ver solución

Un Counter de Prometheus modela correctamente algo que solo se acumula con el tiempo —el total histórico de invocaciones nunca "baja", aunque el sistema deje de recibir tráfico nuevo—; es exactamente el mismo comportamiento que AWS/Lambda/Invocations de CloudWatch (lección 3), que también es un conteo acumulativo, no un valor instantáneo. Un Gauge, en cambio, está pensado para valores que suben y bajan libremente —temperatura, memoria usada en este instante, ranuras de concurrencia ocupadas ahora mismo—. Si manifest_invocations_total usara Gauge en vez de Counter, el exportador tendría que decidir manualmente cuándo "resetear" el valor, y cualquier consulta PromQL que calculara una tasa de cambio (con la función rate(), pensada específicamente para contadores) daría resultados sin sentido — rate() asume que el valor solo sube, y usa una caída inesperada como señal de que el proceso se reinició, no como un decremento real.

Ejercicio 3 — Compara, en una tabla corta de tu propia autoría, la ruta completa de este dato en CloudWatch (lección 3) frente a la ruta completa en Prometheus/Grafana (esta lección). Nombra cada capa por la que pasa el número 17 (eventos buenos) en cada camino.

Ver solución

Una tabla razonable:

CapaCamino CloudWatch (lección 3)Camino Prometheus/Grafana (esta lección)
Origen del datoEl propio Lambda, instrumentado automáticamente por el runtime de AWSmanifest_metrics_exporter.py, un script Python que replica el mismo batch fijo
RecolecciónCloudWatch Metrics, nativo del proveedorPrometheus, con scrape_interval: 15s, sobre un exportador HTTP propio
Consultaawslocal cloudwatch get-metric-statistics, sintaxis específica de AWSPromQL (manifest_invocations_total - manifest_errors_total), portátil entre proveedores
VisualizaciónNinguna en esta guía (solo JSON crudo)Un panel de Grafana, consultado con la misma expresión PromQL

La diferencia clave que esta tabla revela: el camino de CloudWatch termina en un número dentro de un JSON de AWS CLI; el camino de Prometheus/Grafana termina en el mismo número, pero visualizado, y con una sintaxis de consulta (PromQL) que no cambia si mañana Andes Cargo migrara de AWS a otro proveedor de nube — la razón de fondo detrás de la cita de mercado del Paso 1 de esta lección.


Resumen y siguiente paso

Esta lección corrió, de punta a punta, una segunda fuente real de métricas para el mismo batch fijo de 20 invocaciones: Prometheus v3.13.2 y Grafana 13.1.3, agregados al docker-compose.yml de la lección 5, un exportador Python real (manifest_metrics_exporter.py) exponiendo los mismos contadores que CloudWatch ya mostró de forma representativa, y un panel de Grafana —creado con la API real de Grafana, no descrito en prosa— confirmando el mismo 17 de eventos buenos que ya conocías. La cita de mercado del Paso 1 justificó por qué esta segunda ruta importa: Prometheus/Grafana aparece nombrado explícitamente en ofertas reales, junto a otras herramientas de observabilidad de terceros, con la misma frecuencia que CloudWatch nativo.

Antes de avanzar deberías poder: explicar la diferencia entre extra_hosts: host.docker.internal:host-gateway y el comportamiento automático de Docker Desktop; escribir de memoria la expresión PromQL de la tasa de error de este batch; y nombrar las cuatro capas de la ruta completa del dato en el camino de Prometheus/Grafana.

La lección 7 cierra el hilo del módulo: toma exactamente los números de esta lección —20 invocaciones, 3 errores, extraídos con PromQL real— y se los da a error_budget_calculator.py, la calculadora del Módulo 2, por primera vez con telemetría real en vez de un dataset de ejemplo.

Recursos

  1. src/paths/aws-cloud-ecosystem/VALIDACION.md — la cita de mercado exacta ("Prometheus, Grafana, and ELK stack") que justifica esta lección.
  2. Prometheus — Docker Hub — la imagen oficial, versión v3.13.2, usada en esta lección.
  3. Grafana — Download OSS — la imagen oficial, versión 13.1.3.
  4. Prometheus — Querying basics (PromQL) — la referencia oficial de las expresiones PromQL de esta lección.
  5. Grafana HTTP API — Data source y Dashboard — la referencia oficial de los dos endpoints usados en el Paso 6.
  6. prometheus/client_python — Counter — la documentación oficial del tipo de métrica que usa manifest_metrics_exporter.py.