tracing: instrumentación estructurada que sí sigue a la tarea
Si la pila dejó de contar la historia, hace falta que cada tarea la cuente por sí misma. tracing introduce dos primitivas —el span, un periodo de tiempo con contexto, y el event, un instante— y el atributo instrument, que envuelve una async fn en un span que se re-entra en cada reanudación aunque la tarea salte de hilo. Frente al println, tracing emite datos estructurados con la ancestralidad de spans que reconstruye el árbol de llamadas que la pila ya no guarda.
La lección anterior dejó un vacío: si la pila ya no cuenta la historia y el println! se entrelaza en un caos ilegible, ¿cómo sabe cada tarea quién es y qué está haciendo? La respuesta de Rust async es tracing, y su idea central es sutil. En lugar de imprimir líneas planas de texto, emites datos estructurados organizados por dos primitivas: el span, que representa un periodo —el tiempo que una tarea pasa atendiendo una petición— y el event, que representa un instante dentro de ese periodo. Los spans se anidan y forman un árbol, y aquí está la magia: ese árbol de spans es exactamente el árbol de llamadas que la pila del hilo dejó de guardar. Cuando cada evento arrastra la cadena de spans que lo contiene —esta petición, dentro de este usuario, dentro de este servicio—, recuperas la ancestralidad que el backtrace perdió, y la recuperas de una forma que sobrevive a la migración de hilos y a las diez mil tareas concurrentes. tracing no es “un logger mejor”: es la reconstrucción de la pila lógica como dato observable.
- Distinguir un span (un periodo con contexto) de un event (un instante), y ver por qué los spans se anidan en un árbol.
- Instrumentar una
async fncon#[instrument]y entender por qué su span se re-entra correctamente en cada reanudación. - Emitir eventos con campos estructurados (
info!,debug!) en lugar de texto plano interpolado. - Razonar por qué
tracingreemplaza alprintln!en async: contexto por tarea, filtrado por niveles y salida legible pese a la concurrencia.
Span y event: el periodo y el instante
tracing parte de dos conceptos que el println! funde en uno. Un event es un suceso puntual —“la consulta devolvió 512 bytes”—, el análogo estructurado de una línea de log. Un span es distinto: modela un tramo de tiempo con un principio y un fin, durante el cual el programa ejecuta una unidad de trabajo. Mientras un span está activo, todo event que se emita queda anotado como hijo suyo, y los spans se anidan: un span “peticion” contiene un span “consulta_db” que contiene los events de esa consulta.
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
use tracing::{info, debug, span, Level};
async fn atender(id: u64) {
let s = span!(Level::INFO, "atender", peticion = id);
let _guard = s.enter(); // el span queda activo mientras viva _guard
debug!("validando la peticion"); // este event es hijo del span atender
let datos = consultar(id).await;
info!(bytes = datos.len(), "consulta lista");
}
Nada de esto hace nada por sí solo: los spans y events se emiten a un subscriber, un componente que decide qué registrar y cómo. Sin subscriber instalado, la instrumentación es casi gratis; con uno, cobra vida. El más común imprime a la consola con formato legible y filtra por la variable RUST_LOG:
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
El guard que devuelve span.enter() marca el span como activo en el hilo actual y lo desactiva al soltarse (Drop). En código síncrono es perfecto; en async es una trampa. Si sostienes ese guard a través de un .await, la tarea se suspende con el span aún “entrado”, pero el hilo sigue corriendo otras tareas —que heredarán tu span por error— y al reanudarte en otro hilo el guard estará en el hilo equivocado. Por eso en async casi nunca se usa enter() a mano: se usa #[instrument] o el adaptador .instrument(), que re-entran el span en cada poll y lo salen en cada suspensión, respetando la migración de hilos.
instrument: el span que respeta las suspensiones
La forma idiomática de instrumentar una función async es el atributo #[instrument], que envuelve todo el cuerpo en un span, captura los argumentos como campos y —esto es lo esencial— gestiona correctamente las suspensiones:
use tracing::{info, instrument};
#[instrument]
async fn atender(id: u64, region: &str) -> usize {
info!("empezando");
let datos = consultar(id).await;
info!(bytes = datos.len(), "consulta lista");
datos.len()
}
#[instrument] genera un span llamado atender con id y region como campos, y en vez de entrar el span de golpe, aplica internamente el adaptador Instrument al Future. El efecto es quirúrgico: el span se re-entra en cada poll de la tarea y se sale en cada Poll::Pending. Así, aunque la tarea se duerma en el .await y se reanude en otro hilo trabajador —recuerda el robo de trabajo del nivel anterior—, cada tramo de ejecución vuelve a activar el span correcto en el hilo correcto. La ancestralidad que la pila perdía en cada suspensión, tracing la reconstruye en cada reanudación.
Cuando llamas a una async fn instrumentada desde otra también instrumentada, sus spans se anidan: el subscriber ve atender conteniendo a consultar, y cada event lleva la lista completa de spans padres. Ese es el árbol de llamadas lógico —el que el backtrace no podía darte— materializado como dato.
#[instrument(skip(cliente), fields(usuario = %ctx.usuario_id))]
async fn consultar(cliente: &Cliente, ctx: &Contexto, id: u64) -> Vec<u8> {
// skip(cliente): no intentes formatear un Cliente enorme como campo.
// fields(...): añade campos calculados que los argumentos no dan.
cliente.get(id).await
}
El skip es indispensable: sin él, #[instrument] intentaría registrar todos los argumentos con su Debug, incluido un cliente de red de miles de bytes. fields(...) deja añadir contexto derivado —el % pide Display, el ? pediría Debug—.
#[instrument] cubre las funciones que escribes, pero a veces necesitas instrumentar un Future que no declaras tú —el que pasas a tokio::spawn, un stream, un bloque async suelto—. Para eso está el adaptador .instrument(span) del trait Instrument: envuelve cualquier future en un span con la misma garantía de re-entrada en cada poll. De hecho, #[instrument] no es más que azúcar sobre él. La regla práctica: anota con el atributo donde puedas, y usa .instrument() en las fronteras donde solo tienes un future en la mano, como al lanzar una tarea con tokio::spawn(fut.instrument(span)).
flowchart TD R[span peticion] --> A[span atender id 42] A --> C[span consultar usuario 7] C --> E1[event validando] C --> E2[event consulta lista bytes 512] A --> E3[event respondido] style R fill:#cba6f7,color:#11111b style A fill:#89b4fa,color:#11111b style C fill:#89b4fa,color:#11111b style E2 fill:#a6e3a1,color:#11111b
Por qué reemplaza al println en async
La superioridad de tracing sobre println! no es cosmética; resuelve cada uno de los fallos que la lección anterior expuso. Comparemos los dos mundos sobre el mismo problema:
Contexto por tarea
Cada event arrastra la cadena de spans que lo contiene —petición, usuario, servicio—. Sabes qué tarea lo emitió aunque mil corran a la vez sobre ocho hilos. El println! solo te daba un hilo que ya no distingue tareas.
Campos estructurados
info!(bytes = n, id) emite pares clave-valor, no una frase. Un subscriber JSON los vuelca a un sistema que filtra, agrega y busca por campo. println! produce texto que hay que volver a parsear.
Niveles y filtrado
RUST_LOG=info,mi_crate::db=debug ajusta el detalle sin recompilar. Los println! de depuración o los borras o los dejas ensuciando producción.
Sobrevive a la migración
El span se re-entra en cada poll aunque la tarea cambie de hilo. La identidad lógica persiste a través de las suspensiones; el println! con thread id se vuelve ruido en cuanto la tarea salta.
La diferencia de fondo: println! describe lo que pasó en un hilo en un instante, y en async ni el hilo ni el instante identifican nada. tracing describe lo que le pasó a una tarea a lo largo de un periodo, que es justo la unidad de la que async está hecho. Por eso no es un lujo de observabilidad para producción: es la sustitución natural del depurador de pila para el desarrollo diario. Y como el mismo flujo de spans y events alimenta tanto la consola en tu máquina como un colector distribuido en producción, tracing es también el sustrato sobre el que se apoya tokio-console, la herramienta de la próxima lección.
La lección anterior diagnosticó la enfermedad: async reificó el flujo de control en datos, y por eso la pila —que solo sabe de flujo implícito— dejó de contar la historia. tracing es la cura que se sigue de ese diagnóstico con necesidad casi lógica. Si el problema es que la relación “esta operación ocurre dentro de aquella” ya no vive en la pila del hilo, entonces la solución no puede ser mirar mejor la pila: tiene que ser reconstruir esa relación como un dato explícito. Eso es exactamente un span. Un span es la reificación del marco de pila que async disolvió: donde antes “estar dentro de atender” era un hecho físico —tu marco sobre el suyo en la misma pila—, ahora es un hecho declarado, un span hijo de otro span, que persiste como valor aunque la ejecución salte de hilo o se duerma mil veces. Y por eso el detalle de que #[instrument] re-entre el span en cada poll no es un tecnicismo de implementación, sino el corazón del asunto: es lo que hace que la ancestralidad lógica sea invariante frente a la migración de hilos, la misma invariancia que la pila del SO ya no podía ofrecer. Visto así, tracing completa una simetría profunda. Async movió el flujo de control del hardware —pila, registros— al software —la máquina de estados—; tracing mueve la observación de ese flujo del mismo hardware —el backtrace, el thread id— al mismo software —el árbol de spans—. La instrumentación estructurada no compite con el depurador: opera en la capa a la que async trasladó la verdad. Cuando interiorizas que un event sin su cadena de spans es tan inútil en async como un valor sin su tipo, dejas de ver tracing como logging sofisticado y empiezas a verlo como lo que es: el sistema de coordenadas nativo de un mundo donde el flujo de control es dato, y a los datos se los anota con contexto para poder, después, entenderlos.
Un span es un periodo con contexto; un event es un instante dentro de él. Los spans se anidan y forman el árbol de llamadas que la pila ya no guarda. #[instrument] envuelve una async fn en un span, captura sus argumentos como campos y —vía el adaptador Instrument— re-entra el span en cada poll y lo sale en cada suspensión, respetando la migración de hilos; por eso enter() a mano a través de un .await es un error. Los events se emiten con campos estructurados (info!(bytes = n)) a un subscriber que filtra por RUST_LOG y da salida legible o JSON. Reemplaza al println! porque aporta contexto por tarea, filtrado sin recompilar y una identidad que sobrevive al salto de hilo.
- Añade
tracingytracing-subscriber, instala el subscriberfmtconEnvFiltery anota dosasync fnanidadas con#[instrument]. Observa cómo los spans se anidan en la salida. - Sustituye un
println!porinfo!(campo = valor, "mensaje")y compara la salida conRUST_LOG=debugfrente aRUST_LOG=info. - Lanza cincuenta tareas concurrentes que emitan events y comprueba que puedes atribuir cada event a su tarea por la cadena de spans, algo imposible con
println!. - Provoca deliberadamente el error de sostener un
span.enter()a través de un.awaity razona por qué el contexto se “contamina” hacia otras tareas; luego arréglalo con#[instrument]. - Usa
#[instrument(skip(...), fields(...))]para omitir un argumento voluminoso y añadir un campo calculado; explica cuándo usarías%y cuándo?.