How to set the log level without recompiling
Short answer: Enable the env-filter feature on tracing-subscriber, replace with_max_level with EnvFilter::from_default_env(), and run the program with RUST_LOG=info cargo run — or any other level. The variable is read when the program starts, so the level changes without editing main.rs again. The trap: when RUST_LOG is unset, the filter lets only error through, and a program logging at info looks like it has stopped logging at all.
The previous article pinned the level in code with with_max_level(Level::INFO) and ended with a warning: that level is compiled into the binary. Turning debug logs on for one service, or silencing a chatty one, means editing main.rs, rebuilding, redeploying — for every change. RUST_LOG moves the decision out of the binary. The program reads the variable at startup, and the same binary can run quiet in production and loud on a broken machine.
The switch to EnvFilter
Section titled “The switch to EnvFilter”from_default_env lives in the filter module, which is behind a feature flag. Enable it:
[dependencies]tracing = "0.1.44"tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }Then swap one builder call for another. The program below logs the same five events as the previous article’s, from two different targets — the top-level module, and a worker module — because where an event comes from matters in a moment:
mod worker { use tracing::debug; pub fn tick() { debug!("worker tick"); }}
use tracing::{debug, error, info, trace, warn};use tracing_subscriber::EnvFilter;
fn main() { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); worker::tick(); trace!("server heartbeat"); debug!("cache hit"); info!("server started"); warn!("disk 80% full"); error!("connection lost");}from_default_env() builds a filter from the RUST_LOG variable — the name is a convention the whole Rust ecosystem shares, so libraries that also use tracing respond to the same variable. Nothing else changed: the macros are identical to the previous article’s, and the subscriber still formats the lines. What changed is who decides the cutoff.
The ladder, one rung at a time
Section titled “The ladder, one rung at a time”The variable holds a level name, lowercase: trace, debug, info, warn, or error. Each one means “this level and everything above it”:
RUST_LOG=info cargo run2026-10-29T18:45:35.128525Z INFO log_level_demo: server started2026-10-29T18:45:35.128607Z WARN log_level_demo: disk 80% full2026-10-29T18:45:35.128616Z ERROR log_level_demo: connection lostThree of the five events survive at info; trace! and debug! are dropped. Turn it up:
RUST_LOG=debug cargo run2026-10-29T18:45:35.306860Z DEBUG log_level_demo::worker: worker tick2026-10-29T18:45:35.306947Z DEBUG log_level_demo: cache hit2026-10-29T18:45:35.306956Z INFO log_level_demo: server started2026-10-29T18:45:35.306963Z WARN log_level_demo: disk 80% full2026-10-29T18:45:35.306970Z ERROR log_level_demo: connection lostAll five events are out now. Notice the order: worker tick comes first because worker::tick() is the first call in main — the filter changes which lines survive, never the order they were emitted in. Note also the second target in the output: log_level_demo::worker is the module path, and it is about to become useful.
RUST_LOG=trace shows all six lines, RUST_LOG=warn leaves the WARN and ERROR lines, and RUST_LOG=error leaves one. The timestamps differ on every run; the lines do not.
The default that hides everything
Section titled “The default that hides everything”Now run the program with no variable at all:
cargo run2026-10-29T18:45:34.578962Z ERROR log_level_demo: connection lostFive calls, one line. With the env-filter feature enabled, an unset RUST_LOG means everything below error is dropped — this is the behavior the previous article warned about, seen from the other side. It is the default for a defensible reason: a library you depend on can log at trace, and an unset variable keeps that firehose off. But it fails silently in a specific, expensive way: the program ships, the deploy script never sets RUST_LOG, the log looks like the service is doing nothing, and the bug is in the logging config, not the service.
A default that does not hide everything
Section titled “A default that does not hide everything”The fix is three lines in main: read the variable, and fall back to a level of your choosing when it is not set:
mod worker { use tracing::debug; pub fn tick() { debug!("worker tick"); }}
use tracing::{debug, error, info, trace, warn};use tracing_subscriber::EnvFilter;
fn main() { let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("info")); tracing_subscriber::fmt().with_env_filter(filter).init(); worker::tick(); trace!("server heartbeat"); debug!("cache hit"); info!("server started"); warn!("disk 80% full"); error!("connection lost");}try_from_default_env returns a Result — it is an error when RUST_LOG is unset, and the closure answers with info. Run it with no variable:
2026-10-29T18:47:11.940341Z INFO log_level_demo: server started2026-10-29T18:47:11.940543Z WARN log_level_demo: disk 80% full2026-10-29T18:47:11.940561Z ERROR log_level_demo: connection lostThe environment still wins when it is set — RUST_LOG=debug shows all five events as before — but an unset variable now produces a normal log instead of a near-empty one. The fallback level is a decision to make per program: info for a service whose logs humans read, warn for one whose logs a collector aggregates.
Per-target directives
Section titled “Per-target directives”A bare level name applies to every target. A target=level directive applies to one module path, and directives are comma-separated, so the two can be mixed:
RUST_LOG="warn,log_level_demo::worker=debug" cargo run2026-10-29T18:45:35.648765Z DEBUG log_level_demo::worker: worker tick2026-10-29T18:45:35.648846Z WARN log_level_demo: disk 80% full2026-10-29T18:45:35.648856Z ERROR log_level_demo: connection lostGlobal level warn, but the worker module talks at debug. Everything else — trace!("server heartbeat"), debug!("cache hit"), info!("server started") — is filtered out. This is the tool for the noisy-dependency problem: a dependency’s events arrive under its crate name as the target, so RUST_LOG=info,some_crate=warn keeps the program’s own log at full volume while silencing the library that logs every retry.
One trap, caught the hard way: the target is a module path, and module paths use underscores — even when the package name has hyphens. The package here is log-level-demo, and this filter matches nothing:
RUST_LOG="warn,log-level-demo::worker=debug" cargo run2026-10-29T18:45:35.822138Z WARN log_level_demo: disk 80% full2026-10-29T18:45:35.822215Z ERROR log_level_demo: connection lostNo error, no warning from the filter — the directive simply never matches an event, and the global warn carries the log alone. If a per-target directive appears to do nothing, the spelling of the target is the first thing to check, and the crate’s own log lines show what the target really looks like.
The level is now a deployment setting instead of a compile-time constant. What it controls is still flat text — one line per event. The next article adds fields that can be searched and counted instead of read.