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. - Tag + union Value —
Value<K>is a#[repr(C)]struct with a 1-byte tag and a 32-byte union, totaling 40 bytes (down from 80 bytes in the original enum design). Drop-able types useManuallyDropwith manual cleanup viaDrop. - StaticStr variant —
&'static strliterals are stored zero-copy asValue::StaticStr(pointer + length, no allocation). 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. #[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!;
Auto-Added Keys
The macro automatically adds three keys that every wide event needs:
-
"log"— the list of log entries accumulated byinfo!(),warn!(), etc. Handled entirely internally; the user never declares"log"in the JSON. It appears in the serialized output automatically. -
"duration"— the duration of the wide event lifecycle. If the user does not declare"duration"in the JSON, the macro automatically adds"duration": { "total_ms": duration! }. The guard setsduration.total_msto the elapsed milliseconds on drop. -
"event"— event metadata. If the user does not declare"event"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, tracing 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 + ::tracing::info!) |
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
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 uses ::tracing::info! (fully
qualified) to avoid calling the shadowing info! macro, which would append to
the log list instead of emitting the JSON line.
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.