Skip to content
Select theme

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.

from_default_env lives in the filter module, which is behind a feature flag. Enable it:

Cargo.toml
[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:

src/main.rs
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 variable holds a level name, lowercase: trace, debug, info, warn, or error. Each one means “this level and everything above it”:

Terminal window
RUST_LOG=info cargo run
2026-10-29T18:45:35.128525Z INFO log_level_demo: server started
2026-10-29T18:45:35.128607Z WARN log_level_demo: disk 80% full
2026-10-29T18:45:35.128616Z ERROR log_level_demo: connection lost

Three of the five events survive at info; trace! and debug! are dropped. Turn it up:

Terminal window
RUST_LOG=debug cargo run
2026-10-29T18:45:35.306860Z DEBUG log_level_demo::worker: worker tick
2026-10-29T18:45:35.306947Z DEBUG log_level_demo: cache hit
2026-10-29T18:45:35.306956Z INFO log_level_demo: server started
2026-10-29T18:45:35.306963Z WARN log_level_demo: disk 80% full
2026-10-29T18:45:35.306970Z ERROR log_level_demo: connection lost

All 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.

Now run the program with no variable at all:

Terminal window
cargo run
2026-10-29T18:45:34.578962Z ERROR log_level_demo: connection lost

Five 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.

The fix is three lines in main: read the variable, and fall back to a level of your choosing when it is not set:

src/main.rs
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 started
2026-10-29T18:47:11.940543Z WARN log_level_demo: disk 80% full
2026-10-29T18:47:11.940561Z ERROR log_level_demo: connection lost

The 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.

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:

Terminal window
RUST_LOG="warn,log_level_demo::worker=debug" cargo run
2026-10-29T18:45:35.648765Z DEBUG log_level_demo::worker: worker tick
2026-10-29T18:45:35.648846Z WARN log_level_demo: disk 80% full
2026-10-29T18:45:35.648856Z ERROR log_level_demo: connection lost

Global 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:

Terminal window
RUST_LOG="warn,log-level-demo::worker=debug" cargo run
2026-10-29T18:45:35.822138Z WARN log_level_demo: disk 80% full
2026-10-29T18:45:35.822215Z ERROR log_level_demo: connection lost

No 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.