Logging contextual en Rust: Integración de tracing con OpenTelemetry para aplicaciones async con Tokio y Axum

Logging contextual en Rust: Integración de tracing con OpenTelemetry para aplicaciones async (Tokio + Axum)

La observabilidad en aplicaciones asíncronas de Rust es un desafío único. A diferencia de lenguajes con runtime más maduros, Rust exige una instrumentación explícita para correlacionar eventos en flujos concurrentes. Sin logging contextual, depurar problemas de latencia o errores en entornos con Tokio y Axum se convierte en una tarea casi imposible, donde los logs pierden su utilidad al carecer de relación con el contexto de ejecución.

Este artículo te guía paso a paso en la implementación de tracing como capa de abstracción sobre OpenTelemetry, desde la configuración inicial hasta la integración en un proyecto real con Tokio y Axum. El objetivo es claro: lograr una observabilidad robusta, medible y escalable para tus aplicaciones backend.

Preparación del entorno: Dependencias y configuración inicial

El primer paso es configurar el entorno de desarrollo con las dependencias necesarias. Para este proyecto, necesitarás:

  • tracing y tracing-subscriber para la instrumentación de logs contextuales.
  • opentelemetry y sus crates relacionados para la exportación de trazas.
  • tokio como runtime asíncrono.
  • axum para el framework web.

Añade las siguientes dependencias a tu Cargo.toml:

[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
opentelemetry = { version = "0.20", features = ["rt-tokio", "trace"] }
opentelemetry-otlp = { version = "0.13", features = ["trace"] }
tokio = { version = "1.0", features = ["full"] }
axum = "0.7"

Configuración de tracing-subscriber

El crate tracing-subscriber es el encargado de recopilar y formatear los eventos de tracing. Para habiltar el logging contextual, configúralo de la siguiente manera:

use tracing_subscriber::{fmt, EnvFilter, prelude::*};

fn init_tracing() {
    tracing_subscriber::registry()
        .with(EnvFilter::from_default_env())
        .with(fmt::Layer::default().json())
        .init();
}

Este código inicializa un suscriptor que:

  • Filtra los eventos según el nivel de log configurado en el entorno (RUST_LOG).
  • Formatea los logs en JSON para facilitar su procesamiento posterior.

Integración con OpenTelemetry: Exportación de trazas

OpenTelemetry es el estándar de facto para la observabilidad en sistemas distribuidos. Su integración con Rust permite exportar trazas a backends como Jaeger, Zipkin o Lescopr. Para ello, necesitarás configurar un exporter OTLP (OpenTelemetry Protocol).

Configuración del exporter OTLP

Añade el siguiente código para inicializar el exporter y vincularlo con tracing:

use opentelemetry::{
    global,
    sdk::trace as sdktrace,
    trace::TracerProvider as _,
};
use opentelemetry_otlp::WithExportConfig;
use std::time::Duration;

fn init_opentelemetry() -> sdktrace::TracerProvider {
    let exporter = opentelemetry_otlp::new_exporter()
        .tonic()
        .with_endpoint("http://localhost:4317")
        .with_timeout(Duration::from_secs(5));

    sdktrace::TracerProvider::builder()
        .with_batch_exporter(exporter)
        .build()
}

Este fragmento:

  • Crea un exporter OTLP que envía trazas a un endpoint local (puedes reemplazarlo por el de Lescopr o cualquier otro backend compatible).
  • Configura un TracerProvider con un exporter por lotes para optimizar el envío de trazas.

Vinculación con tracing

Para que tracing utilice OpenTelemetry como backend, necesitas un layer de OpenTelemetry. Usa el crate tracing-opentelemetry:

[dependencies]
tracing-opentelemetry = "0.20"

Luego, actualiza la función init_tracing:

use tracing_opentelemetry::layer::OpenTelemetryLayer;
use opentelemetry::sdk::trace::TracerProvider;

fn init_tracing(tracer_provider: TracerProvider) {
    let opentelemetry_layer = OpenTelemetryLayer::new(tracer_provider);
    
    tracing_subscriber::registry()
        .with(EnvFilter::from_default_env())
        .with(opentelemetry_layer)
        .with(fmt::Layer::default().json())
        .init();
}

Instrumentación de Axum: Logging contextual en handlers

Axum es un framework web minimalista construido sobre Tokio. Para integrar logging contextual en tus handlers, necesitas propagar el contexto de tracing en cada petición HTTP.

Middleware para propagación de contexto

Crea un middleware que extraiga el contexto de tracing de los headers de la petición y lo inyecte en el contexto de Tokio:

use axum::{
    extract::Request,
    http::header,
    middleware::Next,
    response::Response,
};
use opentelemetry::{
    global,
    trace::Tracer,
};
use tracing::Span;

async fn tracing_middleware<B>(request: Request<B>, next: Next<B>) -> Response {
    let tracer = global::tracer("axum-tracer");
    let span = tracer.start("http-request");
    
    // Extraer el contexto de tracing de los headers (ej: traceparent)
    let headers = request.headers();
    if let Some(traceparent) = headers.get(header::TRACEPARENT) {
        // Aquí podrías parsear el traceparent y propagarlo
        // Para simplificar, asumimos que el contexto ya está en el span
    }
    
    // Inyectar el span en el contexto de Tokio
    let _enter = span.enter();
    
    let response = next.run(request).await;
    span.end();
    response
}

Uso en handlers

Ahora, en cualquier handler de Axum, puedes acceder al contexto de tracing y registrar eventos contextuales:

use axum::{Json, extract::Path};
use serde::Serialize;

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}

async fn get_user(Path(user_id): Path<u64>) -> Json<User> {
    tracing::info!(user_id, "Fetching user");
    
    // Simular una operación asíncrona
    tokio::time::sleep(Duration::from_millis(100)).await;
    
    tracing::debug!(user_id, "User fetched successfully");
    
    Json(User {
        id: user_id,
        name: "Alice".to_string(),
    })
}

Este handler:

  • Registra un evento de log con el user_id como campo estructurado.
  • Incluye un sleep para simular una operación asíncrona, donde el contexto de tracing se mantiene.

Validación y optimización: Métricas clave

Una vez implementado el logging contextual, es fundamental validar que las trazas se están generando y exportando correctamente. Aquí hay algunos puntos clave a verificar:

  • Correlación de trazas: Asegúrate de que cada petición HTTP tenga un trace_id único y que los spans hijos estén correctamente anidados.
  • Latencia: Mide el tiempo de ejecución de cada span para identificar cuellos de botella. OpenTelemetry permite exportar métricas de latencia junto con las trazas.
  • Errores: Usa tracing::error! para registrar errores y verifica que se propaguen correctamente en el backend de observabilidad.

Ejemplo de métricas con OpenTelemetry

Puedes extender la instrumentación para incluir métricas de rendimiento:

use opentelemetry::{
    global,
    metrics::Meter,
};

fn init_metrics() {
    let meter = global::meter("axum-metrics");
    let request_counter = meter.u64_counter("http_requests").init();
    let request_latency = meter.f64_histogram("http_request_latency").init();
    
    // Usar en handlers:
    // request_counter.add(1, &[KeyValue::new("route", "/users")]);
    // request_latency.record(latency, &[KeyValue::new("route", "/users")]);
}

Despliegue y monitorización continua

El despliegue de una aplicación con logging contextual requiere configurar el backend de observabilidad para recibir las trazas. Lescopr ofrece una solución lista para producción que centraliza trazas, logs y métricas en un solo lugar, con dashboards preconfigurados para Rust y Tokio.

Pasos para el despliegue

  1. Configurar el endpoint de OpenTelemetry: Reemplaza el endpoint local en init_opentelemetry por el de Lescopr o tu backend preferido.
  2. Validar la exportación de trazas: Usa herramientas como otel-cli para verificar que las trazas se están enviando correctamente.
  3. Configurar alertas: Establece alertas basadas en métricas como latencia o tasa de errores para reaccionar rápidamente a incidentes.

Casos de uso avanzados: Propagación de contexto en microservicios

En arquitecturas de microservicios, la propagación de contexto es crítica para correlacionar trazas entre servicios. OpenTelemetry soporta la propagación de contexto a través de headers HTTP estándar como traceparent y tracestate.

Ejemplo de propagación en peticiones HTTP

Si tu aplicación en Rust realiza peticiones a otros servicios, puedes propagar el contexto de tracing de la siguiente manera:

use reqwest::Client;
use opentelemetry::global;
use opentelemetry_http::HeaderInjector;

async fn call_external_service() {
    let client = Client::new();
    let tracer = global::tracer("external-service");
    let mut span = tracer.start("external-call");
    
    // Inyectar el contexto en los headers de la petición
    let mut headers = reqwest::header::HeaderMap::new();
    let mut injector = HeaderInjector(headers);
    global::get_text_map_propagator(|propagator| {
        propagator.inject_context(&span.context(), &mut injector);
    });
    
    let response = client
        .get("https://api.example.com/data")
        .headers(headers)
        .send()
        .await
        .unwrap();
    
    span.end();
}

Conclusión

La implementación de logging contextual en Rust con tracing y OpenTelemetry es un proceso que requiere una instrumentación cuidadosa, especialmente en entornos asíncronos con Tokio y Axum. Sin embargo, los beneficios en términos de observabilidad son inmensos: desde la depuración de problemas complejos hasta la optimización del rendimiento de tus aplicaciones.

Para profundizar, la documentación de Lescopr detalla la implementación paso a paso, incluyendo ejemplos avanzados de propagación de contexto y configuración de alertas. Con Lescopr, puedes centralizar todas tus trazas, logs y métricas en un solo lugar, obteniendo una visión unificada de tu sistema distribuido.