println! debugging works until it doesn't. Once your Rust application grows beyond a single binary — async services, multi-crate workspaces, production deployments — you need structured, leveled, filterable logging. The tracing ecosystem is the Rust community's answer.
The log crate gives you leveled messages. tracing gives you spans — structured regions of execution with typed fields. This matters for:
.await pointsAdd the core dependencies:
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Initialize the subscriber early in main:
use tracing_subscriber::EnvFilter;
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
tracing::info!("application started");
}
Now control verbosity at runtime:
# Default: info level
cargo run
# Debug for your crate, info for everything else
RUST_LOG=my_crate=debug cargo run
# Trace everything (noisy but complete)
RUST_LOG=trace cargo run
Use the macros directly — they mirror log levels:
use tracing::{trace, debug, info, warn, error};
fn process_signal(frequency_hz: f64, power_dbm: f64) {
trace!(frequency_hz, power_dbm, "processing signal");
if power_dbm < -100.0 {
warn!(power_dbm, "signal below noise floor");
}
info!(frequency_hz, power_dbm, "signal processed");
}
Note the structured fields — frequency_hz and power_dbm are key-value pairs, not interpolated into a string. Log aggregators can filter and query on them directly.
Spans represent a unit of work with a beginning and end:
use tracing::{info_span, instrument};
fn analyze_chain(blocks: &[Block]) -> ChainResult {
let span = info_span!("analyze_chain", block_count = blocks.len());
let _guard = span.enter();
// Everything logged here is "inside" the span
for (i, block) in blocks.iter().enumerate() {
tracing::debug!(index = i, name = %block.name, "processing block");
}
// _guard dropped here, span closes
ChainResult::default()
}
For functions, #[instrument] auto-creates a span with the function name and arguments:
use tracing::instrument;
#[instrument(skip(db), fields(user_id))]
async fn load_design(db: &Database, user_id: i64) -> Result<Design> {
tracing::Span::current().record("user_id", user_id);
let design = db.get_design(user_id).await?;
tracing::info!(design_id = design.id, "design loaded");
Ok(design)
}
Key options:
skip(field) — don't log sensitive or large argumentsfields(name) — declare fields to record laterlevel = "debug" — override the span's levelerr — automatically log errors from Result returnsFor web services, add request tracing with tower:
[dependencies]
tower-http = { version = "0.6", features = ["trace"] }
use axum::Router;
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/api/designs", get(list_designs))
.layer(TraceLayer::new_for_http());
Every request now gets a span with method, URI, status code, and latency — no manual instrumentation needed.
tracing_subscriber::fmt()
.with_target(false) // hide module paths
.with_level(true)
.compact() // single-line format
.init();
Output:
2026-03-01T12:00:00Z INFO signal processed frequency_hz=2.4e9 power_dbm=-45.0
tracing_subscriber::fmt()
.json()
.init();
Output:
{"timestamp":"2026-03-01T12:00:00Z","level":"INFO","message":"signal processed","frequency_hz":2.4e9,"power_dbm":-45.0}
Capture logs in tests to verify behavior:
use tracing_subscriber::fmt::MakeWriter;
#[test]
fn test_warns_on_low_signal() {
// tracing-test crate provides an assertion layer
let (subscriber, handle) = tracing_test::subscriber::mock()
.event(tracing_test::event::expect()
.at_level(tracing::Level::WARN))
.done()
.run_with_handle();
tracing::subscriber::with_default(subscriber, || {
process_signal(2.4e9, -110.0);
});
handle.assert_finished();
}
Or more simply with the tracing-test crate:
use tracing_test::traced_test;
#[traced_test]
#[test]
fn test_low_signal_warning() {
process_signal(2.4e9, -110.0);
assert!(logs_contain("signal below noise floor"));
}
#[instrument(err)]
fn parse_touchstone(path: &Path) -> Result<Network> {
let content = std::fs::read_to_string(path)
.map_err(|e| {
tracing::error!(path = %path.display(), "failed to read file");
e
})?;
// parse...
Ok(network)
}
Keep tracing out of library crates that need to stay minimal:
[features]
tracing = ["dep:tracing"]
[dependencies]
tracing = { version = "0.1", optional = true }
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn cascade_noise_figure(stages: &[(f64, f64)]) -> f64 {
// ...
}
tracing is designed for production use:
max_level_* features)For extreme performance sensitivity:
[dependencies]
tracing = { version = "0.1", features = ["max_level_info", "release_max_level_warn"] }
This compiles out trace! and debug! in release builds entirely.
| Feature | println! | log | tracing |
|---|---|---|---|
| Levels | ❌ | ✅ | ✅ |
| Structured fields | ❌ | ❌ | ✅ |
| Spans | ❌ | ❌ | ✅ |
| Async-aware | ❌ | ❌ | ✅ |
| Runtime filtering | ❌ | ✅ | ✅ |
| Zero-cost disable | ❌ | ✅ | ✅ |
Start with tracing from day one. The API is barely more complex than println!, but the observability payoff compounds as your project grows.