A structured JSON logger for Rust with zerolog-inspired ergonomics.
Every log call produces a single newline-terminated JSON object. Fields are typed
and appended via a fluent builder; nothing is written until .msg() or .send()
is called. The hot path is allocation-free in steady state.
Most Rust logging infrastructure falls into two camps: simple adapters for the
log crate façade, or tracing, which is the right choice when you need spans,
async instrumentation, or a large subscriber ecosystem.
wirelog is for a different situation: you want structured JSON output, a simple and predictable per-event API, and as little overhead as possible. If you've used zerolog in Go and want the same ergonomic model in Rust, this is it.
wirelog is not a replacement for tracing. It has no span concept, no async
instrumentation, and no subscriber ecosystem. Choose tracing when you need those
things.
[dependencies]
wirelog = "0.1"Requires Rust 1.85 or later (edition 2024).
use wirelog::Logger;
let log = Logger::new(std::io::stdout());
log.info().msg("server started");
log.info()
.str("request_id", "abc-123")
.int("status", 200)
.dur("latency", elapsed)
.msg("request complete");
log.error()
.err(&e)
.str("path", "/api/users")
.msg("handler failed");Each call produces one JSON line:
{"level":"info","message":"server started","time":"2026-06-03T14:00:00.000Z"}
{"level":"info","request_id":"abc-123","status":200,"latency":42,"message":"request complete","time":"2026-06-03T14:00:00.001Z"}
{"level":"error","error":"connection refused","path":"/api/users","message":"handler failed","time":"2026-06-03T14:00:00.002Z"}| Method | Rust type | JSON type |
|---|---|---|
.str(key, val) |
&str |
string |
.int(key, val) |
i64 |
number |
.uint(key, val) |
u64 |
number |
.float(key, val) |
f64 |
number (null for NaN / ±Inf) |
.bool(key, val) |
bool |
boolean |
.err(e) |
&dyn Error |
string (key "error") |
.dur(key, val) |
Duration |
number (milliseconds) |
.time(key, val) |
SystemTime |
string (RFC 3339, ms precision) |
All field methods take self and return Self, so they chain without intermediate
bindings. .msg(text) appends a "message" field and flushes. .send() flushes
without a message field.
Two fields are appended automatically on every flush:
"level"— emitted first, before any user fields"time"— RFC 3339 with millisecond precision, emitted last
trace · debug · info · warn · error · fatal · panic
fatal and panic are level designators only — wirelog does not call
std::process::exit or panic!(). That responsibility belongs to the caller.
Runtime filtering sets a minimum level on the logger; events below it are no-ops:
let log = Logger::new(std::io::stdout()).level(Level::Warn);
log.debug().msg("this is suppressed"); // no output
log.warn().msg("this is emitted"); // writtenFor production builds where certain levels will never be used, you can eliminate them entirely at compile time via Cargo feature flags. Disabled levels cost nothing — the call site reduces to a no-op that the optimizer removes completely.
| Feature | Levels compiled out |
|---|---|
| (none — default) | none; all levels active |
level-debug |
trace |
level-info |
trace, debug |
level-warn |
trace, debug, info |
level-error |
trace, debug, info, warn |
level-off |
all levels |
Enable in Cargo.toml:
[dependencies]
wirelog = { version = "0.1", features = ["level-info"] }Compile-time and runtime filtering compose: a level must pass both to produce
output. With features = ["level-info"], logger.debug() is a compile-time
no-op regardless of the runtime minimum level set on the logger.
If multiple features are enabled, the most restrictive wins.
Logger::with() returns a Context builder. Attach fields to it and call
.logger() to get a new logger that prepends those fields to every event it
emits. Context fields are encoded once at construction — there is no per-event
allocation for them.
let log = Logger::new(std::io::stdout());
// All events from req_log include service="auth" and request_id="abc-123"
let req_log = log
.with()
.str("service", "auth")
.str("request_id", "abc-123")
.logger();
req_log.info().msg("token validated");
req_log.debug().int("user_id", 42).msg("user loaded");Subloggers share the same underlying writer — cloning a logger is cheap (one atomic increment). The parent logger is unaffected by subloggers derived from it.
wirelog is generic over its writer: Logger<W: Write + Send>. Choose based on
how important the generic parameter is to your codebase.
The writer type is a compile-time parameter. The compiler can inline and optimize
the write path fully. The tradeoff: <W> propagates into every struct and impl
that stores a logger.
use wirelog::Logger;
let logger = Logger::new(std::io::stdout());Best for library crates and shallow call chains where the writer type is fixed.
AnyLogger is a type alias for Logger<Box<dyn Write + Send>>. The writer is
erased behind a trait object, removing <W> from every consumer type.
use wirelog::{AnyLogger, Logger};
struct Server {
log: AnyLogger, // no generic parameter
}
let logger = Logger::boxed(std::io::stdout());Best for application crates with layered architectures (controller → service → repository) where threading a generic parameter through every type is more noise than it's worth.
Performance: the vtable lookup is indistinguishable from static dispatch in
practice. Both paths are serialized through a Mutex; the lock acquisition
dominates and makes the dispatch overhead unmeasurable.
wirelog does not flush after each event — flushing is the writer's responsibility. The right choice depends on your sink:
stdout / stderr — use directly. The OS line-buffers stdout to a terminal
(each \n triggers a flush) and leaves stderr unbuffered. No wrapping needed.
let log = Logger::new(std::io::stderr());File sink — wrap with BufWriter.
Without it, every event is a syscall. BufWriter batches events into 8 KB
chunks, dramatically reducing syscall count and Mutex hold time under load.
use std::{fs::File, io::BufWriter};
let log = Logger::new(BufWriter::new(File::create("app.log")?));File sink with crash safety — use LineWriter.
It flushes after every \n, which is exactly how wirelog terminates each event.
Each event reaches the file immediately without requiring a manual flush call.
use std::{fs::File, io::LineWriter};
let log = Logger::new(LineWriter::new(File::create("app.log")?));Measured with criterion on Apple
M-series (arm64), writing to io::sink() to isolate encoding cost from I/O.
| wirelog | tracing + JSON subscriber | ratio | |
|---|---|---|---|
| disabled event (runtime filter) | 2.6 ns | 0.3 ns | — |
| single field | 170 ns | 693 ns | ~4× faster |
| ten fields | 294 ns | 1,181 ns | ~4× faster |
The hot path is allocation-free in steady state — a thread-local buffer is reused
across events. The dominant cost is Mutex acquisition (~20 ns lock/unlock) plus
the underlying write. See Choosing a writer for how to
reduce that cost with BufWriter.
Disabled event note: tracing's 0.3 ns reflects its static callsite interest
cache — after the first dispatch the check is a single atomic load. wirelog's
2.6 ns is a level comparison plus Drop glue. Both are negligible in practice;
neither is a meaningful differentiator.
Static vs dynamic dispatch: benchmarks show no measurable difference between
Logger<W> and AnyLogger on either the single-field or ten-field path. Choose
based on ergonomics, not performance.
| wirelog | tracing | |
|---|---|---|
| Output format | JSON only | pluggable (fmt, JSON, custom) |
| API model | per-event builder | macros + spans |
| Async / span support | — | ✓ |
| Subscriber ecosystem | — | large |
| Active logging overhead | ~170 ns / event | ~693 ns / event |
| Compile-time level filtering | ✓ (feature flags) | ✓ (feature flags) |
| Contextual fields | ✓ (subloggers) | ✓ (spans) |
log crate compatibility |
planned | ✓ |
If your use case requires spans, async instrumentation, or a rich subscriber
ecosystem, use tracing. wirelog is the better fit when you want simple,
low-overhead, structured JSON logging with a familiar builder API.
wirelog is a deliberate port of zerolog's ergonomic model to Rust.
| zerolog (Go) | wirelog (Rust) | |
|---|---|---|
| Fluent builder API | ✓ | ✓ |
| JSON output | ✓ | ✓ |
| Typed fields | partial | ✓ |
| Compile-time level filtering | ✓ | ✓ (feature flags) |
| Contextual subloggers | ✓ | ✓ |
io.Writer / io::Write sink |
✓ | ✓ |
| Static vs dynamic dispatch | — | ✓ (Logger<W> / AnyLogger) |
| Proc-macro field derivation | — | planned (wirelog-derive) |
MIT