¿Cómo funciona el tracing distribuido? ¿Qué es un trace, qué es un span, y cómo se propaga el contexto entre llamadas a servicios?
Un trace es la historia completa de una petición a través de una docena de servicios; un span es un capítulo de esa historia — y un header llamado traceparent es lo que mantiene la historia conectada.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo funciona el tracing distribuido? ¿Qué es un trace, qué es un span, y cómo se propaga el contexto entre llamadas a servicios?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#La pregunta que responde el tracing
Una petición llega a tu API gateway, pasa al servicio de pedidos, que llama al servicio de clientes y al servicio de inventario, y el servicio de inventario llama a una base de datos y a una capa de caché. Todo el proceso tarda 340ms y el cliente dice que lo siente lento. Los logs de cinco servicios distintos, cada uno con sus propios timestamps y sin ningún identificador compartido, no te van a decir cuál de esos siete saltos explica los 340ms. El tracing distribuido existe exactamente para responder esa pregunta: a lo largo de una cadena de llamadas entre servicios, ¿a dónde se fue realmente el tiempo?
#Trace y span, con precisión
Un trace representa el recorrido completo de una petición a través del sistema — cada servicio que tocó, desde el momento en que entró por el borde hasta el momento en que salió la respuesta final. Un trace no es un único registro; es un árbol (o, en general, un grafo dirigido) construido a partir de muchos registros más pequeños.
Un span es uno de esos registros: una única unidad de trabajo con un nombre, una hora de inicio, una duración, un estado (ok o error), un conjunto de atributos clave-valor y, opcionalmente, eventos con marca de tiempo dentro de él (comparables a líneas de log estructuradas asociadas a esa unidad de trabajo). Cada span lleva un span_id propio y único, y un parent_span_id que apunta al span que lo originó — excepto el primer span de un trace, el span raíz, que no tiene padre. Todos los spans del mismo trace comparten un único trace_id.
Reconstruir ese árbol a partir del trace_id más el parent_span_id de cada span es exactamente cómo un visor de trazas dibuja el conocido diagrama en cascada, y con este ejemplo es inmediatamente evidente a dónde se fueron los 180ms: no a los saltos de red, ni a la rama de customer-service, sino a una consulta a base de datos de 95ms enterrada tres niveles adentro de inventory-service.
#Llevar el trace ID a través de un límite de proceso
Dentro de un mismo proceso, propagar el contexto es relativamente sencillo — el mecanismo de almacenamiento local asíncrono o local al hilo de un runtime puede llevar "el span actualmente activo" a través de llamadas asíncronas anidadas sin tener que pasar un parámetro por cada firma de función. La parte difícil es cruzar un límite de proceso, porque el destinatario no comparte memoria en absoluto con el llamador. El único canal disponible es el que ya usan ambos procesos para comunicarse — headers HTTP, metadata de gRPC, o los headers de un mensaje en una cola.
Esto está estandarizado en la especificación W3C Trace Context. Antes de hacer una llamada saliente, un servicio serializa el trace ID y el span ID activos en un header traceparent con la forma 00-<trace-id>-<parent-span-id>-<flags> — por ejemplo 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. El servicio receptor parsea ese header, y cualquier span que cree mientras atiende la petición queda registrado como hijo del span ID que acaba de recibir, aun cuando los dos servicios nunca hayan compartido memoria y ni siquiera estén escritos en el mismo lenguaje.
#Instrumentarlo: OpenTelemetry
OpenTelemetry (OTel) es el estándar independiente de proveedor para producir estos datos: una API y un SDK para crear spans y propagar contexto, más exportadores que envían los spans terminados a un backend como Jaeger, Tempo, Zipkin, o un APM comercial. Las librerías de auto-instrumentación se enganchan a frameworks comunes — servidores y clientes HTTP, drivers de base de datos populares — así que una buena parte de los spans se crean sin que tengas que escribir nada. La instrumentación manual es para los spans que importan específicamente a tu lógica de negocio.
import { trace, context, propagation, SpanStatusCode, SpanKind } from "@opentelemetry/api";const tracer = trace.getTracer("order-service", "1.0.0");async function getOrderSummary(orderId: string) {return tracer.startActiveSpan("get-order-summary", async (span) => {span.setAttribute("order.id", orderId);try {const customer = await callDownstream("customer-service", "/customers/" + orderId);const inventory = await callDownstream("inventory-service", "/reservations/" + orderId);span.setStatus({ code: SpanStatusCode.OK });return { customer, inventory };} catch (err) {span.recordException(err as Error);span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });throw err;} finally {span.end();}});}async function callDownstream(serviceName: string, path: string) {return tracer.startActiveSpan("http-call:" + serviceName,{ kind: SpanKind.CLIENT },async (span) => {const headers: Record<string, string> = {};// Serializa el trace/span ID activo en el header saliente "traceparent"// para que los spans del destinatario se adjunten como hijos de este.propagation.inject(context.active(), headers);try {const res = await fetch("https://" + serviceName + path, { headers });span.setAttribute("http.status_code", res.status);return await res.json();} finally {span.end();}});}
startActiveSpan crea el span y además lo convierte en el "actual" dentro del contexto de ejecución, que es lo que permite que una llamada anidada a callDownstream cree automáticamente su span como hijo de get-order-summary sin ninguna referencia explícita al padre — la maquinaria de propagación de contexto se encarga de eso dentro del proceso. Cruzar hacia el siguiente servicio es el único punto donde hay que actuar explícitamente: propagation.inject es lo que realmente escribe el header traceparent.
#Nadie traza cada petición
A escala significativa, registrar y exportar un span por cada petición suele ser demasiado costoso — en almacenamiento y en el overhead de CPU de la propia instrumentación. El muestreo (sampling) decide qué traces se conservan. El muestreo basado en cabecera (head-based) toma la decisión al comienzo mismo de un trace, antes de saber nada sobre cómo va a resultar — típicamente un porcentaje aleatorio, como "conservar el 1% de los traces", decidido en el span raíz y propagado hacia abajo para que todo el trace se muestree de forma consistente. Es barato y simple, pero una petición rara, costosa o fallida tiene la misma pequeña probabilidad de conservarse que una rutinaria, así que los equipos suelen combinarlo con una regla como "siempre conservar los traces que terminan en error", decidida localmente en cada servicio. El muestreo basado en cola (tail-based) aplaza la decisión hasta que todo el trace se ha ensamblado, lo cual requiere que un colector almacene en memoria todos los spans de un trace hasta que termina — pero permite conservar cada trace que fue lento o falló, sin importar la tasa de muestreo aleatorio, a costa de operar esa infraestructura de buffering.