NashTech Blog

From println! Hell to Production-Ready Logging: A Rust Developer’s Journey

Table of Contents

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

Picture of Hoang Vo Huy

Hoang Vo Huy

Leave a Comment

Your email address will not be published. Required fields are marked *

Suggested Article

Scroll to Top