When text logs stop working
Your application is running in production. A user reports a crash. You pull the logs and stare at a wall of unstructured text. Grep returns four hundred lines of noise. You need to filter by user_id, aggregate by request_id, and pipe the output into a dashboard tool. Text logs fail here. Structured data wins. JSON is the lingua franca of modern observability. Rust gives you a clean way to emit structured logs without the pain of manual serialization.
Structured logging in plain words
Traditional logging treats output like a diary entry: a string of characters meant for human eyes. JSON logging treats output like a database row: fields, types, and values that machines can parse instantly. The tracing crate is the standard for structured logging in Rust. It captures events with key-value pairs. The tracing-subscriber crate takes those events and decides where they go. By adding the JSON layer, you tell the subscriber to serialize every event into a JSON object instead of a text line. The result is a stream of parseable records.
Minimal setup
Start with the dependencies. You need tracing for the macros and tracing-subscriber for the output layer. The JSON functionality lives behind a feature flag.
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json"] }
Initialize the subscriber in main before any logging happens. The Layer builder lets you configure the JSON output.
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, fmt::Layer};
fn main() {
// Create a layer that formats events as JSON.
// Disable extra fields like thread IDs to keep output clean.
let json_layer = Layer::new()
.json()
.with_target(false)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(false)
.with_line_number(false);
// Build the subscriber: registry + level filter + json layer.
// This initializes the global tracing system.
tracing_subscriber::registry()
.with(tracing_subscriber::filter::LevelFilter::INFO)
.with(json_layer)
.init();
// Log an event with structured fields.
// `message` is special; it becomes the "message" key in JSON.
tracing::info!(message = "Application started", user_id = 123);
}
Run the code. You get a single line of JSON on stdout.
{"message":"Application started","user_id":123}
Check stdout. You just emitted your first structured log.
How the pipeline works
When main runs, the subscriber initializes globally. The registry acts as the base. The LevelFilter::INFO drops anything below INFO. The json_layer intercepts the remaining events. When tracing::info! fires, it captures message and user_id. The layer serializes these into a JSON object. The message field gets special treatment: it becomes the top-level message key. Other fields become keys in the same object. The result writes to stdout.
Realistic logging with errors
Real applications have functions, errors, and context. You often want to log errors as structured data too. The tracing macros support formatting errors cleanly.
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, fmt::Layer};
use std::io;
fn process_request(id: u64) -> io::Result<()> {
// Simulate work.
if id % 2 == 0 {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "Even IDs are forbidden"));
}
Ok(())
}
fn main() {
let json_layer = Layer::new().json();
tracing_subscriber::registry()
.with(tracing_subscriber::filter::LevelFilter::DEBUG)
.with(json_layer)
.init();
// Log a request start.
tracing::info!(request_id = 42, "Processing request");
// Use `?` to attach the error to the log event if it fails.
if let Err(e) = process_request(42) {
// The % prefix formats the error using Display.
// This ensures the JSON contains a readable string, not a raw struct dump.
tracing::error!(error = %e, request_id = 42, "Request failed");
}
}
The % prefix is a community convention. Without it, tracing might try to serialize the error struct directly, which can fail or produce verbose output. Use % for errors to get a clean string in the JSON.
Attach errors with %. Keep your JSON clean.
Spans and context
Spans represent context. In JSON, they appear as nested objects. This helps correlate logs.
use tracing::span;
fn main() {
let json_layer = Layer::new().json();
tracing_subscriber::registry()
.with(tracing_subscriber::filter::LevelFilter::INFO)
.with(json_layer)
.init();
// Create a span with fields.
let req_span = span!(tracing::Level::INFO, "request", id = 42);
let _guard = req_span.enter();
// Events inside the span inherit the span's fields.
tracing::info!("Processing");
}
The output nests the span fields.
{"message":"Processing","request":{"id":42}}
You can flatten this structure if your log aggregator prefers flat keys. Use flatten_event(true) on the layer. This merges span fields into the top level.
let json_layer = Layer::new()
.json()
.flatten_event(true);
Choose the shape that matches your tooling. Nested spans preserve hierarchy. Flattened events simplify querying.
Filtering with environment variables
Production applications rarely hardcode log levels. The community convention is to expose RUST_LOG support. Users expect to control log levels via environment variables. Use EnvFilter to honor this expectation.
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, fmt::Layer};
fn main() {
// EnvFilter reads RUST_LOG from the environment.
// Falls back to INFO if the variable is missing.
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let json_layer = Layer::new().json();
tracing_subscriber::registry()
.with(filter)
.with(json_layer)
.init();
tracing::info!("Ready");
}
Run with RUST_LOG=debug ./app to see debug logs. Run with RUST_LOG=error ./app to see only errors.
Honor RUST_LOG. Your users will thank you.
Pitfalls and errors
Missing the feature flag is the most common mistake. If you forget features = ["json"] in Cargo.toml, the compiler rejects the code. You'll see E0599 (no method named json found). The feature is gated because JSON serialization pulls in dependencies. Always check your Cargo.toml if the method is missing.
The message key is reserved. If you log tracing::info!(message = "foo", message = "bar"), the second value wins. Use message only for the human-readable summary. Put data in other keys.
JSON serialization allocates. In hot loops, this adds overhead. Profile before enabling JSON in tight paths. For most I/O bound services, the cost is negligible.
Check the feature flag before blaming the compiler.
When to use JSON logging
Use JSON logging when you need machine-readable output for dashboards, aggregation tools, or log shippers like Fluentd and Vector. Use JSON logging when your application runs in a containerized environment where stdout is the standard log destination. Use plain text logging when you are debugging locally and want human-readable output in the terminal. Use defmt when you are targeting embedded devices with no standard library and need minimal binary size. Use the log crate facade when you are writing a library and want to let the binary decide how to handle logs.
Pick the format that matches your destination. Machines want JSON. Humans want text.