Lesson 3 of 6
Logging & Tracing
Logging in Rust is split into two parts: a facade that code writes to, and a backend chosen by the application. Libraries and applications write log events with the macros of the tracing crate, and the application installs a subscriber, usually from tracing-subscriber, that decides what to keep, how to format it, and where to write it. With no subscriber installed, events are discarded.
tracing goes beyond plain logging in two ways: every event can carry structured fields (user_id = 42) instead of text you parse later, and spans attach context to everything logged during a unit of work, such as a request ID, even across async code.
cargo add tracing
cargo add tracing-subscriber --features env-filter,json| Task | tracing |
|---|---|
| Log a message | info!("started") |
| Log a value | info!(user_id = id, "logged in") |
| Configure output | building a subscriber in main |
| Set levels per module | EnvFilter::new("info,shop::db=debug") or RUST_LOG |
| Attach context to every line | a span: info_span!("request", id) |
| JSON output | .json() |
| Write to (rolling) files | .with_writer(...), the tracing-appender crate |
Capture events from the older log crate |
tracing-log, built into tracing-subscriber |
The examples call .without_time(), .with_ansi(false), and .with_target(false) so the output on this page is plain and stable. In a real program, keep the timestamps, and keep the colors when writing to a terminal.
Part 1: Events
1. Levels, macros, and a subscriber
The five macros match the familiar levels: error!, warn!, info!, debug!, and trace!. Nothing is printed until a subscriber is installed; tracing_subscriber::fmt().init() installs the default one, which writes human-readable lines to stdout and shows INFO and above.
use tracing::{debug, error, info, warn};
fn main() {
info!("before a subscriber is installed: this goes nowhere");
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();
info!("server starting on port {}", 8080);
warn!("config file not found, using defaults");
error!("could not connect to the database");
debug!("not shown: below the default INFO level");
} INFO server starting on port 8080
WARN config file not found, using defaults
ERROR could not connect to the databaseLibraries only ever use the macros; only the application's main installs a subscriber, once.
2. Structured fields
Instead of formatting values into the message, attach them as fields: name = value before the message. They appear as key=value in text output and as real JSON fields in JSON output (example 8), so log tools can filter on them without parsing text. %value records a value with Display, ?value with Debug, and a bare variable name records it under its own name.
use tracing::info;
#[derive(Debug)]
struct Cart {
items: u32,
total_cents: u64,
}
fn main() {
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();
let order_id = "A-1042";
let cart = Cart { items: 3, total_cents: 4_990 };
let user_id = 42;
info!(user_id, order = %order_id, "order placed");
info!(?cart, express = true, "checkout");
info!(cart.items, cart.total_cents, "cart summary");
} INFO order placed user_id=42 order=A-1042
INFO checkout cart=Cart { items: 3, total_cents: 4990 } express=true
INFO cart summary cart.items=3 cart.total_cents=49903. Choosing what to log with EnvFilter
An EnvFilter sets a default level and per-module overrides. EnvFilter::from_default_env() reads the RUST_LOG environment variable, so levels can change without recompiling: RUST_LOG=info,shop::db=debug cargo run. Each event's target defaults to its module path; here the target: argument sets it explicitly.
use tracing::{debug, info, trace, warn};
use tracing_subscriber::EnvFilter;
fn main() {
// In a real program: EnvFilter::from_default_env(), set via RUST_LOG.
let filter = EnvFilter::new("warn,shop::db=debug,shop::http=info");
tracing_subscriber::fmt().without_time().with_ansi(false).with_env_filter(filter).init();
debug!(target: "shop::db", "SELECT * FROM orders WHERE id = 7");
trace!(target: "shop::db", "row decoded"); // below debug: filtered out
info!(target: "shop::http", "GET /orders/7 -> 200");
debug!(target: "shop::http", "headers parsed"); // below info: filtered out
info!(target: "shop::billing", "invoice sent"); // default level is warn
warn!(target: "shop::billing", "invoice 7 is overdue");
}DEBUG shop::db: SELECT * FROM orders WHERE id = 7
INFO shop::http: GET /orders/7 -> 200
WARN shop::billing: invoice 7 is overdueA common setup reads RUST_LOG and falls back to a default: EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")).
Part 2: Spans
4. Spans: context for a unit of work
A span represents a period of time, such as handling one request. While a span is entered, every event includes its name and fields, which is how a request ID ends up on every line. Spans nest, and leaving the scope of the guard returned by enter() exits the span.
use tracing::{info, info_span, warn};
fn load_order(id: u32) {
info!("loading from the database");
if id == 13 {
warn!("order is locked by another request");
}
}
fn main() {
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();
for (request_id, order_id) in [(1, 7), (2, 13)] {
let request = info_span!("request", request_id, path = "/orders");
let _entered = request.enter();
info!("received");
{
let _db = info_span!("db", order_id).entered();
load_order(order_id);
}
info!("responded");
} // `_entered` is dropped at the end of each iteration: the span is exited
info!("outside any span");
} INFO request{request_id=1 path="/orders"}: received
INFO request{request_id=1 path="/orders"}:db{order_id=7}: loading from the database
INFO request{request_id=1 path="/orders"}: responded
INFO request{request_id=2 path="/orders"}: received
INFO request{request_id=2 path="/orders"}:db{order_id=13}: loading from the database
WARN request{request_id=2 path="/orders"}:db{order_id=13}: order is locked by another request
INFO request{request_id=2 path="/orders"}: responded
INFO outside any spanspan.entered() enters and returns the guard in one step.
5. #[instrument]: a span per function call
The #[instrument] attribute wraps a function in a span named after it, with its arguments as fields. skip leaves out arguments that shouldn't be logged, such as passwords or large values, and fields adds more.
use tracing::{info, instrument, warn};
#[instrument(skip(password), fields(method = "password"))]
fn login(user: &str, password: &str) -> bool {
let ok = password == "correct horse";
if ok {
info!("logged in");
} else {
warn!(len = password.len(), "wrong password");
}
ok
}
#[instrument]
fn charge(order_id: u32, cents: u64) {
info!("charging card");
}
fn main() {
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();
login("ana", "hunter2");
login("ana", "correct horse");
charge(7, 4_990);
} WARN login{user="ana" method="password"}: wrong password len=7
INFO login{user="ana" method="password"}: logged in
INFO charge{order_id=7 cents=4990}: charging card6. Spans across async code
A span entered with enter() would stay entered while the task sleeps at an .await, and other tasks running on the same thread would appear to be inside it. For async code, attach the span to the future with .instrument(span) instead: it's entered each time the future runs and exited each time it pauses. #[instrument] on an async fn does this automatically.
use std::time::Duration;
use tracing::{Instrument, info, info_span, instrument};
#[instrument]
async fn fetch(url: &str, delay_ms: u64) -> usize {
info!("start");
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
info!("done");
url.len()
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();
let a = fetch("https://a.example", 30);
let b = fetch("https://b.example/slow", 60);
let (x, y) = tokio::join!(a, b); // both run at the same time; each line still names its own call
info!(total = x + y, "both finished");
async { info!("inside a manually instrumented block") }.instrument(info_span!("job", id = 9)).await;
} INFO fetch{url="https://a.example" delay_ms=30}: start
INFO fetch{url="https://b.example/slow" delay_ms=60}: start
INFO fetch{url="https://a.example" delay_ms=30}: done
INFO fetch{url="https://b.example/slow" delay_ms=60}: done
INFO both finished total=39
INFO job{id=9}: inside a manually instrumented blockPart 3: Output
7. Logging errors
Record an error as a field with %, so its Display text appears. When the error has a chain of causes, as anyhow errors do, {:#} formats the whole chain on one line (thiserror & anyhow).
use anyhow::Context;
use tracing::{error, warn};
fn read_config(path: &str) -> anyhow::Result<String> {
std::fs::read_to_string(path).with_context(|| format!("reading {path}"))
}
fn main() {
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();
let parse_error = "12x".parse::<u32>().unwrap_err();
warn!(error = %parse_error, input = "12x", "ignoring bad quantity");
if let Err(e) = read_config("missing.toml") {
error!(error = format!("{e:#}"), "startup failed");
}
} WARN ignoring bad quantity error=invalid digit found in string input="12x"
ERROR startup failed error="reading missing.toml: No such file or directory (os error 2)"8. JSON logs for production
Log collectors such as Elasticsearch, Loki, or CloudWatch prefer one JSON object per line. .json() switches the format; fields become JSON fields and the current spans are included.
use tracing::{info, info_span, warn};
fn main() {
tracing_subscriber::fmt().json().without_time().with_target(false).init();
let span = info_span!("request", request_id = 81, path = "/checkout");
let _entered = span.enter();
info!(user_id = 42, total_cents = 4_990, "order placed");
warn!(retry_in_ms = 250, "payment provider slow");
}{"level":"INFO","fields":{"message":"order placed","user_id":42,"total_cents":4990},"span":{"path":"/checkout","request_id":81,"name":"request"},"spans":[{"path":"/checkout","request_id":81,"name":"request"}]}
{"level":"WARN","fields":{"message":"payment provider slow","retry_in_ms":250},"span":{"path":"/checkout","request_id":81,"name":"request"},"spans":[{"path":"/checkout","request_id":81,"name":"request"}]}With timestamps enabled, each object also has a "timestamp" field. .flatten_event(true) moves the fields to the top level if your log collector prefers that.
9. Writing logs to a file
with_writer chooses where the output goes. It accepts anything that can produce writers: std::io::stderr (the usual choice for command-line tools, so logs don't mix with the program's output), or a Mutex<File>. For rolling daily files and writing on a background thread, use the tracing-appender crate.
use std::fs::{self, File};
use std::sync::Mutex;
use tracing::info;
fn main() -> std::io::Result<()> {
let file = File::create("app.log")?;
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).with_writer(Mutex::new(file)).init();
info!(version = "1.4.2", "service started");
info!(jobs = 3, "queue drained");
print!("app.log contains:\n{}", fs::read_to_string("app.log")?);
Ok(())
}app.log contains:
INFO service started version="1.4.2"
INFO queue drained jobs=310. A subscriber for one scope
init() sets the global subscriber and can only be called once per program. tracing::subscriber::with_default installs one just for the duration of a closure on the current thread. Tests use it to capture or silence logs, and it's handy for showing two formats side by side.
use tracing::info;
fn work() {
info!(step = 1, "doing work");
}
fn main() {
let plain = tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).finish();
tracing::subscriber::with_default(plain, work);
let json = tracing_subscriber::fmt().json().without_time().with_target(false).finish();
tracing::subscriber::with_default(json, work);
work(); // no subscriber here: nothing is printed
} INFO doing work step=1
{"level":"INFO","fields":{"message":"doing work","step":1}}11. Disabled levels cost almost nothing
A debug! call below the active level checks the level and returns: its arguments aren't even evaluated. Leaving detailed debug! and trace! calls in production code is therefore cheap, and they can be switched on with RUST_LOG when needed.
use std::sync::atomic::{AtomicU32, Ordering};
use tracing::{debug, info};
use tracing_subscriber::EnvFilter;
static CALLS: AtomicU32 = AtomicU32::new(0);
fn expensive_summary() -> String {
CALLS.fetch_add(1, Ordering::Relaxed);
"a very long report".to_string()
}
fn main() {
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).with_env_filter(EnvFilter::new("info")).init();
for _ in 0..1_000 {
debug!(summary = %expensive_summary(), "tick"); // disabled: never evaluated
}
info!(summary = %expensive_summary(), "done");
println!("expensive_summary ran {} time(s)", CALLS.load(Ordering::Relaxed));
} INFO done summary=a very long report
expensive_summary ran 1 time(s)12. Libraries that use the log crate
Before tracing, the ecosystem's logging facade was the log crate, and many libraries still use it. tracing-subscriber's init() also captures log events, so they appear in the same output.
use tracing::info;
mod legacy_library {
pub fn connect() {
log::info!("connected via the log crate");
log::debug!("not shown at INFO level");
}
}
fn main() {
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();
info!("from tracing");
legacy_library::connect();
} INFO from tracing
INFO connected via the log cratePart 4: Putting it together
13. Realistic example: an instrumented order service
A request handler with the patterns a real service uses: a span per request with a request ID, #[instrument] on the steps, structured fields everywhere, warnings for recoverable problems, errors with their cause, and one summary event per request. The filter comes from RUST_LOG with a default.
use tracing::{debug, error, info, info_span, instrument, warn};
use tracing_subscriber::EnvFilter;
#[derive(Debug)]
struct Order {
id: u32,
items: u32,
cents: u64,
}
#[instrument(skip(order), fields(order_id = order.id))]
fn reserve_stock(order: &Order) -> Result<(), String> {
debug!(items = order.items, "reserving");
if order.items > 10 {
return Err(format!("only 10 in stock, {} requested", order.items));
}
Ok(())
}
#[instrument(skip(order), fields(order_id = order.id))]
fn charge(order: &Order, attempt: u32) -> Result<(), String> {
if order.cents > 50_000 && attempt == 1 {
warn!("provider timeout, retrying");
return charge(order, attempt + 1);
}
info!(cents = order.cents, "charged");
Ok(())
}
fn handle(request_id: u32, order: Order) {
let _span = info_span!("request", request_id).entered();
let result = reserve_stock(&order).and_then(|()| charge(&order, 1));
match result {
Ok(()) => info!(order_id = order.id, status = 201, "request complete"),
Err(e) => error!(order_id = order.id, status = 409, error = %e, "request failed"),
}
}
fn main() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).with_env_filter(filter).init();
handle(1, Order { id: 7, items: 2, cents: 4_990 });
handle(2, Order { id: 8, items: 12, cents: 9_000 });
handle(3, Order { id: 9, items: 1, cents: 72_000 });
} INFO request{request_id=1}:charge{attempt=1 order_id=7}: charged cents=4990
INFO request{request_id=1}: request complete order_id=7 status=201
ERROR request{request_id=2}: request failed order_id=8 status=409 error=only 10 in stock, 12 requested
WARN request{request_id=3}:charge{attempt=1 order_id=9}: provider timeout, retrying
INFO request{request_id=3}:charge{attempt=1 order_id=9}:charge{attempt=2 order_id=9}: charged cents=72000
INFO request{request_id=3}: request complete order_id=9 status=201Where to go from here
- Distributed tracing:
tracing-opentelemetryexports spans to Jaeger, Zipkin, or any OpenTelemetry collector, so one request can be followed across services. - Timing:
.with_span_events(FmtSpan::CLOSE)logs how long each span was open. - Rolling files:
tracing-appenderwrites to daily or hourly files, on a background thread so logging never blocks a request. - Web frameworks:
tower-http'sTraceLayercreates a span for every HTTP request, as the next lesson shows.
Summary
- Code logs with
tracing's macros; the application installs one subscriber, usuallytracing_subscriber::fmt(), configured inmain. - Prefer structured fields (
user_id = 42,%display,?debug) to formatting values into messages. - Filter by level and module with
EnvFilter, fromRUST_LOGin real programs. - Spans add context to every event inside them; use
#[instrument]on functions and.instrument(span)on futures. .json()for log collectors,with_writerfor stderr or files,with_defaultfor scoped subscribers in tests.- Disabled events don't evaluate their arguments, and events from the
logcrate are captured too.
Next: Web Services with axum.
