Type Hell in Tracing: Multiple Output Layers

I'm currently working on a project for reducing boilerplate for creating Rust CLI applications. I've been trying to work on things on and off for a while now, and it inevitably leads to failure as soon as I try making it variable on what output format to use for console logging. I'll also be optionally adding OpenTelemetry support if environment variables for it are set.

Here are the goals:

  1. (working) Vary the console output destination based on a CLI param
  2. (working) Vary the console output format based on a CLI param
  3. (working) Filter the console output based on RUST_LOG env via EnvFilter
  4. (working) The above should exist as its own layer so that other layers will not be filtered by console filtering.
  5. (not working) Add another parallel output to a file.

Note that I'm not involving OpenTelemetry yet but I'm trying to get to a state where I have two separate layers: one for the console with its own filtering rules, and another to an arbitrary place (I'm just using json to a file for now) which is not filtered.

My working code can be seen here in a Gist but I'll post it here as well:

//! This quick CLI demo shows how to set up tracing for console logging with a custom format defined
//! at runtime. We *must* do type erasure here by putting each "format" (layer) inside of a Box, but
//! it does indeed work.
// :dep clap = { version = "4", features = ["derive"] }
// :dep sysexits = "0.9"
// :dep tokio = { version = "1", features = ["full"] }
// :dep tracing = "0.1"
// :dep tracing-appender = { version = "0.2", features = ["parking_lot"] }
// :dep tracing-core = "0.1"
// :dep tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }

use clap::{Parser, ValueEnum};
use std::fmt::Display;
use sysexits::Result;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_core::Subscriber;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::{EnvFilter, Layer};

#[derive(Debug, Clone, Parser)]
pub struct App {
    #[clap(short = 'F', long = "format", default_value_t)]
    format: LogFormat,
    #[clap(short = 'D', long = "dest", name = "DESTINATION", default_value_t)]
    dest: LogDest,
}

#[derive(Debug, Clone, ValueEnum)]
pub enum LogFormat {
    Full,
    Pretty,
    Compact,
    Json,
    Off,
}

impl Default for LogFormat {
    fn default() -> Self {
        Self::Full
    }
}

impl Display for LogFormat {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                Self::Full => "full",
                Self::Pretty => "pretty",
                Self::Compact => "compact",
                Self::Json => "json",
                Self::Off => "off",
            }
        )
    }
}

#[derive(Debug, Clone, ValueEnum)]
pub enum LogDest {
    Stderr,
    Stdout,
}

impl Default for LogDest {
    fn default() -> Self {
        Self::Stderr
    }
}

impl Display for LogDest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                Self::Stderr => "stderr",
                Self::Stdout => "stdout",
            }
        )
    }
}

// important shit
impl App {
    pub fn console_layer<S>(
        &self,
        writer: NonBlocking,
    ) -> Option<Box<dyn Layer<S> + Send + Sync + 'static>>
    where
        S: Subscriber + for<'span> LookupSpan<'span>,
    {
        match self.format {
            // FIXME missing is extra field configuration per type of layer, with type erasure we
            //       can't configure them
            LogFormat::Full => Some(
                tracing_subscriber::fmt::layer::<S>()
                    .with_writer(writer)
                    .boxed(),
            ),
            LogFormat::Pretty => Some(
                tracing_subscriber::fmt::layer::<S>()
                    .pretty()
                    .with_writer(writer)
                    .boxed(),
            ),
            LogFormat::Compact => Some(
                tracing_subscriber::fmt::layer::<S>()
                    .compact()
                    .with_writer(writer)
                    .boxed(),
            ),
            LogFormat::Json => Some(
                tracing_subscriber::fmt::layer::<S>()
                    .json()
                    .with_writer(writer)
                    .boxed(),
            ),
            LogFormat::Off => None,
        }
    }

    pub fn writer(&self) -> (NonBlocking, WorkerGuard) {
        match self.dest {
            LogDest::Stderr => tracing_appender::non_blocking(std::io::stderr()),
            LogDest::Stdout => tracing_appender::non_blocking(std::io::stdout()),
        }
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let args = App::parse();

    eprintln!("{args:?}");

    let filter = EnvFilter::from_default_env();
    let (writer, guard) = args.writer();
    let formatter = args.console_layer(writer);

    // layer filters first and then outputs (if an output is defined)
    let layer = tracing_subscriber::registry().with(filter).with(formatter);

    tracing::subscriber::set_global_default(layer).expect("unable to register telemetry");

    // if we hit a panic, send it to tracing as well
    std::panic::set_hook(Box::new(move |panic_info| match panic_info.location() {
        Some(location) => {
            tracing::error!(
                "panic at {}:{}: {}",
                location.file(),
                location.line(),
                panic_info
            )
        }
        None => {
            tracing::error!("panic: {}", panic_info)
        }
    }));

    // test the logging
    tracing::trace!(param = "a", "it's a trace");
    tracing::debug!(param = "b", "it's a debug");
    tracing::info!(param = "c", "it's an info");
    tracing::warn!(param = "d", "it's a warning");
    tracing::error!(param = "e", "it's an error");

    // manual clean up
    drop(guard);

    Ok(())
}

Now, within main, I'm attempting to add a JSON file appender and my code has been updated to the following:

#[tokio::main]
async fn main() -> Result<()> {
    let args = App::parse();

    eprintln!("{args:?}");

    let filter = EnvFilter::from_default_env();
    let (writer, guard) = args.writer();
    let formatter = args.console_layer(writer);

    // ERROR here in calling the boxed method
    let layer = tracing_subscriber::registry().with(filter).with(formatter).boxed();

    let file_layer = Some(tracing_subscriber::fmt::layer()
        .json()
        .with_writer(File::create("log.json").unwrap())
        .boxed());

    tracing::subscriber::set_global_default(
        tracing_subscriber::registry().with(layer).with(file_layer),
    )
        .expect("unable to register telemetry");
}

The massive type failure is:

error[E0599]: the method `boxed` exists for struct `Layered<Option<Box<dyn Layer<Layered<..., ...>> + Send + Sync>>, ...>`, but its trait bounds were not satisfied
   --> examples/logconfig.rs:135:77
    |
135 |     let layer = tracing_subscriber::registry().with(filter).with(formatter).boxed();
    |                                                                             ^^^^^ method cannot be called due to unsatisfied trait bounds
    |
   ::: /home/naftuli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-subscriber-0.3.19/src/layer/layered.rs:22:1
    |
22  | pub struct Layered<L, I, S = I> {
    | ------------------------------- doesn't satisfy `_: Layer<Layered<EnvFilter, Registry>>`
    |
    = note: the full type name has been written to '/home/naftuli/devel/src/github.com/naftulikay/slowburn/target/debug/examples/logconfig-b1e66d936692f11a.long-type-9788004849417096537.txt'
    = note: consider using `--verbose` to print the full type name to the console
    = note: the following trait bounds were not satisfied:
            `Layered<EnvFilter, Registry>: __tracing_subscriber_Layer<Layered<EnvFilter, Registry>>`
            which is required by `Layered<Option<Box<dyn __tracing_subscriber_Layer<Layered<EnvFilter, Registry>> + Send + Sync>>, Layered<EnvFilter, Registry>>: __tracing_subscriber_Layer<Layered<EnvFilter, Registry>>`

For more information about this error, try `rustc --explain E0599`.

This seems it might be related to the EnvFilter but I'm not sure. As shown above, if I only have one logical layer (filter to output), I don't get the compiler error. As soon as I attempt to have another parallel layer, that's when everything descends into madness.

I've shed a lot of blood getting to this point, I've made many attempts over probably more than a year to get here to actually be able to vary the output format successfully.

I've tried putting the EnvFilter in an Arc and that didn't seem to do the trick, I don't think with will accept an Arc. I assume that I might need to do additional boxing for type erasure but I'm not sure where to start.

If I'm able to get this working, I'll definitely open source it since getting a proper tracing setup for a basic CLI application has been a huge challenge for me and I want to save others the time and effort if possible (including myself from ever having to repeat this again).

The important part of this error message is:

   ::: /home/naftuli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-subscriber-0.3.19/src/layer/layered.rs:22:1
    |
22  | pub struct Layered<L, I, S = I> {
    | ------------------------------- doesn't satisfy `_: Layer<Layered<EnvFilter, Registry>>`

Looking at the docs for Layered, we can see the constraints that must be met for it to implement Layer: Layered in tracing_subscriber::layer - Rust

impl<S, A, B> Layer<S> for Layered<A, B, S>
where
    A: Layer<S>,
    B: Layer<S>,
    S: Subscriber,

B in this case is Registry. In order for Layered to implement Layer, Registry must implement Layer. It does not. It implements Subscriber and therefore implements SubscriberExt.

In other words, you can get a Layered from Registry, but it will be unable to implement the Layer trait. Perhaps you didn't mean to use Registry twice?

-    let layer = tracing_subscriber::registry().with(filter).with(formatter).boxed();
+    let layer = filter.with_subscriber(formatter).boxed();

I haven't tried this, but it seems like it should work.

1 Like

Thanks for helping me break this problem down, I greatly appreciate it.

In other words, you can get a Layered from Registry , but it will be unable to implement the Layer trait. Perhaps you didn't mean to use Registry twice?

So my goal is to have the following separate sets of layers as parallel subscribers that exist independently from one another largely for filtering. Logs going to the console should have a filter that is separate from the filter that goes to OpenTelemetry. Something like this:

tracing --> console filter --> console output
        --> otel filter --> otel output

I assumed a registry was the way to go about having an independent set of layers. I'll try out your example, thanks again!

Is there a tracing book out there somewhere so I can get more intimately familiar with all the types and how they work together? The amount of types feels rather daunting to be honest and I'd like to get a more practical sense of things so I don't find myself lost as much.

Trying this out:

let (writer, guard) = args.writer();
let filter: EnvFilter = EnvFilter::from_default_env();
let formatter: Option<Box<dyn Layer<_> + Send + Sync + 'static>> = args.console_layer(writer);

// layer filters first and then outputs (if an output is defined)
let layer = filter.with_subscriber(formatter).boxed();

I think the issue is that formatter is an Option:

error[E0277]: the trait bound `Option<Box<dyn __tracing_subscriber_Layer<_> + Send + Sync>>: tracing_core::Subscriber` is not satisfied
    --> examples/logconfig.rs:134:40
     |
134  |     let layer = filter.with_subscriber(formatter).boxed();
     |                        --------------- ^^^^^^^^^ the trait `tracing_core::Subscriber` is not implemented for `Option<Box<dyn __tracing_subscriber_Layer<_> + Send + Sync>>`
     |                        |
     |                        required by a bound introduced by this call
     |
     = help: the following other types implement trait `tracing_core::Subscriber`:
               Arc<S>
               Box<S>
               FmtSubscriber<N, E, F, W>
               Layered<L, S>
               NoSubscriber
               Registry
note: required by a bound in `tracing_subscriber::Layer::with_subscriber`
    --> /home/naftuli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-subscriber-0.3.19/src/layer/mod.rs:730:8
     |
730  |     S: Subscriber,
     |        ^^^^^^^^^^ required by this bound in `Layer::with_subscriber`
...
1092 |     fn with_subscriber(mut self, mut inner: S) -> Layered<Self, S>
     |        --------------- required by a bound in this associated function

I tried removing the option by using NoSubscriber but I get similar errors.

Would the solution possibly be to build my own Layer implementation that just composes the filter and the optional subscriber?

Try something like this (adatp the layers and filter to your needs):

let file_appender = tracing_rolling_file::RollingFileAppenderBase::new(
        "log.json",
        tracing_rolling_file::RollingConditionBase::new().max_size(LOG_MAX_FILE_SIZE),
        1,
    )
    .unwrap();
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);

let console_layer = tracing_subscriber::fmt::layer()
    .pretty()
    .with_file(true)
    .with_line_number(true)
    .with_target(true)
    .with_filter(tracing_subscriber::EnvFilter::from_default_env());

let file_layer = tracing_subscriber::fmt::layer()
    .with_writer(non_blocking)
    .json()
    .with_file(true)
    .with_line_number(true)
    .with_target(true)
    .with_span_list(true)
    .with_filter(tracing_subscriber::filter::LevelFilter::INFO);

tracing_subscriber::registry()
    .with(console_layer)
    .with(file_layer)
    .init();

I was able to at least get the first layer (console) working using the following:

let (writer, guard) = args.writer();

let console_filter: EnvFilter = EnvFilter::from_default_env();

let console_layer: Option<Box<dyn Layer<_> + Send + Sync + 'static>> = args
    .console_layer(writer)
    .map(|f| f.with_filter(console_filter).boxed());

tracing::subscriber::set_global_default(Registry::default().with(console_layer))
        .expect("unable to register telemetry");

I'm going to try to set up the JSON file logger just to validate that it will be possible to integrate OTEL once I'm ready for that.

It seems that it's working! :tada:

let (writer, guard) = args.writer();

let console_filter: EnvFilter = EnvFilter::from_default_env();

let console_layer: Option<Box<dyn Layer<_> + Send + Sync + 'static>> = args
    .console_layer(writer)
    .map(|f| f.with_filter(console_filter).boxed());

let json_filter = EnvFilter::new("trace");
let json_file = File::create("log.json").unwrap();

let json_layer: Option<Box<dyn Layer<_> + Send + Sync + 'static>> = Some(
    tracing_subscriber::fmt::layer()
        .json()
        .with_writer(Arc::new(json_file))
        .with_filter(json_filter)
        .boxed(),
);

tracing::subscriber::set_global_default(
    Registry::default().with(console_layer).with(json_layer),
)
.expect("unable to register telemetry");

Sadly I don't understand yet how it's working. If there's a book on tracing I'd love to read it so I can get a handle on how all of this works typesystem-wise.


Update: I noticed that it has a compiler error if I'm not registering the layer with the registry, but if I am more specific with the Layer's subscriber generic, it does work:

// note we specify Layer<Registry> here
let json_layer: Option<Box<dyn Layer<Registry> + Send + Sync + 'static>> = Some(
    tracing_subscriber::fmt::layer()
        .json()
        .with_writer(Arc::new(json_file))
        .with_filter(json_filter)
        .boxed(),
);

I have updated the gist to include my final code in case anyone could benefit from this.

Posting it here also:

//! This quick CLI demo shows how to set up tracing for console logging with a custom format defined
//! at runtime. We *must* do type erasure here by putting each "format" (layer) inside of a Box, but
//! it does indeed work.

// :dep clap = { version = "4", features = ["derive"] }
// :dep sysexits = "0.9"
// :dep tokio = { version = "1", features = ["full"] }
// :dep tracing = "0.1"
// :dep tracing-appender = { version = "0.2", features = ["parking_lot"] }
// :dep tracing-core = "0.1"
// :dep tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }

use clap::{Parser, ValueEnum};
use std::fmt::Display;
use std::fs::File;
use std::sync::Arc;
use sysexits::Result;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_core::Subscriber;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::{EnvFilter, Layer, Registry};

#[derive(Debug, Clone, Parser)]
pub struct App {
    #[clap(short = 'F', long = "format", default_value_t)]
    format: LogFormat,
    #[clap(short = 'D', long = "dest", name = "DESTINATION", default_value_t)]
    dest: LogDest,
}

#[derive(Debug, Clone, ValueEnum)]
pub enum LogFormat {
    Full,
    Pretty,
    Compact,
    Json,
    Off,
}

impl Default for LogFormat {
    fn default() -> Self {
        Self::Full
    }
}

impl Display for LogFormat {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                Self::Full => "full",
                Self::Pretty => "pretty",
                Self::Compact => "compact",
                Self::Json => "json",
                Self::Off => "off",
            }
        )
    }
}

#[derive(Debug, Clone, ValueEnum)]
pub enum LogDest {
    Stderr,
    Stdout,
}

impl Default for LogDest {
    fn default() -> Self {
        Self::Stderr
    }
}

impl Display for LogDest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                Self::Stderr => "stderr",
                Self::Stdout => "stdout",
            }
        )
    }
}

// important shit
impl App {
    pub fn console_layer<S>(
        &self,
        writer: NonBlocking,
    ) -> Option<Box<dyn Layer<S> + Send + Sync + 'static>>
    where
        S: Subscriber + for<'span> LookupSpan<'span>,
    {
        match self.format {
            // FIXME missing is extra field configuration per type of layer, with type erasure we
            //       can't configure them
            LogFormat::Full => Some(
                tracing_subscriber::fmt::layer::<S>()
                    .with_writer(writer)
                    .boxed(),
            ),
            LogFormat::Pretty => Some(
                tracing_subscriber::fmt::layer::<S>()
                    .pretty()
                    .with_writer(writer)
                    .boxed(),
            ),
            LogFormat::Compact => Some(
                tracing_subscriber::fmt::layer::<S>()
                    .compact()
                    .with_writer(writer)
                    .boxed(),
            ),
            LogFormat::Json => Some(
                tracing_subscriber::fmt::layer::<S>()
                    .json()
                    .with_writer(writer)
                    .boxed(),
            ),
            LogFormat::Off => None,
        }
    }

    pub fn writer(&self) -> (NonBlocking, WorkerGuard) {
        match self.dest {
            LogDest::Stderr => tracing_appender::non_blocking(std::io::stderr()),
            LogDest::Stdout => tracing_appender::non_blocking(std::io::stdout()),
        }
    }
}

type BoxedLayer<S> = Box<dyn Layer<S> + Send + Sync + 'static>;

#[tokio::main]
async fn main() -> Result<()> {
    let args = App::parse();

    eprintln!("{args:?}");

    let (writer, guard) = args.writer();

    let console_filter: EnvFilter = EnvFilter::from_default_env();

    let console_layer: Option<Box<dyn Layer<_> + Send + Sync + 'static>> = args
        .console_layer(writer)
        .map(|f| f.with_filter(console_filter).boxed());

    let json_filter = EnvFilter::new("trace");
    let json_file = File::options().create(true).append(true).open("log.json").unwrap();

    let json_layer: Option<Box<dyn Layer<_> + Send + Sync + 'static>> = Some(
        tracing_subscriber::fmt::layer()
            .json()
            .with_writer(Arc::new(json_file))
            .with_filter(json_filter)
            .boxed(),
    );

    tracing::subscriber::set_global_default(
        Registry::default().with(console_layer).with(json_layer),
    )
        .expect("unable to register telemetry");

    // if we hit a panic, send it to tracing as well
    std::panic::set_hook(Box::new(move |panic_info| match panic_info.location() {
        Some(location) => {
            tracing::error!(
                "panic at {}:{}: {}",
                location.file(),
                location.line(),
                panic_info
            )
        }
        None => {
            tracing::error!("panic: {}", panic_info)
        }
    }));

    // test the logging
    tracing::trace!(param = "a", "it's a trace");
    tracing::debug!(param = "b", "it's a debug");
    tracing::info!(param = "c", "it's an info");
    tracing::warn!(param = "d", "it's a warning");
    tracing::error!(param = "e", "it's an error");

    // manual clean up
    drop(guard);

    Ok(())
}

:disappointed_face:

I wish I had a good answer for this! There are a few crates where this kind of type complexity happens. axum and plotters come to mind as examples of trait-heavy interfaces that are difficult to figure out.

To help with troubleshooting, I am using docs.rs almost exclusively. And just trying my best to do deductive reasoning. I've only used tracing very lightly.

One of the changes I can see is that you are now passing the EnvFilter to a layer's filter with with_filter() instead of a subscriber's layer with with(). (Writing this out makes me sound like I've lost my mind.)

Interesting that it can be composed with a subscriber as a layer, but is that what broke the original code? That's a very surprising non-composition that is partially allowed, if not for the constraints failing to be satisfied.

Yeah, hopefully a book will be made at some point but I know tracing is still 0.1 so anything and everything could change.

I'm also conscious of the fact that there's at least some hit to performance (2x pointer lookup, first to the struct itself and then to the trait vtable) by using type-erasure with Box<dyn Thing> but I'm not sure how hard it will be to wrangle things back into fixed types. I'd really like to do this if at all possible.

The type-hell issue seems to rear its head whenever I'm trying to make configuring tracing modular, i.e. not just doing everything inside of a single function but splitting out creation of layers/formatters/subscribers into separate methods. As soon as you try to actually define type signatures in function return types, things get insane very quickly and you'll get generic types which include 7-10+ parameters.

My specific motivations are that I don't want to spend 8 hours bootstrapping a basic multi-level CLI tool so I'm trying like clientele to optimize this so that it makes actual sense:

  1. a trait named Command which has a pre_exec, exec, and post_exec set of methods with the user only needing to implement exec to run a command.
  2. a proc-macro trait called ParentCommand: Command which will generate the exec body for a parent command with multiple sub-commands (i.e. my-cli api users list) in a macro.
  3. utilize sysexits::Result as the return type for system friendly exit codes.

Essentially something like this:

pub trait Command {
  async fn exec(&self) -> sysexits::Result<()>;
}

// ParentCommand is a proc macro to generate an implementation of exec that executes
// whatever subcommand is passed provided it is a Command

#[derive(Debug, Clone, clap::Parser, ParentCommand)]
pub struct RootCommand {
    #[arg(short = 'L', long = "logger")]
    cli_log_levels: Vec<Directive>,
    // ParentCommand macro will look for a field labeled as subcommands and will generate a
    // simple exec method with a `match` block that will execute whatever command is issued
    #[command(subcommand)]
    command: RootCommands,
}

#[derive(Debug, Clone, clap::Parser]
pub enum RootCommands {
    // each variant must contain a Command for this to work
    List(ListCommand),
}

#[derive(Debug, Clone, clap::Parser]
pub struct ListCommand {
    format: String,
}

impl Command for ListCommand {
    async fn exec(&self) -> sysexits::Result<()> {
        eprintln!("Executing list command: {}", self.format);
        Ok(())
    }
}

#[tokio::main]
async fn main() -> sysexits::Result<()> {
    RootCommand::parse().exec().await
}

Trying to reduce boilerplate for highly structured subcommand hierarchies.

And then of course, the telemetry part of it will likewise try to be as simple as possible: support an enum of the available log formats to be configured by a CLI param or environment variable, and have a easy way to configure an EnvFilter from various sources:

TelemetryConfig::builder()
    .console(ConsoleConfig::builder()
        .format(Format::Pretty)
        .build())
    .console_filter(TelemetryFilter::builder()
        .crate(env!("CARGO_CRATE_NAME"), Level::Debug)
        .default_directives(&["tower=warn"])
        .files(&["logging.properties"])
        .env_var("RUST_LOG")
        .cli_directives(app.cli_directives))
    .build()

I've set up tracing many many times for many different CLIs and I always get snagged on the details of setting up console format/filter, and OpenTelemetry is its own nightmare to set up. Hopefully as I have time I'll be able to finish out this work I'll be able to open source things so that I never have to spend hours getting tracing working before being able to write any actual CLI code.

When you get it working, tracing is awesome especially with OpenTelemetry, but it's a TON of overhead and takes a lot of work to get something duct-taped together that doesn't break the typesystem.

I've dealt with this problem in what I imagine is a similar scale (thousands of servers doing their own logging/metrics) and what worked well (at the time) was removing all of the configurable stuff from each service. They just dump every piece of data that could be interesting, and a separate processor would take care of the filtering, formatting, and submitting telemetry.

It requires extremely low-overhead and compact serialization when every request generates dozens or hundreds of log lines (in the millions of log lines per second, per service). You have to be diligent about what you log, too. Just like you need to be aware of costs associated with storing and aggregating metrics, discarding the ones you never use. It's a hard problem! But moving this concern out of each individual service paid back dividends.

The basic options are stream processing or writing a columnar storage format for batch processing. There's still a load of complexity, but it's external to your program.

On the topic of low-overhead tracing, I was made aware of this crate a few weeks ago: fastrace - crates.io: Rust Package Registry. It might not be applicable here, but I was intrigued by the attention to detail. And it's definitely more suitable to the "just log everything but be very efficient about it" model than allowing anything and everything to be configurable at runtime. (I haven't used it, though!)

Definitely. I spent some time last year getting tracing with OpenTelemetry for a basic Axum web service in Rust working with Datadog's OTEL collector, and it's invaluable once you do get it working. Seeing traces propagate throughout the system, logs and metrics associated with those spans, it's incredible, and I'm really hoping to make getting that working a lot easier for myself and for anyone else who needs to do it.

Part of the motivation behind having (up to) two outputs is to serve different purposes: when you're running an application locally without OTEL, console logging is critical for the development lifecycle, when you're running your application deployed, turning console logging off and sending everything to OpenTelemetry is likely what you want.

That's true enough! I want to say I rely on unit tests for most of my service development, but that isn't always the case. It's just in the ballpark of ideal. Tracing/logging in a human-consumable manner is beneficial in many ways. My suggestion with an external processor was not to dismiss these use cases. It's always possible to run a local processor to watch the log stream while developing.

Food for thought! As always, there are tradeoffs. I figured I would suggest alternatives that can help avoid "type hell".

I was able to get it working without type erasure :tada:

use clap::{Parser, ValueEnum};
use std::cmp::PartialEq;
use std::fmt::Display;
use std::fs::File;
use std::sync::Arc;
use tracing_appender::non_blocking::NonBlocking;
use tracing_core::span::{Attributes, Id, Record};
use tracing_core::{Event, Interest, LevelFilter, Metadata, Subscriber};
use tracing_subscriber::filter::Directive;
use tracing_subscriber::fmt::format::{Compact, DefaultFields, Format, Json, JsonFields, Pretty};
use tracing_subscriber::layer::{Context, Filter, SubscriberExt};
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::{fmt, EnvFilter, Layer, Registry};

#[derive(Debug, Clone, Parser)]
struct App {
    #[clap(short = 'F', long = "format", default_value_t)]
    format: LogFormat,
    #[clap(short = 'l', long = "level", name = "DIRECTIVE")]
    directives: Vec<Directive>,
}

#[derive(Debug, Clone, Eq, PartialEq, ValueEnum)]
enum LogFormat {
    Full,
    Pretty,
    Compact,
    Json,
    Off,
}

impl Default for LogFormat {
    fn default() -> Self {
        Self::Full
    }
}

impl Display for LogFormat {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                Self::Full => "full",
                Self::Pretty => "pretty",
                Self::Compact => "compact",
                Self::Json => "json",
                Self::Off => "off",
            }
        )
    }
}

/// A simple `Arc<EnvFilter>` wrapper that implements `Filter<S>`.
#[derive(Debug, Clone)]
pub struct SharedEnvFilter(Arc<EnvFilter>);

impl<S> Filter<S> for SharedEnvFilter
where
    S: Subscriber + for<'span> LookupSpan<'span>,
{
    fn enabled(&self, meta: &Metadata<'_>, cx: &Context<'_, S>) -> bool {
        <EnvFilter as Filter<S>>::enabled(&self.0, meta, cx)
    }

    fn callsite_enabled(&self, meta: &'static Metadata<'static>) -> Interest {
        <EnvFilter as Filter<S>>::callsite_enabled(&self.0, meta)
    }

    fn event_enabled(&self, event: &Event<'_>, cx: &Context<'_, S>) -> bool {
        <EnvFilter as Filter<S>>::event_enabled(&self.0, event, cx)
    }

    fn max_level_hint(&self) -> Option<LevelFilter> {
        <EnvFilter as Filter<S>>::max_level_hint(&self.0)
    }

    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
        <EnvFilter as Filter<S>>::on_new_span(&self.0, attrs, id, ctx)
    }

    fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) {
        <EnvFilter as Filter<S>>::on_record(&self.0, id, values, ctx)
    }

    fn on_enter(&self, id: &Id, ctx: Context<'_, S>) {
        <EnvFilter as Filter<S>>::on_enter(&self.0, id, ctx)
    }

    fn on_exit(&self, id: &Id, ctx: Context<'_, S>) {
        <EnvFilter as Filter<S>>::on_exit(&self.0, id, ctx)
    }

    fn on_close(&self, id: Id, ctx: Context<'_, S>) {
        <EnvFilter as Filter<S>>::on_close(&self.0, id, ctx)
    }
}

#[tokio::main]
async fn main() -> sysexits::Result<()> {
    let args = App::parse();

    eprintln!("{args:?}");

    let (writer, _guard) = tracing_appender::non_blocking(std::io::stderr());

    // simple Arc<EnvFilter> + implementation of Filter<S>; use the RUST_LOG env var and any
    // directives passed on the CLI
    let console_filter = SharedEnvFilter(Arc::new(
        args.directives
            .into_iter()
            .fold(EnvFilter::from_default_env(), |f, d| f.add_directive(d)),
    ));

    // NOTE in order to not have to box everything (i.e. use concrete types), we use options and
    //      register *all of them* with only one of them being a Some
    let console_full = if args.format == LogFormat::Full {
        Some(generate_full(writer.clone()))
    } else {
        None
    };

    let console_pretty = if args.format == LogFormat::Pretty {
        Some(generate_pretty(writer.clone()))
    } else {
        None
    };

    let console_compact = if args.format == LogFormat::Compact {
        Some(generate_compact(writer.clone()))
    } else {
        None
    };

    let console_json = if args.format == LogFormat::Json {
        Some(generate_json(writer.clone()))
    } else {
        None
    };

    // parallel output layer to json file
    let json_filter = EnvFilter::new("trace");
    let json_file = File::options()
        .create(true)
        .append(true)
        .open("log.json")
        .unwrap();

    let json_file_layer = tracing_subscriber::fmt::layer()
        .json()
        .with_writer(Arc::new(json_file))
        .with_filter(json_filter);

    tracing::subscriber::set_global_default(
        Registry::default()
            .with(console_full.map(|l| l.with_filter(console_filter.clone())))
            .with(console_pretty.map(|l| l.with_filter(console_filter.clone())))
            .with(console_compact.map(|l| l.with_filter(console_filter.clone())))
            .with(console_json.map(|l| l.with_filter(console_filter.clone())))
            .with(json_file_layer),
    )
        .unwrap();

    tracing::trace!(param_a = "a", "trace");
    tracing::debug!(param_b = "b", "debug");
    tracing::info!(param_c = "c", "debug");
    tracing::warn!(param_d = "d", "warn");
    tracing::error!(param_e = "e", "error");

    Ok(())
}

fn generate_full<S>(writer: NonBlocking) -> fmt::Layer<S, DefaultFields, Format, NonBlocking>
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    tracing_subscriber::fmt::layer::<S>().with_writer(writer)
}

fn generate_pretty<S>(writer: NonBlocking) -> fmt::Layer<S, Pretty, Format<Pretty>, NonBlocking>
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    tracing_subscriber::fmt::layer::<S>()
        .pretty()
        .with_writer(writer)
}

fn generate_compact<S>(
    writer: NonBlocking,
) -> fmt::Layer<S, DefaultFields, Format<Compact>, NonBlocking>
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    tracing_subscriber::fmt::layer::<S>()
        .compact()
        .with_writer(writer)
}

fn generate_json<S>(writer: NonBlocking) -> fmt::Layer<S, JsonFields, Format<Json>, NonBlocking>
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    tracing_subscriber::fmt::layer::<S>()
        .json()
        .with_writer(writer)
}

Of note:

  1. As per the tracing_subscriber::layer docs, we keep consistent types by registering an Option of each output format. Only one of them is actually going to be Some at runtime, but since the types diverge, we must register all of them.
  2. EnvFilter is not Clone so we create a new type SharedEnvFilter which simply wraps an Arc<EnvFilter> and proxies all methods to it. We need to "use" the filter in multiple places for each of the various console formats but only one of these will be actually needed, hence Arc, should be zero overhead and optimized away.
  3. One thing of note: if you let console_x = generate_x(...), this will generate a type error unless you actually use the variable in registering it. This is because S: Subscriber and it is not defined until it is used in Registry::with.
  4. Similarly to the library I am working on, you can set logging level using RUST_LOG environment variable or you can pass directives on the command-line via -l my_logger=trace -l my_logger::submodule=warn.

It's nice to feel like I finally have a handle on all of this and I hope that any other lost soul will be able to find this and get logging working without losing hours and hours to it :smiley:

3 Likes