Configure logging for a Rust service from a YAML file

For Rust developers who want log levels and outputs they can change without a rebuild

You build a small orders program that logs through both log and tracing, prints readable lines to the console, writes JSON to a rolling log file and gives one module its own level, all driven by fibre_logging.yaml.

Before you start

You need Rust with Cargo. fibre_logging comes from crates.io:

fibre_logging = "0"

Create the project#

Start a new binary crate and add fibre_logging together with the two logging facades.

cargo new orders
cd orders
Cargo.toml
[dependencies]
fibre_logging = "0"
log = "0.4"
tracing = "0.1"

fibre_logging installs itself as the backend for both log and tracing, so you call their macros directly and never call a fibre_logging macro.

Log to the console#

Create fibre_logging.yaml in the project root, next to Cargo.toml. It defines one appender called console and a root logger that sends everything at info or above to it.

fibre_logging.yaml
version: 1

appenders:
  console:
    kind: console
    encoder:
      kind: pattern
      pattern: "%d{%H:%M:%S} %-5p %t - %m%n"

loggers:
  root:
    level: info
    appenders: [console]

In the pattern, %d{...} is the timestamp with a strftime format, %-5p is the level padded to five characters, %t is the target (the module path), %m is the message and %n is a newline.

Now replace src/main.rs with this:

src/main.rs
use std::path::Path;

fn main() {
    let _logging = fibre_logging::init::init_from_file(Path::new("fibre_logging.yaml"))
        .expect("failed to start logging");

    log::info!("orders service starting");
    tracing::info!("listening on port {}", 8080);
    log::debug!("this debug line is below the root level");
}

init_from_file returns an InitResult. Bind it to a named variable such as _logging so it lives until the end of main. Dropping it shuts down the background writer threads and flushes whatever is still buffered. If you write let _ = ... instead, it is dropped straight away.

$ cargo run
14:45:51 INFO  orders - orders service starting
14:45:51 INFO  orders - listening on port 8080

Your timestamps will differ. The log line and the tracing line come out in the same format. The debug line is missing because the root level is info.

Log from modules with both macros#

Targets come from module paths, so give the program some modules. db logs its query with log::debug! and a row count with tracing::trace!. api uses tracing::info! with a structured order_id field and log::warn! for large orders.

src/main.rs
use std::path::Path;

mod db {
    pub fn load_order(id: u32) -> u32 {
        log::debug!("SELECT * FROM orders WHERE id = {id}");
        tracing::trace!(rows = 1, "query finished");
        id * 100
    }
}

mod api {
    use crate::db;

    pub fn handle_request(id: u32) {
        tracing::info!(order_id = id, "handling order {id}");
        let cents = db::load_order(id);
        if cents > 250 {
            log::warn!("order {id} is over the review limit ({cents} cents)");
        }
    }
}

fn main() {
    let _logging = fibre_logging::init::init_from_file(Path::new("fibre_logging.yaml"))
        .expect("failed to start logging");

    log::info!("orders service starting");
    for id in 1..=3 {
        api::handle_request(id);
    }
    log::info!("orders service stopping");
}
$ cargo run
14:45:58 INFO  orders - orders service starting
14:45:58 INFO  orders::api - handling order 1
14:45:58 INFO  orders::api - handling order 2
14:45:58 INFO  orders::api - handling order 3
14:45:58 WARN  orders::api - order 3 is over the review limit (300 cents)
14:45:58 INFO  orders - orders service stopping

Each line now shows the module it came from. Nothing from orders::db appears because both of its events are below info.

Give one module its own level#

Add a logger named after the module you want to hear more from. Logger names match targets by their :: prefix, so orders::db covers that module and anything nested inside it. Add this under loggers: in fibre_logging.yaml:

fibre_logging.yaml
  orders::db:
    level: debug

The module logger only sets a level. Loggers are additive by default (additive: true), so events it lets through are handed to the root logger's appenders and reach the console.

$ cargo run
14:46:03 INFO  orders - orders service starting
14:46:03 INFO  orders::api - handling order 1
14:46:03 DEBUG orders::db - SELECT * FROM orders WHERE id = 1
14:46:03 INFO  orders::api - handling order 2
14:46:03 DEBUG orders::db - SELECT * FROM orders WHERE id = 2
14:46:03 INFO  orders::api - handling order 3
14:46:03 DEBUG orders::db - SELECT * FROM orders WHERE id = 3
14:46:03 WARN  orders::api - order 3 is over the review limit (300 cents)
14:46:03 INFO  orders - orders service stopping

The SQL lines show up for orders::db only. The rest of the program stays at info.

Add a rolling log file#

For a file that tools can read, add a rolling_file appender with the json_lines encoder and give it to the root logger. Here is the whole fibre_logging.yaml:

fibre_logging.yaml
version: 1

appenders:
  console:
    kind: console
    encoder:
      kind: pattern
      pattern: "%d{%H:%M:%S} %-5p %t - %m%n"

  file:
    kind: rolling_file
    directory: "logs"
    file_name_prefix: "orders"
    file_name_suffix: ".log"
    encoder:
      kind: json_lines
    policy:
      time_granularity: daily
      max_file_size: "10MB"
      max_retained_sequences: 7

loggers:
  root:
    level: info
    appenders: [console, file]

  orders::db:
    level: debug

orders::db still only sets a level. Its events reach both the console and the file through the root logger.

The policy rolls the file once a day or when it passes 10MB (whichever comes first) and keeps the 7 most recent rolled files. time_granularity also accepts minutely, hourly and never. The logs directory is created for you.

cargo run
ls logs
head -n 2 logs/orders.log

The console output is the same as in the previous step. The file holds one JSON object per event:

orders.log
{"fields":{"file":"src/main.rs","line":27,"module_path":"orders"},"level":"INFO","message":"orders service starting","name":"orders","target":"orders","timestamp":"2026-09-23T14:46:15.526Z"}
{"fields":{"order_id":1},"level":"INFO","message":"handling order 1","name":"event src/main.rs:15","target":"orders::api","thread_id":"1","thread_name":"main","timestamp":"2026-09-23T14:46:15.527Z"}

The order_id = id field from tracing::info! is kept as a real field under fields, which is what you filter on in a log search tool. Events from the log macros carry the source file, line and module path there instead. If you want the fields at the top level of each object, set flatten_fields: true on the json_lines encoder.

Change the config without recompiling#

So far the path fibre_logging.yaml has been hardcoded. find_config_file looks in the current directory instead. When the FIBRE_ENV or APP_ENV variable is set, it picks fibre_logging.<env>.yaml if that file exists. Replace main and drop the use std::path::Path; line at the top:

fibre_logging.yaml
fn main() {
    let config = fibre_logging::init::find_config_file(None).expect("no logging config found");
    let _logging = fibre_logging::init::init_from_file(&config).expect("failed to start logging");

    log::info!("logging configured from {}", config.display());

    log::info!("orders service starting");
    for id in 1..=3 {
        api::handle_request(id);
    }
    log::info!("orders service stopping");
}

Build it once:

cargo build

From here on, run the compiled binary directly so you can see that nothing is rebuilt. Open fibre_logging.yaml and change the orders::db level from debug to trace:

fibre_logging.yaml
  orders::db:
    level: trace
$ ./target/debug/orders
14:46:30 INFO  orders - logging configured from ./fibre_logging.yaml
14:46:30 INFO  orders - orders service starting
14:46:30 INFO  orders::api - handling order 1
14:46:30 DEBUG orders::db - SELECT * FROM orders WHERE id = 1
14:46:30 TRACE orders::db - query finished
14:46:30 INFO  orders::api - handling order 2
...

The tracing::trace! line from db now appears. Set the level back to debug before moving on.

Next, keep the SQL out of the console but still in the file. Point orders::db at the file only and set additive: false so its events are not also handed to the root logger's appenders:

fibre_logging.yaml
  orders::db:
    level: debug
    appenders: [file]
    additive: false
$ ./target/debug/orders
14:46:37 INFO  orders - logging configured from ./fibre_logging.yaml
14:46:37 INFO  orders - orders service starting
14:46:37 INFO  orders::api - handling order 1
14:46:37 INFO  orders::api - handling order 2
14:46:37 INFO  orders::api - handling order 3
14:46:37 WARN  orders::api - order 3 is over the review limit (300 cents)
14:46:37 INFO  orders - orders service stopping

The three DEBUG orders::db lines are still written to logs/orders.log.

Finally, add a second config for a quiet environment. Save this as fibre_logging.quiet.yaml next to the first one:

fibre_logging.quiet.yaml
version: 1

appenders:
  console:
    kind: console
    encoder:
      kind: pattern
      pattern: "%-5p %t - %m%n"

loggers:
  root:
    level: warn
    appenders: [console]
$ FIBRE_ENV=quiet ./target/debug/orders
WARN  orders::api - order 3 is over the review limit (300 cents)

The same binary now prints only warnings, with no timestamp and no log file. If FIBRE_ENV names an environment that has no file, find_config_file falls back to fibre_logging.yaml.

Next steps#

The usage guide on GitHub covers the other appenders (file, custom for reading events inside your application and the debug-only debug_report), internal error reporting, tracing spans in JSON output and the tokio-console feature: https://github.com/excsn/fibre/blob/main/logging/README.USAGE.md

The API reference is on docs.rs: https://docs.rs/fibre_logging