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.
[]
= "0"
= "0.4"
= "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.
version: 1
appenders:
console:
kind: console
encoder:
kind: pattern
pattern: "%d{%H:%M:%S} %-5p %t - %m%n"
loggers:
root:
level: info
appenders: 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:
use Path;
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.
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.
use Path;
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:
orders::db:
level: debugThe 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.
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:
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:
orders::db:
level: debugorders::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.
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:
Build it once:
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:
orders::db:
level: trace
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:
orders::db:
level: debug
appenders:
additive: false
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:
version: 1
appenders:
console:
kind: console
encoder:
kind: pattern
pattern: "%-5p %t - %m%n"
loggers:
root:
level: warn
appenders:
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
