When the logs drown the signal
Your Rust web server is running in production. A user reports a login failure. You enable logging to investigate, but suddenly your terminal floods with thousands of lines of debug output from the database driver, the HTTP parser, and the background task scheduler. The actual auth error is buried under a waterfall of noise. You need a way to turn down the global volume while cranking up the detail for the specific module causing trouble.
Rust's logging ecosystem solves this with a filter string. You configure a baseline level for the entire application and then override that baseline for specific module paths. The filter runs at runtime, deciding which log events pass through to your output and which get dropped. This lets you isolate the signal without changing code or recompiling.
How the filter works
Think of the filter as a mixing board with a master volume knob and individual faders for each channel. The master knob sets the global threshold. If you set the master to info, all events below info (like debug and trace) get silenced everywhere. The individual faders let you pull up specific channels. You can set the auth module to debug while keeping the rest of the app at info.
The filter matches log events against a set of rules. Each event has a target, which is usually the module path where the log macro was called. The filter checks the target against the rules and applies the most specific match. If no rule matches the target, the filter falls back to the global default.
This matching happens before the log message is formatted. Rust logging macros expand to code that checks the filter first. If the event is filtered out, the string formatting and argument evaluation are skipped entirely. This means debug!("Expensive result: {}", compute_heavy()) won't call compute_heavy() if debug logging is disabled. The filter protects you from the cost of constructing the message, not just the cost of printing it.
Minimal example
The standard way to configure filtering is through the RUST_LOG environment variable. The tracing ecosystem reads this variable automatically if you use EnvFilter.
use tracing::{debug, info};
use tracing_subscriber::{EnvFilter, fmt};
fn main() {
// Read the filter from RUST_LOG environment variable.
// Fall back to "info" if the variable is not set.
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
// Initialize the subscriber with the filter.
// This installs the global tracing dispatcher.
tracing_subscriber::fmt()
.with_env_filter(filter)
.init();
info!("Server starting up");
debug!("Loading configuration"); // Hidden by default
}
Run this with a filter string to see the override in action:
RUST_LOG="info,my_app::auth=debug" cargo run
The output shows the info message but hides the debug message because the global level is info. If you move the debug call into a module named my_app::auth, it becomes visible because the specific rule overrides the global default.
Set the filter once at startup. The logger reads the environment variable during initialization and ignores changes afterward.
Anatomy of the filter string
The filter string follows a concise syntax. You combine rules with commas. Each rule can be a global level or a module-specific override.
A bare level like info sets the global default. A rule like my_app::auth=debug sets the level for that specific path. The filter resolves targets using a prefix tree. When an event fires with target my_app::auth::login, the filter checks for a rule matching that exact path. If none exists, it checks my_app::auth. If that exists, it applies that rule. If not, it checks my_app. This continues until it hits a rule or falls back to the global default. The most specific match always wins.
You can silence a module entirely by setting it to off. This is useful for noisy third-party crates that spam debug logs. For example, hyper=off disables all logging from the hyper crate.
The levels follow a standard hierarchy: trace, debug, info, warn, error. trace is the most verbose. error is the least. Events are only emitted if their level is greater than or equal to the threshold set by the filter.
Convention aside: The community standard is to use lowercase levels in the filter string. tracing accepts DEBUG, Debug, and debug, but lowercase is the norm in documentation and examples. Stick to lowercase to avoid confusion.
Realistic example with third-party noise
Real applications pull in dependencies that generate their own logs. You often need to suppress noise from external crates while keeping your own code verbose.
// main.rs
use tracing::{debug, info, warn};
use tracing_subscriber::{EnvFilter, fmt};
mod auth;
fn main() {
// Read RUST_LOG from environment.
// Default to "warn" for production-like silence.
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("warn"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.init();
info!("Application starting");
debug!("Initializing subsystems"); // Hidden by default
// Simulate a noisy third-party crate
external_crate::do_work();
auth::process_login("admin@example.com");
}
mod external_crate {
use tracing::debug;
pub fn do_work() {
debug!("External crate internal state update");
debug!("External crate buffer flush");
}
}
// auth.rs
use tracing::{debug, info, warn};
pub fn process_login(email: &str) {
debug!("Checking credentials for {}", email);
info!("Auth module active");
warn!("Slow response detected");
}
Run this with a targeted filter:
RUST_LOG="warn,auth=debug,external_crate=off" cargo run
The output shows warnings and errors globally. The auth module emits debug, info, and warn messages. The external_crate is completely silenced. This gives you precise control over the log stream without modifying the dependencies.
If you pass a malformed filter string, EnvFilter::try_from_default_env() returns an error. Handle this result explicitly. Using unwrap() or expect() is common in main because a bad filter string indicates a configuration mistake that should fail fast.
let filter = EnvFilter::try_from_default_env()
.expect("Invalid RUST_LOG format");
Don't swallow filter errors silently. A typo in the filter string can leave your logging in an unexpected state. Fail loudly so you notice the mistake immediately.
Pitfalls and gotchas
Module paths must match the target used by the log macros. By default, the target is the module path where the macro is called. If you have a module named my_app::auth, the filter rule my_app::auth=debug works. If you rename the module to authentication, the rule breaks. The filter is case-sensitive and exact on the path segments.
The target parameter in log macros overrides the default module path. debug!(target: "custom", "message") sets the target to custom. The filter matches against custom, not the module path. This is useful for grouping logs across modules, but it means your filter rules must match the custom target.
Convention aside: Avoid overriding the target unless you have a strong reason. The default module path provides automatic hierarchy and makes filtering predictable. Custom targets require manual maintenance of the filter string.
Dynamic filtering requires explicit setup. EnvFilter reads the environment variable once. Changing RUST_LOG after the subscriber is initialized has no effect. If you need to change filters at runtime, use tracing_subscriber::reload to wrap the filter in a reloadable layer. This lets you update the filter via an API endpoint or signal handler.
Performance degrades if you log too much. Filtering is fast, but emitting millions of log events still costs CPU cycles. Use trace and debug sparingly. Reserve info for events that matter to operators. warn and error should indicate problems that require attention.
Trust the borrow checker of logging: if you can't filter it, you probably shouldn't log it. Keep your log output proportional to the value it provides.
Decision matrix
Use RUST_LOG environment variables when you want to control logging from the command line or deployment configuration without touching code. Use programmatic EnvFilter construction when your logging rules come from a config file or database and need to be parsed at startup. Use tracing with tracing_subscriber for new applications that need structured data, spans, and high performance. Use env_logger only for legacy codebases or tiny scripts where adding tracing feels like overkill. Use off in the filter string to silence a noisy third-party crate entirely. Use RUST_LOG=debug to flood the logs for local development, then switch to RUST_LOG=info for production. Use target overrides only when you need to group logs across unrelated modules under a single filter key.
Use tracing for new code. Reach for env_logger only when legacy constraints force your hand.