El reporte siempre suena igual: “la aplicación a veces va lenta.” No en cada solicitud, no para cada usuario, y no es reproducible a voluntad — lo que significa que las sentencias print y un depurador adjunto a tu máquina local son inútiles, porque la solicitud lenta ya ocurrió en una réplica de producción que no estabas mirando, y para cuando la estás buscando, el momento ya pasó. Los logs ayudan un poco, si la solicitud por suerte pasó por un camino de código que registra algo, pero los logs de un handler de FastAPI, un worker de Celery y un connection pool de base de datos son tres flujos separados sin hilo conductor que los una a esa única solicitud lenta.
Ese es el problema concreto que resuelve el trazado distribuido, y es más estrecho y específico de lo que el término “observabilidad” como buzzword sugiere. Un trace es la línea de tiempo causal de una sola solicitud, unida a través de cada servicio y salto que tocó, con duraciones reales adjuntas a cada paso. OpenTelemetry es el estándar neutral de vendor para producir esa línea de tiempo — se instrumenta una vez, se exporta a cualquier lado (Jaeger, Tempo, Honeycomb, Datadog) — y la parte que requiere verdadero cuidado de ingeniería no es instalar la librería, sino asegurarse de que el contexto del trace sobreviva cada límite que una solicitud cruza, especialmente el que hay entre un handler de API síncrono y una tarea asíncrona en segundo plano.
Aprenderás:
- La diferencia concreta entre una línea de log, una métrica y un span de trace, y por qué necesitas los tres, no solo uno
- Cómo encajan realmente el modelo de trace, span y contexto de OpenTelemetry
- Cómo auto-instrumentar una aplicación FastAPI y obtener spans para cada solicitud con unas pocas líneas de configuración
- Por qué el contexto del trace se rompe por defecto en un límite de tarea Celery, y cómo propagarlo manualmente para que el span del worker se anide correctamente bajo la solicitud que lo originó
- Cómo combinar el logging estructurado con trace IDs para que cada línea de log esté a un clic de distancia de su línea de tiempo completa
- Cómo funciona realmente el sampling, y por qué “trazar todo” es una decisión de la que te arrepentirás a escala
Tabla de Contenidos
- Los Conceptos Básicos
- La Arquitectura Completa
- Las Capas Fundamentales Explicadas
- Recorrido de Extremo a Extremo
- Casos Especiales
- Desafíos de Escalado y Producción
- Ejemplos de Código
- Errores Comunes
- Buenas Prácticas en Producción
Los Conceptos Básicos
Los Logs, las Métricas y los Traces Son Herramientas Diferentes
Un log es un evento discreto con un mensaje y algo de contexto, útil para saber “qué pasó” en un punto específico del tiempo. Una métrica es un número agregado a lo largo del tiempo — cantidad de solicitudes, latencia p99, tasa de errores — útil para ver “¿está el sistema saludable ahora mismo?” de un vistazo, pero descarta el detalle por solicitud. Un trace no es ninguno de los dos — es la línea de tiempo conectada de una solicitud específica a medida que se mueve por cada servicio, función y llamada de I/O que toca, con una duración adjunta a cada paso. Las métricas te dicen que algo es lento, los traces te dicen dónde, y los logs estructurados correlacionados con ese mismo trace te dicen por qué en el paso específico que fue lento.
Por Qué Esto Importa Específicamente para las APIs Python
- El trabajo asíncrono y en segundo plano fragmenta la línea de tiempo por defecto. Un handler de FastAPI que dispara una tarea Celery, o que aguarda una llamada HTTP a otro servicio, divide una solicitud lógica en múltiples contextos de ejecución físicos — sin propagación explícita, nada los une de vuelta.
- “A veces va lenta” es irreproducible por construcción. No puedes adjuntar un depurador a una réplica de producción después del hecho. Un trace capturado en el momento es el único artefacto que sigue existiendo una vez que la solicitud terminó.
- Múltiples servicios significan múltiples flujos de logs. Hacer grep en varios archivos de log buscando un timestamp coincidente es una mala manera de reconstruir el camino de una solicitud — un trace ID compartido no lo es.
La Arquitectura Completa
cliente
│ traceparent: 00-<trace_id>-<span_id>-01
▼
┌─────────────┐ span: http.request ┌───────────────┐
│ FastAPI │──────────────────────────►│ span: db.query │
│ (root span) │ └───────────────┘
└──────┬───────┘
│ task.delay() — trace context injected into task headers
▼
┌─────────────┐
│ Celery worker │ span: celery.task (child of the FastAPI root span)
└─────────────┘
│
▼
OTLP exporter → Collector → Jaeger / Tempo / vendor backend
El principio rector: un trace es un árbol de spans, cada span tiene exactamente un padre (excepto el root), y lo que hace que el árbol esté conectado a través de los límites de proceso es un pequeño fragmento de contexto — un trace ID y el span ID actual — llevado explícitamente a través de cada límite que no sea una llamada en proceso simple. HTTP lo lleva en el header traceparent (el estándar W3C Trace Context); una cola de mensajes como Celery no tiene ese header integrado, así que tu código lo inyecta y extrae manualmente.
Las Capas Fundamentales Explicadas
1. Traces, Spans y Contexto
Qué es: Un span representa una unidad de trabajo — una solicitud HTTP, una consulta a base de datos, una llamada a función — con un nombre, un tiempo de inicio y fin, atributos (metadatos en clave-valor) y una referencia a su span padre. Un trace es el conjunto completo de spans que comparten un trace ID. El contexto es el pequeño paquete (trace ID + span ID actual + trace flags) propagado para que un nuevo span sepa a qué trace y padre adjuntarse.
Por qué importa: Nada de “a qué trace pertenece este span” es automático a través de un límite de proceso o hilo — se lleva explícitamente, ya sea en proceso (a través de contextvars de Python) o por la red (el header HTTP traceparent, o headers inyectados manualmente para todo lo que no sea HTTP).
Consejo de producción: No intentes construir esto desde cero — el SDK Python de OpenTelemetry maneja la propagación de contextvars automáticamente para cualquier cosa que use su instrumentación, incluyendo a través de async/await. El lugar donde tienes que pensarlo explícitamente es cualquier límite que el SDK aún no conoce, que es exactamente el caso de Celery descrito abajo.
2. Auto-instrumentación de FastAPI
Qué es: opentelemetry-instrumentation-fastapi envuelve el manejo de solicitudes de tu aplicación para que cada solicitud entrante obtenga automáticamente un span raíz, con atributos para el método HTTP, la ruta y el código de estado — sin necesidad de crear spans manualmente para el caso básico.
Por qué importa: Esto te da trazado real a nivel de solicitud de forma gratuita — cada solicitud muestra su duración real y su ruta, lo que a menudo es suficiente por sí solo para descubrir que /reports/export es el endpoint lento antes de que hayas escrito un solo span manual.
Consejo de producción: La auto-instrumentación cubre el límite solicitud/respuesta. No sabrá automáticamente que “el paso 3 dentro de este handler, que llama a una API de terceros” vale la pena su propio span — para eso, envuelve el código específico que parece lento en un span manual para que aparezca como su propio segmento nombrado en el trace, en lugar de quedar absorbido en la duración total del padre.
3. Logging Estructurado Correlacionado con Trace IDs
Qué es: El logging estructurado significa emitir logs como datos clave-valor (típicamente JSON) en lugar de cadenas de texto libre — logger.info("order created", order_id=123) en lugar de una oración interpolada. Correlacionarlo con el trazado significa inyectar el trace ID y el span ID activos en cada registro de log automáticamente.
Por qué importa: Un log estructurado con un trace ID adjunto te permite ir de “esta línea de log parece sospechosa” a “muéstrame la solicitud completa en la que ocurrió esto” con un solo clic, en cualquier backend que vincule logs a traces (Grafana con Loki+Tempo, Datadog, Honeycomb). Sin eso, esa línea de log es un punto aislado sin camino de regreso a su contexto.
Consejo de producción: Usa structlog o python-json-logger con un procesador que extraiga el trace ID y el span ID del span actual del contexto de OpenTelemetry en cada llamada de log — esto son unas pocas líneas de configuración que se pagan solas la primera vez que estás depurando algo que abarca tres servicios.
4. Propagación de Spans a Través del Límite de una Tarea Celery
Qué es: Las tareas Celery se ejecutan en un proceso completamente separado (el worker), recibiendo solo los datos que fueron serializados en el mensaje de la tarea — por defecto, solo los argumentos de la función, sin ningún contexto de trace adjunto. El span de una tarea, si lo creas, comienza como un trace raíz nuevo y desconectado por defecto, sin ningún vínculo con la solicitud que lo encoló.
Por qué importa: Esta es la brecha más común en la configuración de trazado de un servicio Python — el lado de la API parece completamente instrumentado, el lado del worker parece completamente instrumentado, pero la interfaz del trace muestra dos traces no relacionados en lugar de una línea de tiempo conectada, porque nada llevó el contexto a través de la cola.
Consejo de producción: Inyecta el contexto del trace actual en los headers de la tarea cuando llamas a .delay()/.apply_async(), y extráelo al inicio de la tarea antes de iniciar su span — opentelemetry-instrumentation-celery maneja una buena parte de esto automáticamente a través de los hooks de señales de Celery, pero vale la pena entender la inyección/extracción manual que se muestra a continuación, ya que necesitarás el mismo patrón para cualquier otro límite no HTTP (Kafka, SQS, una cola Redis pura) sin que haya una librería que lo haga por ti. Qué tan bien encaja este patrón de propagación también depende de qué broker y modelo de worker estés usando — consulta esta comparación de opciones de colas de tareas Python para ver cómo se compara la arquitectura de Celery con las alternativas en este aspecto.
5. Exportadores y el Collector
Qué es: El SDK produce spans; un exportador los envía a algún lugar. Puedes exportar directamente a un backend, o — el patrón más común — exportar a través de OTLP (OpenTelemetry Protocol) a un Collector, un proceso separado que agrupa en lotes, filtra y reenvía a uno o más backends reales (Jaeger, Tempo, el endpoint de ingesta de un vendor).
Por qué importa: Exportar directamente desde cada instancia acopla cada servicio al formato de ingesta y los límites de tasa de ese backend. Enrutar a través de un Collector los desacopla — puedes cambiar backends, agregar reglas de sampling, o enviar a dos destinos, todo en la configuración del Collector, sin tocar el código de la aplicación.
Consejo de producción: Empieza con un Collector local incluso en desarrollo — un contenedor Docker, y tu config de exportación (OTEL_EXPORTER_OTLP_ENDPOINT) nunca tiene que cambiar entre local, staging y producción; solo cambia la config de salida del propio Collector.
Recorrido de Extremo a Extremo
Traza una única solicitud lenta desde un endpoint POST /reports que encola una tarea Celery:
- El cliente envía
POST /reports. No hay headertraceparentpresente, así que la instrumentación de FastAPI inicia un trace nuevo y un span raíz, generando un trace ID fresco. - El handler valida la solicitud (un span hijo rápido, unos pocos milisegundos) y llama a
generate_report.delay(report_id). - Antes de que el mensaje sea enviado al broker, el contexto del trace actual (trace ID + ID del span de este handler) se inyecta en los headers del mensaje Celery.
- El handler devuelve
202 Acceptedinmediatamente — el span raíz para la solicitud HTTP termina aquí, pero el trace no está terminado, ya que la tarea encolada aún no se ha ejecutado. - Un worker de Celery recoge la tarea, posiblemente segundos después en una máquina completamente diferente. Antes de ejecutar la función de la tarea, extrae el contexto del trace de los headers del mensaje e inicia un nuevo span como hijo del span de la solicitud original — mismo trace ID, correctamente anidado.
- La tarea se ejecuta, incluyendo una consulta a base de datos que toma 4 de los 4.3 segundos totales de la tarea — visible como su propio span hijo con una duración que lo identifica inmediatamente como el cuello de botella.
- El backend del trace renderiza una cascada conectada: el handler HTTP rápido, luego (después de una brecha de cola que también puedes medir) el span de la tarea del worker, y luego la consulta lenta anidada dentro de él — el salto exacto que fue lento, con un número adjunto, en lugar de una suposición.
Casos Especiales
Instrumentación de endpoints asíncronos. Los handlers async def de FastAPI funcionan correctamente con la auto-instrumentación porque el SDK propaga el contexto a través de contextvars, que asyncio respeta a través de los límites de await dentro de la misma tarea. Necesitas manejo especial para las llamadas asyncio.create_task() que generan tareas genuinamente separadas, ya que una nueva tarea no hereda el contexto del padre a menos que se pase explícitamente.
Tareas en segundo plano con BackgroundTasks. Las BackgroundTasks integradas de FastAPI se ejecutan en proceso después de que se envía la respuesta, todavía dentro del mismo scope de contextvars que la solicitud — el contexto se propaga automáticamente aquí, a diferencia del caso de Celery, porque no hay ningún límite de proceso o serialización involucrado.
Tareas fire-and-forget sin llamador esperando. Algunas tareas Celery son encoladas por un scheduler tipo cron, no por una solicitud en vivo — no hay ninguna “solicitud padre” significativa a la que vincular. Iniciar un trace raíz nuevo para estas es correcto; un span padre artificial solo agrega ruido.
Llamadas HTTP a terceros. opentelemetry-instrumentation-requests (o httpx) auto-instrumenta las llamadas salientes e inyecta el header traceparent en la solicitud saliente automáticamente, así que si el servicio downstream también está instrumentado, el trace continúa a través de ese límite también.
Desafíos de Escalado y Producción
Sampling: “trazar todo” no sobrevive el tráfico real. A un volumen significativo, exportar un span por cada solicitud genera un volumen de datos y un costo enormes por muchas solicitudes aburridas, rápidas y exitosas que nunca mirarás. El head-based sampling (decidir al inicio del trace, por ejemplo el 10% de las solicitudes) es simple pero puede perderse el raro caso lento que realmente te importa. El tail-based sampling (decidir después de que el trace se completa, conservando todo lo que sea lento o con errores independientemente del sample rate) necesita un Collector configurado para hacer buffer y decidir, pero es el patrón que realmente conserva los traces que necesitas.
Cardinalidad en los atributos de span. Adjuntar un ID de usuario sin procesar o un cuerpo de solicitud completo como atributo de span parece conveniente hasta que el costo o el índice de tu backend explota por valores únicos efectivamente ilimitados. Mantén los datos de alta cardinalidad en logs estructurados y reserva los atributos de span para un conjunto acotado de dimensiones por las que realmente filtrarás o agruparás.
Desfase de reloj entre servicios. La vista de cascada de un trace depende de que el reloj de cada servicio esté razonablemente sincronizado — el drift de NTP puede hacer que un span hijo parezca comenzar antes que su padre, lo cual es confuso pero no es en realidad un bug de trazado.
El Collector como nueva dependencia operacional. Una vez que está en el camino de telemetría, su disponibilidad y comportamiento de backpressure se convierten en algo a monitorear — configúralo para descartar spans bajo sobrecarga en lugar de bloquear o hacer crash a la aplicación de la que está recopilando.
Ejemplos de Código
Instrumentar una aplicación FastAPI con trazado automático y exportación OTLP:
# pip install opentelemetry-distro opentelemetry-exporter-otlp \
# opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-celery
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
resource = Resource.create({"service.name": "reports-api"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317"))
)
trace.set_tracer_provider(provider)
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
tracer = trace.get_tracer(__name__)
@app.post("/reports")
async def create_report(report_id: str):
with tracer.start_as_current_span("validate-report-request"):
pass # validation logic here
generate_report.delay(report_id)
return {"status": "queued"}
Propagación manual del contexto del trace a través del límite Celery:
from celery import Celery
from celery.signals import before_task_publish, task_prerun
from opentelemetry import trace, context
from opentelemetry.propagate import inject, extract
celery_app = Celery("worker", broker="redis://localhost:6379/0")
tracer = trace.get_tracer(__name__)
@before_task_publish.connect
def inject_trace_context(headers=None, **kwargs):
# Runs in the caller (the FastAPI process) — attach the current
# trace context onto the outgoing task's message headers.
if headers is None:
return
inject(headers)
@celery_app.task(name="generate_report")
def generate_report(report_id: str):
# This task's own tracing setup extracts the injected context
# from the task's request headers (wired via task_prerun in a
# real setup, or automatically by opentelemetry-instrumentation-celery)
# and starts this span as a child of the original request's span.
with tracer.start_as_current_span("generate_report") as span:
span.set_attribute("report.id", report_id)
_run_slow_query(report_id)
Correlación de logs estructurados con el trace activo usando structlog:
import structlog
from opentelemetry import trace
def add_trace_context(logger, method_name, event_dict):
span = trace.get_current_span()
ctx = span.get_span_context()
if ctx.is_valid:
event_dict["trace_id"] = format(ctx.trace_id, "032x")
event_dict["span_id"] = format(ctx.span_id, "016x")
return event_dict
structlog.configure(
processors=[add_trace_context, structlog.processors.JSONRenderer()]
)
log = structlog.get_logger()
log.info("report_generated", report_id="r_123")
# {"event": "report_generated", "report_id": "r_123",
# "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", ...}
Una config mínima del Collector para desarrollo local:
receivers:
otlp:
protocols:
grpc:
http:
exporters:
otlphttp/jaeger:
endpoint: http://jaeger:4318
processors:
batch:
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/jaeger]
Errores Comunes
Error: asumir que las tareas Celery heredan el contexto del trace automáticamente. Sin inyección/extracción explícita, cada tarea inicia un trace raíz desconectado, y la interfaz muestra fragmentos no relacionados en lugar de una línea de tiempo. Solución: usa opentelemetry-instrumentation-celery o el patrón manual de inject/extract descrito arriba.
Error: poner datos de alta cardinalidad (IDs de usuario sin procesar, payloads completos) en atributos de span. Esto hace explotar el costo de ingesta y el tamaño del índice en la mayoría de los backends. Solución: mantén esos datos en logs estructurados correlacionados por trace ID; mantén los atributos de span en un conjunto acotado y filtrable.
Error: trazar cada solicitud al 100% del sample rate en producción. Es un volumen de datos enorme y principalmente aburrido que cuesta dinero real. Solución: usa tail-based sampling para que las solicitudes lentas y con errores siempre se conserven, mientras que las rutinarias y rápidas se muestrean a la baja.
Error: logs y traces que viven en dos sistemas desconectados sin ID compartido. Terminas correlacionando manualmente revisando timestamps en dos interfaces. Solución: inyecta el trace ID y el span ID en cada línea de log estructurado.
Error: instrumentar la API pero nunca el worker (o viceversa). La mitad de un trace sigue siendo un trace roto — puedes ver que la solicitud fue lenta pero no por qué, o por qué pero no a qué solicitud pertenecía. Solución: instrumenta cada proceso que toca el ciclo de vida de la solicitud.
Buenas Prácticas en Producción
- Instrumenta el límite solicitud/respuesta primero con auto-instrumentación — el paso de mayor valor y menor esfuerzo, y desde donde parten la mayoría de las investigaciones de “por qué a veces va lento”.
- Trata cada límite no HTTP (colas, pub/sub, cron) como un punto de propagación manual hasta que se demuestre lo contrario.
- Correlaciona los logs estructurados con los trace IDs desde el primer día, no después de la primera sesión de depuración cross-service dolorosa que hace evidente la brecha.
- Usa tail-based sampling una vez que superes volúmenes de tráfico de juguete — el sampling completo no escala ni financieramente ni en la práctica.
- Enruta a través de un Collector en lugar de exportar directamente desde cada servicio. Es la juntura que te permite cambiar backends o política de sampling sin redesplegar el código de la aplicación.
Conclusión
Un trace es tan útil como los límites que sobrevive cruzar, y en un servicio Python típico el límite que se rompe primero es el que hay entre un handler de solicitud síncrono y cualquier trabajo asíncrono que dispara — una tarea Celery, una cola, una llamada fire-and-forget. La auto-instrumentación obtiene el 80% fácil de forma gratuita; el 20% restante, propagar el contexto exactamente a través de esos límites, es donde “tenemos trazado” se convierte en “podemos encontrar el salto lento en una solicitud que nadie puede reproducir.”
Si tu configuración actual tiene FastAPI instrumentado y Celery ejecutando traces desconectados a su lado, esa brecha — no un dashboard faltante, no un exportador faltante — es casi con certeza la razón por la que la interfaz del trace no ha respondido tus últimas preguntas de “por qué fue lento esto.”

