wide-log 0.2.0

A fast wide event logging crate a la loggingsucks.com
Documentation

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.
  • SmallVec storage — up to 24 entries and 8 log entries are inline on the stack; zero heap allocation in the common case.
  • FastStr SSO — short strings (< ~23 bytes) use small-string optimization; zero heap allocation for short values and log messages.
  • sonic-rs SIMD — serialization is SIMD-accelerated for fast JSON output.
  • Zero shared state — the hot path uses thread-local/task-local pointers, no Mutex, no Arc, no atomics.

Quick Start

use wide_log::wide_log;

wide_log!({
    "service": {
        "name": null,
        "version": "1.0.0",
    },
    "requests": counter!,
});

fn main() {
    tracing_subscriber::fmt().init();

    let _guard = WideLogGuard::new();

    wl_set!("service.name", "example-service");
    wl_inc!("requests");

    info!("request received");
    warn!("upstream slow");

    // _guard drops here → duration.total_ms is set automatically,
    // event is serialized to JSON, emitted via ::tracing::info!:
    //
    // {"service":{"name":"example-service","version":"1.0.0"},
    //  "duration":{"total_ms":42},"requests":1,
    //  "log":[{"level":"info","message":"request received"},
    //         {"level":"warn","message":"upstream slow"}]}
}

Auto-Added Keys

The macro automatically adds two keys that every wide event needs:

  1. "log" — the list of log entries accumulated by info!(), warn!(), etc. Handled entirely internally; the user never declares "log" in the JSON. It appears in the serialized output automatically.

  2. "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 sets duration.total_ms to the elapsed milliseconds on drop.

Usage

Non-Async Code

use wide_log::wide_log;

wide_log!({
    "service": {
        "name": null,
        "version": "1.0.0",
    },
    "requests": counter!,
});

fn main() {
    tracing_subscriber::fmt().init();

    // Create guard — takes no arguments. Sets default values from JSON
    // (service.version = "1.0.0"), starts the timer:
    let _guard = WideLogGuard::new();

    // Set per-request field values:
    wl_set!("service.name", "example-service");
    wl_inc!("requests");

    // Add log messages — these accumulate in the "log" array:
    info!("request received");
    warn!("upstream slow");

    // _guard drops here → duration.total_ms is set automatically,
    // event is serialized to JSON, emitted via ::tracing::info!.
}

Async with Single-Threaded Runtime

use wide_log::wide_log;

wide_log!({
    "service": {
        "name": null,
        "version": "1.0.0",
    },
    "requests": counter!,
});

#[tokio::main(flavor = "current_thread")]
async fn main() {
    tracing_subscriber::fmt().init();

    handle_request().await;
}

async fn handle_request() {
    scope_default(async {
        wl_set!("service.name", "example-service");
        wl_inc!("requests");
        info!("request received");

        fetch_upstream().await;  // log macros work across .await

        info!("request completed");
    }).await;
    // guard drops here → duration.total_ms set, event emitted
}

async fn fetch_upstream() {
    warn!("upstream slow");
}

scope_default uses tokio::task_local! so the event is available across .await points.

Async with Multi-Threaded Runtime

use wide_log::wide_log;

wide_log!({
    "service": {
        "name": null,
        "version": "1.0.0",
    },
    "requests": counter!,
});

#[tokio::main(flavor = "multi_thread")]
async fn main() {
    tracing_subscriber::fmt().init();

    let mut handles = vec![];
    for i in 0..10 {
        handles.push(tokio::spawn(handle_request(i)));
    }
    for h in handles {
        h.await.unwrap();
    }
}

async fn handle_request(id: u64) {
    scope_default(async {
        wl_set!("service.name", format!("worker-{id}"));
        wl_inc!("requests");
        info!("request {} started", id);

        // The task may be moved to another thread here.
        // task_local! ensures the event pointer moves with it.
        tokio::task::yield_now().await;

        info!("request {} completed", id);
    }).await;
    // guard drops → event emitted with duration.total_ms
}

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 axum::routing::get;
use axum::Router;
use wide_log::wide_log;

wide_log!({
    "service": {
        "name": null,
        "version": "1.0.0",
    },
    "http": {
        "method": null,
        "path": null,
        "status": null,
    },
});

async fn ok() -> &'static str {
    // Guard is already active via the middleware — no scope_default() needed.
    wl_set!("service.name", "ok-service");
    wl_set!("http.method", "GET");
    wl_set!("http.path", "/ok");
    wl_set!("http.status", 200u64);

    info!("request received");
    // ...
    info!("request completed");
    ""
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().init();

    let app = Router::new()
        .route("/ok", get(ok))
        .layer(WideLogLayer);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Why middleware, not WideLogGuard::new(): 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::new() (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;

wide_log!({
    "service": { "name": null, "version": "1.0.0" },
    "requests": counter!,
});

fn main() {
    let _guard = WideLogGuard::new_with_emit(|ev| {
        if let Ok(json) = ev.to_json() {
            println!("{json}");
        }
    });

    wl_set!("service.name", "example-service");
    wl_inc!("requests");
    info!("request received");
}

Explicit Duration

wide_log!({
    "service": { "name": null, "version": "1.0.0" },
    "duration": { "wall_ms": duration! },
    "requests": counter!,
});
// 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 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!).

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.