← Índice

8.4 Lo que vio el servidor

Esta traducción fue asistida por IA y revisada superficialmente. Si encuentras errores o algo no suena natural, escríbeme — lo agradezco.

El trabajo sin informe es buen trabajo, siempre que sea bueno. “Todo va bien” es suficiente respuesta hasta que deja de serlo. Entonces llega una petición que se comportó de forma extraña - y quieres saber qué vio el servidor. Lo vio. Simplemente no lo dijo.

Lo que necesitas

Podrías escribir println!("task added: id={id} title={title}") - y funcionaría. La salida tendría este aspecto:

task added: id=3 title=buy coffee

tracing con una línea de inicialización da:

2026-06-24T10:12:05Z  INFO tq_api::routes: task added id=3 title="buy coffee"

Marca de tiempo, nivel (INFO, WARN, …) y nombre del módulo - gratis, sin formateo manual. Los campos con nombre (id=3, title="buy coffee") se añaden con sintaxis de macro, no concatenando cadenas. tracing-subscriber controla adónde va la salida y en qué forma.

La construcción

Dos líneas en crates/api/Cargo.toml:

tracing = "0.1"
tracing-subscriber = "0.3"

Al principio de main en crates/api/src/main.rs - inicialización y una línea de arranque:

tracing_subscriber::fmt::init();
// ... config, store, router ...
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::info!("listening on 0.0.0.0:3000");
axum::serve(listener, app).await.unwrap();

tracing_subscriber::fmt::init() es una abreviatura para la configuración estándar: salida a stdout, una línea por registro, con marca de tiempo y nombre del módulo.

En crates/api/src/routes.rs, se añaden llamadas a los handlers.

En add - tras una adición exitosa, cuando ya se conoce id:

async fn add(State(state): State<AppState>, Json(req): Json<AddRequest>) -> Result<Json<Task>, ApiError> {
    let mut guard = state.store.lock().unwrap();
    let id = guard.add(req.title.as_str()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
    persistence::save(guard.all(), &state.data_path).unwrap();
    tracing::info!(id = id, title = %req.title, "task added");
    Ok(Json(guard.get(id).unwrap().clone()))
}

id = id - un campo llamado id con el valor de la variable del mismo nombre. title = %req.title - formateado a través de Display (el prefijo %). Sin el prefijo, se usa Debug.

En list - al principio:

async fn list(State(state): State<AppState>) -> Json<Vec<Task>> {
    tracing::info!("list tasks");
    let guard = state.store.lock().unwrap();
    Json(guard.all().to_vec())
}

En get_task y done una petición tiene dos resultados posibles: encontrado - info, no encontrado - warn. El nivel warn significa “algo inesperado, pero gestionado”:

async fn get_task(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<Task>, ApiError> {
    let guard = state.store.lock().unwrap();
    match guard.get(id) {
        Ok(task) => {
            tracing::info!(id = id, "get task");
            Ok(Json(task.clone()))
        }
        Err(_) => {
            tracing::warn!(id = id, "task not found");
            Err(ApiError::NotFound)
        }
    }
}

async fn done(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<Task>, ApiError> {
    let mut guard = state.store.lock().unwrap();
    match guard.get_mut(id) {
        Ok(task) => {
            task.complete();
            let task = task.clone();
            persistence::save(guard.all(), &state.data_path).unwrap();
            tracing::info!(id = id, "task marked done");
            Ok(Json(task))
        }
        Err(_) => {
            tracing::warn!(id = id, "task not found");
            Err(ApiError::NotFound)
        }
    }
}

El resultado

$ make serve
2026-06-24T10:12:01.000Z  INFO tq_api: listening on 0.0.0.0:3000

En otro terminal:

$ curl -s -w "\ncode: %{http_code}\n" -X POST localhost:3000/tasks \
  -H "Content-Type: application/json" -d '{"title":"buy coffee"}'
{"id":3,...}
code: 200

$ curl -s -w "\ncode: %{http_code}\n" localhost:3000/tasks/99
not found
code: 404

En el primer terminal:

2026-06-24T10:12:05.123Z  INFO tq_api::routes: task added id=3 title="buy coffee"
2026-06-24T10:12:08.789Z  WARN tq_api::routes: task not found id=99

Una petición exitosa - INFO. Una petición de una tarea inexistente - WARN. El nivel en el registro lleva significado, no solo una nota de que algo ocurrió.

El código completo de tq para este capítulo está en 8-ready/04-what-the-server-saw/.


Lore: map_err con un bloque

En proyectos reales, get_task y done se escriben más a menudo con map_err y un closure de bloque - sin match:

let task = guard.get(id).map_err(|_| {
    tracing::warn!(id = id, "task not found");
    ApiError::NotFound
})?;
tracing::info!(id = id, "get task");
Ok(Json(task.clone()))

El closure |_| { ... } funciona como un bloque: múltiples expresiones, la última sin ; es el valor de retorno. Es el mismo principio que cualquier bloque en Rust.


Lore: stdout o stderr

fmt::init() escribe en stdout. Para servidores, stderr es más habitual - stdout se reserva para datos, stderr para diagnósticos. Detrás de fmt::init() se oculta un builder:

tracing_subscriber::fmt()
    .with_writer(std::io::stderr()) // escribe en stderr
    .without_time()                 // sin marca de tiempo
    .init();

Esto es una ilustración de las opciones del builder, no una plantilla de producción. En proyectos reales, la configuración de tracing-subscriber suele estar dictada por la plataforma o el equipo - fmt::init() es suficiente hasta que haya una razón concreta para cambiarlo.


Lore: RUST_LOG

Cinco niveles - trace, debug, info, warn, error - forman una escala de “muy detallado” a “solo lo crítico”. Por defecto fmt::init() muestra info y superiores. La variable de entorno RUST_LOG cambia el umbral:

RUST_LOG=debug make serve   # debug y superiores
RUST_LOG=warn make serve    # solo warn y error

debug! y trace! son útiles al depurar un problema concreto - en producción se desactivan para no llenar la salida con detalles de la operación normal.