How I went from copy-pasting tracing-subscriber boilerplate to shipping a one-line JSON logger that actually works in production.
The Logging Evolution Every Rust Developer Goes Through
Stage 1: The println! Paradise 🌈
We’ve all been there. Your first Rust project, everything’s working locally:
fn main() {
println!("Starting server...");
let user = authenticate_user();
println!("User: {:?}", user);
if user.is_premium() {
println!("Premium user detected!");
}
println!("Server running on port 8080");
}
Life is simple. Logs are readable. Everything makes sense.
Stage 2: The Production Reality Check 💥
Then you deploy to production and your DevOps team has… questions:
- “Where are the structured logs?”
- “How do we parse this with our ELK stack?”
- “Can you add request IDs and correlation tracking?”
- “Why are debug logs mixed with errors?”
- “We need JSON format for our monitoring dashboard.”
Suddenly, println! doesn’t seem so clever anymore.
Stage 3: The tracing Discovery 🔍
You discover the `tracing` ecosystem. It’s powerful, flexible, and… overwhelming:
use tracing_subscriber::{
fmt,
layer::SubscriberExt,
util::SubscriberInitExt,
EnvFilter,
Registry,
};
fn setup_logging() -> Result<(), Box> {
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))?;
let formatting_layer = fmt::layer()
.json()
.with_current_span(false)
.with_span_list(false)
.with_level(true)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false);
let file_appender = tracing_appender::rolling::daily("./logs", "app.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
let file_layer = fmt::layer()
.json()
.with_writer(non_blocking)
.with_ansi(false);
Registry::default()
.with(env_filter)
.with(formatting_layer)
.with(file_layer)
.init();
Ok(())
}
This works, but now you’re copying this setup between every project, tweaking it slightly each time, and inevitably breaking something.
Stage 4: The Copy-Paste Nightmare 📋
Six months later, you have five different projects with five slightly different logging setups:
- Project A: Console-only JSON logging
- Project B: File logging with daily rotation
- Project C: Both console and file, but different JSON formats
- Project D: Custom formatting that nobody remembers how to modify
- Project E: Broken logging because someone “simplified” the setup
Each project has its own `logging.rs` module with 50+ lines of subscriber configuration. Documentation is scattered. New team members spend hours figuring out how to add a simple log statement.
The Breakthrough: What If Logging Was Just… Simple?
After the hundredth time copying logging setup between projects, I had an epiphany:
99% of the time, I want the same thing:
- Structured JSON logs for production
- Environment variable control (`RUST_LOG`)
- Optional file output with rotation
- Zero configuration boilerplate
What if it was just:
fn main() {
custom_tracing_logger::init();
tracing::info!(user_id = 123, action = "login", "User authenticated");
}
That’s it. No layers, no registries, no formatters. Just logs.
Understanding the Tracing Ecosystem (The Missing Manual)
Before building the solution, let’s understand what we’re working with:
The tracing Crate: Your Instrumentation Layer
use tracing::{info, warn, error, debug, trace};
// Simple message
info!("Server started");
// Structured data (the magic sauce)
info!(
user_id = 12345,
session_id = "sess_abc123",
action = "purchase",
amount = 99.99,
"User completed purchase"
);
// Spans for request tracing
let span = info_span!("http_request", method = "POST", path = "/api/users");
let _enter = span.enter();
Key insight: `tracing` is just the instrumentation. It doesn’t know how to format or where to send logs. That’s where `tracing-subscriber` comes in.
tracing-subscriber: The Heavy Lifter
This is where the complexity lives:
- Layers: Different processing steps (formatting, filtering, output)
- Formatters: JSON, plain text, custom formats
- Filters: What logs to show (level, module, custom logic)
- Writers: Where logs go (stdout, files, network)
The power is incredible, but the learning curve is steep.
The Solution: Opinionated Defaults with Escape Hatches
I built `custom-tracing-logger` around a simple philosophy:
Make the common case trivial, keep the complex case possible.
The Common Case (95% of projects):
// Development: console logging
custom_tracing_logger::init();
// Production: console + daily rotating files
// Set: LOG_FILE_DIR=./logs LOG_FILE_PREFIX=myapp
custom_tracing_logger::init();
// Background service: file-only logging
// Set: LOG_FILE_DIR=./logs LOG_FILE_ONLY=true
custom_tracing_logger::init();
Same function, different behavior based on environment variables.
The Complex Case (5% of projects):
If you need custom formatters, multiple outputs, or complex filtering, you can still use `tracing-subscriber` directly. This crate doesn’t lock you in.
The Technical Deep Dive: How It Works
Under the hood, the crate makes intelligent decisions based on environment variables:
pub fn init() {
// Smart environment variable parsing (handles Windows cmd quirks)
let env_filter = match std::env::var("RUST_LOG") {
Ok(val) => EnvFilter::new(val.trim()),
Err(_) => EnvFilter::new("info"),
};
// Configuration detection
let log_file_dir = std::env::var("LOG_FILE_DIR").ok();
let log_file_prefix = std::env::var("LOG_FILE_PREFIX").unwrap_or_else(|_| "app".to_string());
let file_only = std::env::var("LOG_FILE_ONLY").unwrap_or_default() == "true";
// Smart layer composition
match (log_file_dir, file_only) {
(Some(log_dir), false) => {
// Console + File: Production setup
registry.with(console_layer).with(file_layer).try_init()
},
(Some(log_dir), true) => {
// File only: Background service setup
registry.with(file_layer).try_init()
},
(None, _) => {
// Console only: Development setup
registry.with(console_layer).try_init()
}
}
}
Key Design Decisions:
1. Environment-driven configuration: Follows 12-factor app principles
2. Graceful degradation: If file logging fails, console logging continues
3. Windows compatibility: Handles cmd.exe trailing space issues
4. Daily rotation: Automatic log file rotation without configuration
5. Consistent JSON format: Same structure across all outputs
The JSON Format: Designed for Machines, Readable by Humans
Every log entry follows the same structure:
{
"timestamp": "2025-01-15T14:30:45.123456Z",
"level": "INFO",
"fields": {
"message": "User authenticated successfully",
"user_id": 12345,
"session_id": "sess_abc123",
"login_method": "oauth",
"duration_ms": 245
},
"target": "auth_service"
}
Why This Format?
- Elasticsearch-friendly: Direct indexing without parsing
- Grafana-compatible: Easy dashboard creation
- Splunk-ready: Automatic field extraction
- Human-readable: Still makes sense when you `cat` the file
- Type-preserving: Numbers stay numbers, booleans stay booleans
Environment Variable Magic: Runtime Configuration
# Development: See everything, console only
export RUST_LOG=debug
# Production: Info level, console + files
export RUST_LOG=info
export LOG_FILE_DIR=/var/log/myapp
export LOG_FILE_PREFIX=production
# Background service: Errors only, file only
export RUST_LOG=error
export LOG_FILE_DIR=/var/log/myapp
export LOG_FILE_ONLY=true
# Debugging: Specific modules
export RUST_LOG="info,myapp::database=debug,myapp::auth=trace"
Advanced Filtering Examples:
# Silence noisy dependencies
export RUST_LOG="info,tokio=warn,hyper=warn,h2=error"
# Focus on specific components
export RUST_LOG="warn,myapp::payment=debug,myapp::fraud_detection=trace"
# Production debugging (temporary)
export RUST_LOG="error,myapp::critical_path=debug"
Ecosystem Integration: Plays Well with Others
Works with existing tracing ecosystem:
// Your existing tracing code works unchanged
use tracing::{info, warn, error, instrument};
#[instrument]
async fn process_payment(user_id: u64, amount: f64) -> Result {
info!(user_id, amount, "Processing payment");
// Your business logic here
Ok(PaymentId::new())
}
// Just change the initialization
fn main() {
// Old way:
// setup_complex_tracing_subscriber();
// New way:
custom_tracing_logger::init();
// Everything else stays the same
}
Compatible with:
- tracing-opentelemetry: Add distributed tracing
- tracing-flame: Performance profiling
- tracing-tree: Hierarchical span visualization
- Custom subscribers: Add your own layers
The Philosophy: Boring Technology for Important Problems
Logging is infrastructure. It should be:
- Boring: Works the same way everywhere
- Reliable: Never the reason your service fails
- Invisible: You forget it exists until you need it
- Powerful: Handles complex scenarios when required
This crate embodies “boring technology”:
- No surprises: Behaves predictably across environments
- No magic: Simple environment variable configuration
- No lock-in: Standard tracing ecosystem underneath
- No maintenance: Set it up once, forget about it
Future Roadmap: What’s Next?
Planned Features:
1. Metrics integration: Automatic log-based metrics
2. Sampling support: High-volume log sampling
3. Cloud integrations: Direct AWS CloudWatch, GCP Logging
4. Configuration validation: Startup-time config verification
5. Performance dashboard: Built-in logging performance metrics
Community Requests:
- Custom JSON schemas: Industry-specific log formats
- Log aggregation: Built-in log shipping
- Encryption support: Encrypted log files
- Compression: Automatic log compression
The Bottom Line: Time is Your Most Valuable Resource
Every hour spent configuring logging is an hour not spent building features, fixing bugs, or improving user experience.
This crate gives you back that time.
Before: 2-4 hours per project setting up logging
After: 30 seconds adding one line
Before: Inconsistent log formats across services
After: Uniform JSON structure everywhere
Before: Environment-specific logging bugs
After: Same code, different configuration
Before: New team members confused by logging setup
After: One function call, self-documenting
Try It Today
[dependencies]
custom-tracing-logger = "0.1.0"
tracing = "0.1"
fn main() {
custom_tracing_logger::init();
tracing::info!("Welcome to better logging");
}
Your future self will thank you.
Built by developers, for developers who have better things to do than configure logging.
Questions? Issues? Contributions?
GitHub: https://github.com/huyhoang1001/custom-tracing-logger