Building sheen: Bringing charmbracelet/log to Rust

Recently I decided to start building a web framework in Rust. I know, there are lots of Rust web frameworks out there. But lately, I was kind of uncomfortable with how backend frameworks evolved.
I worked with different ones — Rails, Django, Loco — all of them work very well. Except Rails, they became flexible which sounds good, but that flexibility often turns into boilerplate, hidden complexity and inconsistent architectures. After 2023, adding AI writing portion of code transforms this into a mess.
So I decided to create Rapina, an opinionated Rust web framework exploring better DX in an AI-assisted world.
As I love open source, I was browsing and looking for some libraries to support me in the logging/tracing challenge. I was using tracing-subscriber from Rust — it's used in Loco. But then, I came into this one: charmbracelet/log.
I found it so special. But the bad side? Written in Go.
Damn. Why doesn't Rust have something this clean?
What made charmbracelet/log special
Looking at their README, a few things stood out:
- Ease of config — just works out of the box
- Beautiful UI — colorful, aligned, readable
- Very adaptable to any kind of project
- Flexible but not closed — you can customize everything without fighting the library
Their API is dead simple:
log.Info("Hello World!")
log.Error("failed to bake cookies", "err", err)And it just looks good. Colored levels, structured fields, timestamps. No ceremony.
Starting out
I decided to build it. Created the project:
cargo new sheen --libThe name "sheen" came from wanting something that evokes polished, glossy, refined output. And it was available on crates.io.
My first decision: what to build first?
I started with the Level enum. It's the foundation — everything else depends on it:
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
Trace,
Debug,
Info,
Warn,
Error,
}The key decision here: deriving PartialOrd and Ord means enum variants are ordered by declaration. So Level::Trace < Level::Debug < Level::Info. This makes filtering logs trivial:
pub fn enabled(&self, level: Level) -> bool {
level >= self.level
}No manual comparisons, no match statements. The type system does the work.
From there I built the Logger struct, added colors with owo-colors, then structured fields, timestamps, prefixes. Each feature small and incremental.
Architecture decisions
The Formatter trait
At first, all formatting logic lived inside the log() method. It worked, but I knew adding JSON output would mean ugly if/else blocks everywhere. Not scalable.
I wanted users to be able to swap formatters cleanly:
let logger = Logger::new().formatter(JsonFormatter);This meant introducing a trait:
pub trait Formatter: Send + Sync {
fn format(
&self,
level: Level,
message: &str,
timestamp: Option<&str>,
prefix: Option<&str>,
fields: &[(String, String)],
extra: &[(&str, &dyn Debug)],
) -> String;
}The Send + Sync bounds are required because the global logger lives in a static and might be accessed from multiple threads. Rust forces you to think about this upfront.
Box<dyn Formatter> — dynamic dispatch
I wanted to store any formatter in the Logger struct:
pub struct Logger {
level: Level,
formatter: dyn Formatter, // ❌ won't compile
}The problem: dyn Formatter could be TextFormatter (0 bytes) or JsonFormatter (maybe 8 bytes) or some user's custom formatter. Rust needs to know struct sizes at compile time.
The solution: Box<dyn Formatter>. Box puts the data on the heap and stores a pointer (always 8 bytes on 64-bit):
pub struct Logger {
level: Level,
formatter: Box<dyn Formatter>, // ✅ always 8 bytes
}Think of it like storing a locker number instead of the actual item. The locker number always fits in your pocket, regardless of what's inside the locker.
This is a classic Rust pattern for runtime polymorphism. The small overhead of heap allocation and dynamic dispatch is negligible for a logger.
Ergonomic macros
I wanted the API to feel natural:
sheen::info!("Server started", port = 3000, host = "localhost");The macro:
#[macro_export]
macro_rules! info {
($msg:expr) => {
$crate::global::logger().info($msg, &[])
};
($msg:expr, $($key:ident = $value:expr),* $(,)?) => {
$crate::global::logger().info(
$msg,
&[$(( stringify!($key), &$value as &dyn std::fmt::Debug )),*]
)
};
}stringify!($key) converts the identifier port to the string "port". The repetition pattern $(...),* handles zero or more key=value pairs. The trailing $(,)? allows an optional trailing comma.
Why sheen is a good project to learn Rust
If you're looking to level up your Rust skills, building a logging library covers a lot of ground:
Traits and generics — The Formatter trait teaches you how to design extensible APIs. You'll understand the difference between static dispatch (impl Trait) and dynamic dispatch (dyn Trait).
Ownership patterns — Box<dyn Trait> for owned trait objects, &dyn Debug for borrowed trait objects. You'll learn when to use each.
Macros — Declarative macros with macro_rules! are powerful. Building info!, debug!, etc. teaches pattern matching on syntax.
Builder pattern — Idiomatic Rust configuration:
Logger::new()
.level(Level::Debug)
.prefix("myapp")
.timestamp(true)Global state — Using OnceLock for safe, lazy initialization of a global logger.
TTY detection — std::io::IsTerminal for smart behavior (colors in terminal, plain text when piped).
The codebase is small enough to understand completely, but covers patterns you'll use in larger projects.
What's next
Features planned:
logcrate compatibility — work with existing Rust ecosystem- Custom color themes
- File output support
- More time format options
Check the issues — several are tagged good first issue if you want to contribute.
Try it
[dependencies]
sheen = "0.2"fn main() {
sheen::init();
sheen::info!("Hello from sheen", version = "0.2.0");
}sheen is inspired by charmbracelet/log. Thanks to them for showing what good logging DX looks like.