Módulo 3: Observability As Sli Input

5. Manos a la obra: trazas con OpenTelemetry y Jaeger

Descripción

Esta lección sigue "el vagón" de la analogía de la lección 2: la invocación número 17 del batch —la misma que la lección 4 ya identificó por requestId con jq—, vista de punta a punta a través de los tres pasos de su flujo: la subida a S3, el procesamiento en process-shipment-manifest, y el intento (o no) de escritura a Shipments. A diferencia de las dos lecciones anteriores, todo lo que sigue corrió de verdad: un contenedor Jaeger v2 real, levantado con docker run en este mismo entorno; un script Python real, con el SDK de OpenTelemetry, que envió dos trazas completas por OTLP/HTTP; y la API de Jaeger, consultada de verdad, confirmando que ambas trazas llegaron con la estructura exacta que el script produjo.

Conexión con el módulo

La lección 2 prometió que las trazas contestan "¿en qué paso exacto del flujo se cortó una invocación específica?". Esta lección lo demuestra con dos trazas reales: una del camino exitoso (posición 1 del batch, envío 4471), y una de la invocación que falló en la posición 17 —el mismo requestId c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58 que la lección 4 extrajo de los logs—. La lección 6 extiende el mismo docker-compose.yml que esta lección crea, agregando Prometheus y Grafana.


Paso 1 — Por qué Jaeger v2, no all-in-one, y por qué no X-Ray

Antes del código, dos decisiones que vale la pena que entiendas, no solo que copies.

Jaeger v2, imagen jaegertracing/jaeger, nunca jaegertracing/all-in-one. Jaeger v1 llegó a su fin de vida el 31 de diciembre de 2025 — la imagen all-in-one sigue existiendo en Docker Hub, pero está descontinuada, y el propio contenedor lo advierte al arrancar. La imagen correcta y vigente para un despliegue local completo es jaegertracing/jaeger (v2), construida sobre el framework del Colector de OpenTelemetry — confirmada en este entorno, versión 2.20.0.

X-Ray, nombrado por contraste, nunca ejecutado en esta guía. AWS X-Ray sería la alternativa nativa de AWS para trazas distribuidas — pero la documentación oficial de LocalStack es explícita: "Included in Plans: Ultimate", ausente del plan Hobby que el resto de esta guía usa. Esta lección usa OpenTelemetry + Jaeger, en cambio, precisamente porque enseña el mismo concepto —una traza distribuida, con spans padre-hijo— con una herramienta open source que sí corre, sin depender de un tier de pago.


Paso 2 — Levantando Jaeger v2 de verdad

Crea observability/docker-compose.yml en andes-cargo-infra/ (esta lección lo abre con un solo servicio; la lección 6 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
cd observability
docker compose up -d jaeger

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

 Network observability_default  Created
 Container andes-cargo-jaeger  Started
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:16686/

Qué esperar (literal):

200

La UI de Jaeger responde antes de que exista ninguna traza — el paso siguiente es lo que la va a llenar.


Paso 3 — El script de instrumentación

Crea observability/instrument_manifest_flow.py:

# instrument_manifest_flow.py
# Sends two real traces of the upload -> process-shipment-manifest -> DynamoDB flow to
# Jaeger v2 over OTLP/HTTP. Trace 1 is the successful path (shipment 4471). Trace 2 is
# invocation #17 from the fixed batch (lessons 3.3/3.4) -- a manifest with a non-numeric
# weightKg, rejected by validate_manifest() -- shown as a real ERROR span.
# Span content (which shipment, which request id, which error) is fixed and literal;
# only the timestamps Jaeger assigns on ingest vary by run.

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import Status, StatusCode

JAEGER_OTLP_ENDPOINT = "http://localhost:4318/v1/traces"


def build_tracer(service_name):
    provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
    provider.add_span_processor(
        BatchSpanProcessor(OTLPSpanExporter(endpoint=JAEGER_OTLP_ENDPOINT))
    )
    return provider


app_provider = build_tracer("andes-cargo-app-server")
lambda_provider = build_tracer("process-shipment-manifest")
dynamodb_provider = build_tracer("dynamodb")

app_tracer = app_provider.get_tracer("andes-cargo.upload")
lambda_tracer = lambda_provider.get_tracer("andes-cargo.manifest-processor")
dynamodb_tracer = dynamodb_provider.get_tracer("andes-cargo.shipments-table")


def trace_successful_manifest():
    """Trace 1: shipment 4471 (batch position 1), the full upload -> Lambda -> DynamoDB
    path succeeds."""
    with app_tracer.start_as_current_span("shipment-manifest-upload") as upload_span:
        upload_span.set_attribute("shipment.id", "4471")
        upload_span.set_attribute("s3.bucket", "andes-cargo-shipment-docs")
        upload_span.set_attribute(
            "s3.key", "manifests/year=2026/month=08/batch/01-shipment-4471-manifest.txt"
        )

        with lambda_tracer.start_as_current_span("process-shipment-manifest") as fn_span:
            fn_span.set_attribute("faas.name", "process-shipment-manifest")
            fn_span.set_attribute("aws.region", "us-east-1")
            fn_span.set_attribute("request.id", "req-0001-success")

            with dynamodb_tracer.start_as_current_span("dynamodb-put-item") as db_span:
                db_span.set_attribute("db.system", "dynamodb")
                db_span.set_attribute("db.name", "Shipments")
                db_span.set_attribute("aws.dynamodb.table_names", "Shipments")
                db_span.set_status(Status(StatusCode.OK))

            fn_span.set_status(Status(StatusCode.OK))
        upload_span.set_status(Status(StatusCode.OK))


def trace_malformed_manifest():
    """Trace 2: invocation #17 of the fixed batch (lessons 3.3/3.4) -- the manifest for
    shipment 4473 has a non-numeric weightKg ('heavy' instead of a number), which
    validate_manifest() (aws-serverless-and-containers-guide, Module 2) rejects. The
    Lambda span ends in ERROR before any DynamoDB span is opened -- the trace shows
    exactly where the flow broke, the same way the log line in lesson 3.4 does."""
    with app_tracer.start_as_current_span("shipment-manifest-upload") as upload_span:
        upload_span.set_attribute("shipment.id", "4473")
        upload_span.set_attribute("s3.bucket", "andes-cargo-shipment-docs")
        upload_span.set_attribute(
            "s3.key", "manifests/year=2026/month=08/batch/17-shipment-4473-manifest.txt"
        )

        with lambda_tracer.start_as_current_span("process-shipment-manifest") as fn_span:
            fn_span.set_attribute("faas.name", "process-shipment-manifest")
            fn_span.set_attribute("aws.region", "us-east-1")
            fn_span.set_attribute("request.id", "c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58")
            error_message = "manifest validation failed: ['weightKg must be numeric']"
            error = ValueError(error_message)
            fn_span.record_exception(error)
            fn_span.set_status(Status(StatusCode.ERROR, error_message))

        upload_span.set_status(Status(StatusCode.ERROR, "manifest processing failed"))


if __name__ == "__main__":
    trace_successful_manifest()
    trace_malformed_manifest()
    for provider in (app_provider, lambda_provider, dynamodb_provider):
        provider.force_flush()
        provider.shutdown()
    print("2 traces sent to", JAEGER_OTLP_ENDPOINT)

Fíjate en la traza 2: nunca abre un span dynamodb-put-item — exactamente como el flujo real, donde write_shipment_record() nunca se ejecuta si validate_manifest() ya rechazó el manifiesto. La ausencia de ese tercer span es, en sí misma, la evidencia de dónde se cortó el flujo — no hace falta ningún campo adicional para verlo.


Paso 4 — Corriendo el script

pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
python3 observability/instrument_manifest_flow.py

Qué esperar (literal — corrido en este entorno, con OpenTelemetry Python SDK 1.44.0):

2 traces sent to http://localhost:4318/v1/traces

Paso 5 — Confirmando las trazas en Jaeger, de verdad

curl -s http://localhost:16686/api/services

Qué esperar (literal):

{"data":["process-shipment-manifest","dynamodb","jaeger","andes-cargo-app-server"],"total":4,"limit":0,"offset":0,"errors":null}

Tres servicios propios de Andes Cargo (andes-cargo-app-server, process-shipment-manifest, dynamodb) más jaeger, el propio servicio interno del colector — la prueba de que las tres fuentes de spans del script llegaron, cada una con su propio nombre de servicio.

curl -s "http://localhost:16686/api/traces?service=andes-cargo-app-server&limit=10"

Qué esperar (resumen literal de la respuesta — los traceID y las duraciones en microsegundos son tu valor variable, asignados por Jaeger al momento de la ingesta; la estructura, los nombres de span, los atributos y los estados son literales):

=== traceID: 64441cce3ff83431f92061452f78f715 ===
  service=andes-cargo-app-server      op=shipment-manifest-upload     status=ERROR desc="manifest processing failed"
    attrs: s3.bucket=andes-cargo-shipment-docs, s3.key=manifests/year=2026/month=08/batch/17-shipment-4473-manifest.txt, shipment.id=4473
  service=process-shipment-manifest   op=process-shipment-manifest    status=ERROR desc="manifest validation failed: ['weightKg must be numeric']"
    attrs: aws.region=us-east-1, faas.name=process-shipment-manifest, request.id=c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58

=== traceID: d7717881159f0132c7b71704b930da16 ===
  service=andes-cargo-app-server      op=shipment-manifest-upload     status=OK
    attrs: s3.bucket=andes-cargo-shipment-docs, s3.key=manifests/year=2026/month=08/batch/01-shipment-4471-manifest.txt, shipment.id=4471
  service=process-shipment-manifest   op=process-shipment-manifest    status=OK
    attrs: aws.region=us-east-1, faas.name=process-shipment-manifest, request.id=req-0001-success
  service=dynamodb                    op=dynamodb-put-item            status=OK
    attrs: aws.dynamodb.table_names=Shipments, db.name=Shipments, db.system=dynamodb

Dos trazas, exactamente como el script las construyó. La traza 64441cce... tiene dos spans, ambos en ERROR — ninguno de DynamoDB—; la traza d7717881... tiene tres, los tres en OK. El request.id de la traza con error, c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58, es exactamente el mismo que la lección 4 extrajo con jq de los logs — la misma invocación, ahora vista de punta a punta.

Si abres http://localhost:16686 en un navegador y buscas por servicio andes-cargo-app-server, la UI muestra ambas trazas con el mismo detalle: la traza exitosa con tres barras horizontales anidadas (una por span, con la de DynamoDB completamente dentro de la del Lambda), y la traza con error con solo dos, la segunda marcada en rojo.


Errores comunes

Usar la imagen jaegertracing/all-in-one porque "es la que aparece primero en un tutorial viejo" (de conocimiento desactualizado). Qué pasa: alguien busca "Jaeger docker" y encuentra ejemplos de hace unos años que usan all-in-one. Cómo detectarlo: si tu docker-compose.yml tiene jaegertracing/all-in-one en vez de jaegertracing/jaeger. Cómo corregirlo: all-in-one corresponde a Jaeger v1, que llegó a su fin de vida el 31 de diciembre de 2025 — el contenedor todavía arranca (por eso el error es fácil de no notar), pero imprime una advertencia explícita de fin de vida. Esta lección usa jaegertracing/jaeger (v2) exactamente para evitar construir sobre una base descontinuada.

Esperar ver la traza con error también en dynamodb al consultar api/services sobre esa traza específica (de asumir que todo span planeado siempre se abre). Qué pasa: alguien busca la traza 64441cce... en la vista de DynamoDB y no la encuentra, y asume que algo falló en el script. Cómo detectarlo: si tu duda es "¿por qué la traza de error no aparece cuando filtro por servicio dynamodb?". Cómo corregirlo: es el comportamiento correcto, no un error — trace_malformed_manifest() nunca abre un span dynamodb-put-item, exactamente porque el flujo real nunca llega a intentar la escritura cuando la validación falla antes. Que esa traza no aparezca en el servicio dynamodb es, en sí, la evidencia de dónde se cortó el flujo.

Comparar el traceID de esta lección con el que obtengas al correr el script tú mismo, y asumir que algo está mal si no coinciden (de no distinguir lo literal de lo variable). Qué pasa: alguien corre el script, obtiene un traceID distinto al de esta lección, y piensa que hizo algo incorrecto. Cómo detectarlo: si tu preocupación es que el traceID no es idéntico dígito por dígito al de esta lección. Cómo corregirlo: el traceID lo genera Jaeger (o el SDK, según la configuración) en el momento de la ingesta —es, por diseño, aleatorio, igual que un timestamp—. Lo que es literal y debe coincidir exactamente es la estructura: dos trazas, la primera con dos spans en ERROR, la segunda con tres spans en OK, los mismos nombres de servicio, los mismos atributos, el mismo request.id.


Ejercicios

Ejercicio 1 — Explica, usando el código del script, por qué trace_malformed_manifest() llama a fn_span.record_exception(error) antes de fn_span.set_status(...), y no al revés. ¿Cambiaría algo funcionalmente si se invirtiera el orden?

Ver solución

Funcionalmente, el orden entre estas dos llamadas específicas no cambia el resultado final —ambas son operaciones independientes sobre el mismo span, y ninguna depende del resultado de la otra—. Lo que sí importa es que ambas ocurran dentro del bloque with lambda_tracer.start_as_current_span(...), antes de que el span se cierre — un span ya cerrado no puede recibir ni una excepción registrada ni un cambio de estado. El orden elegido en el script (record_exception primero, set_status después) sigue la convención más común en ejemplos de OpenTelemetry: registrar el evento específico (la excepción, con su mensaje y tipo) antes de declarar el resultado agregado del span (su estado final) — una secuencia lógica de "esto es lo que pasó" seguido de "por eso el resultado es este", aunque el propio SDK no la exija en ese orden.

Ejercicio 2 — Diseña, sin escribir código todavía, una tercera traza para una invocación que sí pasa la validación pero falla al escribir en DynamoDB (por ejemplo, si la tabla estuviera regulando escrituras). ¿Cuántos spans tendría, y cuál de ellos terminaría en ERROR?

Ver solución

Tendría tres spansshipment-manifest-upload, process-shipment-manifest, dynamodb-put-item—, la misma cantidad que la traza exitosa, porque el flujo sí llega a abrir el span de DynamoDB (a diferencia de la traza de error de esta lección, donde validate_manifest() corta el flujo antes). La diferencia estaría en el estado: dynamodb-put-item terminaría en ERROR (con su propia excepción registrada, por ejemplo una regulación de capacidad), y ese estado de error se propagaría hacia arriba —process-shipment-manifest y shipment-manifest-upload también terminarían en ERROR, aunque su propio código interno no haya lanzado la excepción directamente—. Este ejercicio muestra que la cantidad de spans en una traza dice tanto como su estado: una traza de tres spans con el tercero en rojo cuenta una historia distinta a una traza de dos spans, aunque ambas terminen siendo, en agregado, una invocación fallida.

Ejercicio 3 — Explica, en una frase, qué evidencia de esta lección faltaría si solo hubieras corrido la lección 3 (métricas) y la lección 4 (logs), sin esta lección 5. Sé específico sobre qué pregunta quedaría sin respuesta.

Ver solución

Faltaría la evidencia de dónde, dentro del flujo de varios pasos, se detuvo la invocación 17. La lección 3 (métricas) confirma que hubo 3 errores; la lección 4 (logs) confirma cuáles fueron y por qué, con el mensaje exacto de validación —pero ninguna de las dos, por sí sola, muestra la relación temporal y jerárquica entre los pasos del flujo: que la subida a S3 sí ocurrió, que el Lambda sí empezó a procesar, y que el intento de escritura a DynamoDB nunca llegó a abrirse. Esa ausencia —un tercer paso que simplemente no está, en vez de estar y haber fallado— es exactamente el tipo de evidencia que solo una traza puede mostrar con precisión, la pregunta que la lección 2 asignó específicamente a este tercer pilar.


Resumen y siguiente paso

Esta lección corrió, de punta a punta, el tercer pilar de observabilidad de este módulo: un contenedor Jaeger v2 real (jaegertracing/jaeger:2.20.0, nunca all-in-one, descontinuada), un script de instrumentación con el SDK de OpenTelemetry Python (1.44.0) que envió dos trazas reales por OTLP/HTTP, y una confirmación, vía la API de Jaeger, de que ambas llegaron con la estructura exacta que el script definió: una traza de tres spans, todos OK (el camino exitoso), y una traza de dos spans, ambos ERROR (la invocación 17, la misma que la lección 4 ya identificó por requestId), sin ningún span de DynamoDB — la prueba visual de que el flujo se cortó antes de cualquier intento de escritura.

Antes de avanzar deberías poder: explicar por qué esta guía usa jaegertracing/jaeger y no all-in-one; explicar por qué la traza de error tiene dos spans en vez de tres; y conectar el request.id de esta lección con el requestId que la lección 4 extrajo de los logs.

La lección 6 extiende el mismo observability/docker-compose.yml que esta lección creó, agregando Prometheus y Grafana — el stack que el mercado pide con más frecuencia que CloudWatch nativo, según la evidencia que esa lección va a citar.

Recursos

  1. Jaeger — Getting Started — la guía oficial de Jaeger v2, incluida la advertencia sobre all-in-one.
  2. GitHub — jaegertracing/jaeger — el repositorio oficial, fuente de la versión 2.20.0 usada en esta lección.
  3. OpenTelemetry — Python — la documentación oficial del SDK que instrument_manifest_flow.py usa.
  4. LocalStack Docs — X-Ray — confirmación de que X-Ray requiere el plan Ultimate, la razón exacta por la que esta lección no lo ejecuta.
  5. Este mismo repositorio, Módulo 3, lección 4 (04-hands-on-real-logs-with-jq.md) — el requestId c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58 que esta lección sigue con una traza completa.