wide-log Crate
A high-speed wide-event logging system for Rust. A single structured event accumulates fields throughout a request/task lifecycle and is emitted as one JSON line on completion.
Design and Purpose
Wide-event logging gives you one row per request in your log aggregator with
every dimension attached — perfect for high-cardinality exploratory analysis.
wide-log makes this ergonomic with a single wide_log! macro that generates
everything from a JSON object literal.
Performance Philosophy
- Enum keys — each JSON key becomes a
#[repr(u8)]enum variant; a key is a single byte on the stack, not a heap-allocated string. - O(1) indexed storage — values are stored in a
SmallVec<[Option<Value>; 32]>indexed bykey.as_index(). No linear scan foradd,inc,dec, oradd_n. The SmallVec is lazily initialized — grows on demand, never zeroed upfront. - Plain enum Value —
Value<K>is a Rust enum with 9 variants. The compiler handles destructors automatically — nounsafe, noManuallyDrop, no manualDropimpls. - StaticStr variant —
&'static strliterals are stored zero-copy asValue::StaticStr. Theinfo!("literal")macro path usesLogMsg::Staticfor zero-copy log messages. - Direct serializer — the
serialize_to<W: io::Write>method writes JSON directly, bypassingserdeentirely. Usesitoafor integer formatting andryufor float formatting (zero-allocation number-to-ASCII). - KEY_STRS lookup table —
Key::as_str()uses a&'static [&'static str]array indexed byas_index(), replacing a multi-armmatchwith a branchless array index. - Thread-local reusable emit buffer — the
default_emitfunction writes into a thread-localVec<u8>that is cleared (not freed) on each emit. The buffer grows once to the high-water mark and reuses that allocation for all subsequent emits. The serialized JSON is then handed to a single dedicated non-blocking stdout writer thread via an unboundedstd::sync::mpscchannel — the calling thread never blocks on I/O. #[inline(always)]oncurrent()— the TLS pointer lookup is fully inlined at everywl_set!/info!call site. UsesContextCell::get_ptr()which returns a raw pointer (noOptionwrapping inside the closure).- Lazy SmallVec init —
WideEvent::new()starts with an emptySmallVec(zero allocation, zero zeroing). Theensure_capacitymethod grows the SmallVec only when a key at a given index is first accessed. - fn pointer for type-conflict callback —
on_type_conflictis anOption<fn(&mut WideEvent, K)>(8 bytes,Copy, no Arc, no clone). - Zero shared state — the hot path uses thread-local/task-local pointers,
no
Mutex, noArc, no atomics.
Quick Start
use wide_log;
wide_log!;
Customizable Built-in Key Strings
All 8 built-in key strings can be renamed using an optional bracketed override
list before the JSON object. This is useful when your log aggregator expects
different key names (e.g., "correlation_id" instead of "id"):
wide_log!;
The override paths use a dotted convention that mirrors the nesting structure. Omit the override list entirely to use all defaults (backwards compatible).
Override Paths
| Override Path | Default | Description |
|---|---|---|
Log |
"log" |
Top-level key for the log entries array |
Log.Level |
"level" |
Level field within each log entry |
Log.Message |
"message" |
Message field within each log entry |
Event |
"event" |
Top-level key for event metadata |
Event.Id |
"id" |
ID field within the event object |
Event.Timestamp |
"timestamp" |
Timestamp field within the event object |
Duration |
"duration" |
Top-level key for duration |
Duration.TotalMs |
"total_ms" |
Milliseconds field within the duration object |
Auto-Added Keys
The macro automatically adds three keys that every wide event needs:
-
"log"(or your customLogoverride) — the list of log entries accumulated byinfo!(),warn!(), etc. Handled entirely internally; the user never declares it in the JSON. It appears in the serialized output automatically. -
"duration"(or your customDurationoverride) — the duration of the wide event lifecycle. If the user does not declare it in the JSON, the macro automatically adds"duration": { "total_ms": duration! }. The guard setsduration.total_msto the elapsed milliseconds on drop. -
"event"(or your customEventoverride) — event metadata. If the user does not declare it in the JSON, the macro automatically adds"event": { "timestamp": null, "id": null }. The guard setsevent.timestampto the current time as an RFC 3339 string on drop. The builder setsevent.idto a ULID string (or UUIDv4 with theuuidfeature) onbuild().
Builder Pattern
Use WideLogGuard::builder() to construct a guard. The builder allows
specifying a custom timezone, ID generator, and emit function:
// Default: UTC timezone, ULID ID, non-blocking stdout emit
let _guard = builder.build;
// Custom timezone
use Tz;
let _guard = builder
.with_timezone
.build;
// Custom ID generator
let _guard = builder
.with_id
.build;
// UUIDv4 ID (requires `uuid` feature)
let _guard = builder
.with_uuid
.build;
// Custom emit function
let _guard = builder
.with_emit
.build;
// Combined
let _guard = builder
.with_timezone
.with_id
.with_emit
.build;
Builder Methods
| Method | Description | Default |
|---|---|---|
with_timezone(tz: chrono_tz::Tz) |
Timezone for timestamp formatting | chrono_tz::Tz::UTC |
with_id(F: FnOnce() -> String) |
Custom ID generator closure | ULID via ulid crate |
with_uuid() |
Use UUIDv4 for ID (requires uuid feature) |
— |
with_emit(F: FnOnce(&WideEvent)>) |
Custom emit function | default_emit (serialize + non-blocking stdout) |
build() |
Construct the guard | — |
Usage
Non-Async Code
use wide_log;
wide_log!;
Async with Single-Threaded Runtime
use wide_log;
wide_log!;
async
async
async
scope_default uses tokio::task_local! so the event is available across
.await points.
Async with Multi-Threaded Runtime
use wide_log;
wide_log!;
async
async
task_local! moves with the task across threads, so the event remains
accessible regardless of which thread the runtime schedules the task on.
Axum Server with WideLogLayer
(requires the tokio feature)
use get;
use Router;
use wide_log;
wide_log!;
async
async
Why middleware, not WideLogGuard::builder().build(): tokio::task_local!
has no imperative setter — you can only set a task-local value by wrapping a
future with .scope(value, future). The middleware provides that wrapper
automatically. WideLogGuard::builder().build() (the sync API) sets
thread_local!, which is stale if a multi-threaded runtime moves the task to
another thread.
No special setup in nested calls: any info!(), warn!(), wl_set!,
etc. call — whether in the handler directly, in a called sync function, or
in a called async function — finds the event via current(), which checks
task-local first. No arguments or threading needed.
Custom Emit
use wide_log;
wide_log!;
Explicit Duration
wide_log!;
// DURATION_PATH = &[Duration, WallMs] → sets duration.wall_ms on drop
Custom Key Names
use wide_log;
wide_log!;
Macro Reference
| Macro | Description |
|---|---|
wl_set!(path, val) |
Set/replace a field value at a nested path |
wl_inc!(path) |
Increment a numeric field by 1 at a nested path (init to 1 if absent) |
wl_dec!(path) |
Decrement a numeric field by 1 at a nested path (init to -1 if absent) |
wl_add!(path, n) |
Add a number to a numeric field at a nested path |
wl_null!(path) |
Set a field to null at a nested path |
info!(msg) / info!(fmt, ...) |
Append info-level log entry (shadows tracing::info!) |
warn!(msg) / warn!(fmt, ...) |
Append warn-level log entry (shadows tracing::warn!) |
error!(msg) / error!(fmt, ...) |
Append error-level log entry (shadows tracing::error!) |
debug!(msg) / debug!(fmt, ...) |
Append debug-level log entry (shadows tracing::debug!) |
trace!(msg) / trace!(fmt, ...) |
Append trace-level log entry (shadows tracing::trace!) |
JSON Syntax Reference
Value Markers
| Marker | Meaning | Guard Behavior |
|---|---|---|
duration! |
This key is the duration. Value is elapsed ms, computed on drop. | Set on drop via DURATION_PATH. |
counter! |
This key is an incrementable counter. Initialized to 0 (absent). | No auto-set; wl_inc! initializes to 1. |
null |
This key exists but has no default value. | No auto-set. |
"literal" |
A string default value. | Set on creation as a FastStr. |
123 |
A numeric default value. | Set on creation as a U64 or I64. |
true/false |
A boolean default value. | Set on creation as Bool. |
The duration! marker is optional — if not used, the macro defaults to
duration.total_ms. Only use duration! when you want a custom duration leaf
name (e.g., "wall_ms": duration!).
Duration Auto-Add Rules
| User declares | Macro result | DURATION_PATH |
|---|---|---|
Nothing (no "duration") |
Adds "duration": { "total_ms": duration! } |
&[Duration, TotalMs] |
"duration": {} |
Fills in "total_ms": duration! |
&[Duration, TotalMs] |
"duration": { "total_ms": duration! } |
Uses as-is | &[Duration, TotalMs] |
"duration": { "wall_ms": duration! } |
Uses as-is | &[Duration, WallMs] |
"duration": { "total_ms": null } |
Fills in duration! for total_ms |
&[Duration, TotalMs] |
"duration": { "secs": null, "nanos": null } |
Error: no duration! leaf, and multiple non-duration leaves are ambiguous. |
— |
The rule: there must be exactly one duration! leaf in the "duration"
subtree. If absent, the macro adds "total_ms": duration!. If the user
declares a "duration" object with only null/literal leaves and no
duration!, the macro defaults total_ms to duration! (adding it if
missing, or converting "total_ms": null to "total_ms": duration!).
Event Auto-Add Rules
| User declares | Macro result |
|---|---|
Nothing (no "event") |
Adds "event": { "timestamp": null, "id": null } |
"event": {} |
Fills in "timestamp": null, "id": null |
"event": { "timestamp": null, "id": null } |
Uses as-is |
"event": { "source": null } |
Adds missing "timestamp" and "id", keeps "source" |
"event": "foo" |
Error: "event" must be an object |
The guard sets event.timestamp to an RFC 3339 string on drop. The builder
sets event.id to a ULID string (or UUIDv4 with with_uuid()) on build().
JSON Key to Enum Variant Naming
The macro converts JSON key names to PascalCase enum variant names:
"service"→Service"name"→Name"total_ms"→TotalMs
The conversion: split on _ and ., capitalize each word, concatenate. If
the name conflicts with a Rust keyword, append _ (e.g., "type" → Type_).
Path Derivation
The JSON structure directly defines the paths:
"service": { "name": ... }→ path[Service, Name], dotted string"service.name"."duration": { "total_ms": ... }→ path[Duration, TotalMs], dotted string"duration.total_ms"."requests": ...→ path[Requests], dotted string"requests".
The macro generates __wl_resolve_path with entries for every path,
including intermediate paths (e.g., "service" → &[Service]) and full leaf
paths (e.g., "service.name" → &[Service, Name]). When the input to
__wl_resolve_path is a string literal (as in wl_set!("service.name", ...)),
the compiler constant-folds the match — zero runtime cost.
info! Shadowing
The info!, warn!, error!, debug!, trace! macros are
#[macro_export]'d at the crate root. When the user does use wide_log::*;,
these shadow tracing::info! etc. To call the real tracing macros, use the
fully qualified path: ::tracing::info!(...).
The generated default_emit function does not route through tracing —
it writes the serialized JSON line directly to non-blocking stdout via
stdout_emit::submit. (Wide-log's info!/warn!/etc. still shadow tracing's
when both are in scope, which is why you must use the fully qualified path
when you want the real tracing macro.)
Features
tokio— enables async support:scope(),scope_default(),WideLogLayertower middleware, andtokio::task_local!storage.uuid— enablesWideLogGuardBuilder::with_uuid()for UUIDv4 ID generation instead of the default ULID.