# 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 as `fibre_logging = "0"`.

## Create the project

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

```sh
cargo new orders
cd orders
```

```toml title="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.

```yaml title="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:

```rust title="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.

```shell-session
$ 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.

```rust title="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");
}
```

```shell-session
$ 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`:

```yaml title="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.

```shell-session
$ 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`:

```yaml title="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.

```sh
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:

```text
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:

```rust title="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:

```sh
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`:

```yaml title="fibre_logging.yaml"
  orders::db:
    level: trace
```

```shell-session
$ ./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:

```yaml title="fibre_logging.yaml"
  orders::db:
    level: debug
    appenders: [file]
    additive: false
```

```shell-session
$ ./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:

```yaml title="fibre_logging.quiet.yaml"
version: 1

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

loggers:
  root:
    level: warn
    appenders: [console]
```

```shell-session
$ 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](https://github.com/excsn/fibre/blob/main/logging/README.USAGE.md)

The API reference is on docs.rs: [https://docs.rs/fibre_logging](https://docs.rs/fibre_logging)
