Wado

core:log

Structured logging and tracing (docs/wep-2026-06-25-core-log.md).

A subscriber is an effect: sinks and layers are handlers of Log, so composition is handler nesting and dispatch stays static. Events carry a level, a message, structured fields, and the caller's source location; spans are first-class values entered for a scope.

Filtering is three gates, cheapest first, each narrowing what the one before admitted: -D log.level=info folds at compile time, set_log_level is one global read, and Log::enabled runs the installed layer stack. enabled(level) exposes the same ladder for guarding fields too expensive to build.

The facade is #[ambient], so performing Log adds no with Log to a caller's signature, and every operation carries a default implementation, so a program that installs nothing still logs — Info and above, one text line per event on stderr. Installing a sink replaces that for its scope; NopSink is how a program asks for silence.

Synopsis

// No sink installed: the default subscriber writes it to stderr.
info(`starting up`);

// An installed sink takes over for its scope. `CaptureSink` is the one a
// test reads back; `TextSink` and `JsonSink` write. The event asserted on is
// at `Error`, the level no build threshold folds away, so what this reads
// back does not depend on how the build was configured.
let mut sink = CaptureSink {};
with Log => &mut sink do {
    in_span(&span(Level::Info, `request`), || {
        error(`disk full`, { free_bytes: 0 });
    });
};

assert sink.events.len() == 1;
assert sink.events[0].message == "disk full";
assert sink.events[0].meta.level matches { Error };
assert sink.events[0].parent matches { Some(1) };

Functions

pub fn level_from_str(s: String) -> Level

Infallible LenientFromStr wrapper: an unrecognized name is Trace, so a typo widens what is logged rather than silencing a program.

pub fn set_log_level(level: Level)

Set the process-wide threshold (tier 2). A sink or layer narrows further.

pub fn log_level() -> Level

The process-wide threshold set by [set_log_level].

pub fn enabled(level: Level) -> bool

#[ambient]

The three tiers, cheapest first, each narrowing what the one before admitted. Exposed as a predicate too, for guarding fields too expensive to build:

if enabled(Level::Debug) {
    debug(`state`, { snapshot: expensive_snapshot() });
}

pub fn event<T: Serialize = NoFields>(level: Level, message: String, fields: T = NoFields {}, target: String = #function, file: String = #file, line: i32 = #line)

#[ambient]

Emit an event at a level a caller computes. The level wrappers' three gates apply here too; only tier 1 comes out weaker, since a level that is not a literal is not a comparison the optimizer can fold. Location defaults resolve at the call site.

pub fn trace<T: Serialize = NoFields>(message: String, fields: T = NoFields {}, target: String = #function, file: String = #file, line: i32 = #line)

#[ambient]

pub fn debug<T: Serialize = NoFields>(message: String, fields: T = NoFields {}, target: String = #function, file: String = #file, line: i32 = #line)

#[ambient]

pub fn info<T: Serialize = NoFields>(message: String, fields: T = NoFields {}, target: String = #function, file: String = #file, line: i32 = #line)

#[ambient]

pub fn warn<T: Serialize = NoFields>(message: String, fields: T = NoFields {}, target: String = #function, file: String = #file, line: i32 = #line)

#[ambient]

pub fn error<T: Serialize = NoFields>(message: String, fields: T = NoFields {}, target: String = #function, file: String = #file, line: i32 = #line)

#[ambient]

pub fn span<T: Serialize = NoFields>(level: Level, name: String, fields: T = NoFields {}, target: String = #function, file: String = #file, line: i32 = #line) -> Span

#[ambient]

pub fn current() -> Option<Span>

#[ambient]

The innermost entered span, or null outside any.

pub fn in_span<T, effect E>(s: &Span, mut body: fn mut() -> T with E) -> T with E

#[ambient]

Run body inside s. A closure can't skip the exit/close calls, so they run on every body exit path, an early return included.

pub fn parse_directives(spec: String) -> List<Directive>

Parse an EnvFilter-style comma-separated list — info,Db::query=debug,handle=trace — from WADO_LOG or any string an application supplies. A bare level is the default for unmatched targets; target=level overrides it for targets under that prefix. A malformed directive is skipped: a typo in an environment variable must not stop a program from starting.

Traits

pub trait Clock with ()

How a sink stamps an event with a time, or declines to. None is no stamp.

A type parameter rather than a field, because only a type can remove the clock: a sink over [NoClock] monomorphizes to code that never reads one, so the component carries no wasi:clocks import. A runtime flag leaves the call reachable, and at -O0 nothing folds it away.

fn now() -> Option<String>

Effects

pub interface Log

The subscriber. Sinks and layers are handlers of it, so composition is handler nesting and dispatch stays static. Operations are best-effort: a handler swallows its own write and serialize errors and never fails the program.

Every operation carries a default implementation, so a program that installs no sink logs to stderr rather than trapping — logging is a debugging aid, and one that crashes the program for want of setup is worse than no logging. The defaults are a TextSink on stderr admitting Info and above; installing any handler replaces them for its scope, and NopSink is how a program asks for silence.

fn enabled(level: Level) -> bool

fn current_span() -> Option<SpanId>

fn new_span(attrs: SpanAttrs) -> SpanId

fn record_fields(span: SpanId, fields: Value)

fn follows_from(span: SpanId, cause: SpanId)

fn enter(span: SpanId)

fn exit(span: SpanId)

fn close(span: SpanId)

fn event(event: Event)

Types

pub type SpanId = u64

Structs

pub struct NoFields

Serializes to {}; the default field payload, so info(msg) needs no second argument and the default type parameter supplies its type.

pub struct Metadata

What a callsite says about itself, independent of the payload.

level: Level

target: String

Category; defaults to the caller's #function.

name: String

Span name; "" for events.

file: String

line: i32

pub struct Event

meta: Metadata

message: String

fields: Value

parent: Option<SpanId>

pub struct SpanAttrs

meta: Metadata

fields: Value

pub struct Span

A span is a first-class value, so a caller can carry one and re-enter it.

Fields are private.

pub fn id(&self) -> SpanId

pub fn record<T: Serialize>(&self, fields: T)

#[ambient]

Add fields to an open span.

pub fn follows_from(&self, cause: &Span)

#[ambient]

Record a non-parent causal link: this span follows from cause.

pub fn close(&self)

#[ambient]

Close a span entered by hand. [in_span] closes on every exit path, so this is only for a span the caller opened without it.

pub struct NoClock

No timestamp, and no wasi:clocks import. The default sink's clock, so a program that installs nothing logs in any world, core:kiln/generator included — its linker offers no WASI at all.

This is the conservative end of the trade, not a verdict on what a default line should carry. Stamping the worlds that do have a clock is a compatible change, and may replace this once a mechanism keeps the guarantee.

impl Clock for NoClock

fn now() -> Option<String>

pub struct WallClock

Wall-clock stamps with millisecond precision. Installing a sink over this adds a wasi:clocks import, so it belongs only in a world that has one.

#[ambient] for the same reason the facade is: a sink's operations are Log's, whose signatures carry no effect, so the clock read cannot be declared on them.

impl Clock for WallClock

fn now() -> Option<String>

#[ambient]

pub struct TextSink<C: Clock = NoClock>

One human-readable line per event on stderr:

2026-08-08T11:47:08.204Z INFO 42 handle_request: user logged in user_id=7 ip=127.0.0.1

Each part its configuration turns off is omitted; location appends at file:line. Writes through the ambient log_stderr, so the sink needs no Stderr in its signature and installs in any world. C selects the timestamp: TextSink<WallClock> stamps, the default TextSink does not.

seq: bool

location: bool

pub fn render(&mut self, event: &Event) -> String

The line this sink would write for event, without writing it — the hook for sending the same format somewhere other than stderr. Consumes a sequence number, so one call is one line.

impl Log for TextSink<C>

fn enabled(&self, level: Level) -> bool
fn current_span(&self) -> Option<SpanId>
fn new_span(&mut self, attrs: SpanAttrs) -> SpanId
fn record_fields(&mut self, span: SpanId, fields: Value)
fn follows_from(&mut self, span: SpanId, cause: SpanId)
fn enter(&mut self, span: SpanId)
fn exit(&mut self, span: SpanId)
fn close(&mut self, span: SpanId)
fn event(&mut self, event: Event)

pub struct JsonSink<C: Clock = NoClock>

One JSON object per line (JSONL) on stderr, through json::to_string:

{"seq":42,"level":"info","target":"handle_request","message":"user logged in","span":3,"fields":{"user_id":7}}

Keys a switch turns off are absent rather than null. C selects the ts key the way it does for [TextSink], so the default over NoClock writes no ts and JsonSink<WallClock> leads with one.

seq: bool

location: bool

pub fn render(&mut self, event: &Event) -> String

The JSON line this sink would write for event, without writing it. Consumes a sequence number, so one call is one line. A sink swallows its own serialize errors, so an unserializable event renders as {} rather than aborting the program.

impl Log for JsonSink<C>

fn enabled(&self, level: Level) -> bool
fn current_span(&self) -> Option<SpanId>
fn new_span(&mut self, attrs: SpanAttrs) -> SpanId
fn record_fields(&mut self, span: SpanId, fields: Value)
fn follows_from(&mut self, span: SpanId, cause: SpanId)
fn enter(&mut self, span: SpanId)
fn exit(&mut self, span: SpanId)
fn close(&mut self, span: SpanId)
fn event(&mut self, event: Event)

pub struct NopSink

Drops everything, cheaply. The sink to install when a program must not log but its libraries still perform Log.

Fields are private.

impl Log for NopSink

fn enabled(&self, level: Level) -> bool
fn current_span(&self) -> Option<SpanId>
fn new_span(&mut self, attrs: SpanAttrs) -> SpanId
fn record_fields(&mut self, span: SpanId, fields: Value)
fn follows_from(&mut self, span: SpanId, cause: SpanId)
fn enter(&mut self, span: SpanId)
fn exit(&mut self, span: SpanId)
fn close(&mut self, span: SpanId)
fn event(&mut self, event: Event)

pub struct CaptureSink

Keeps every event and span in memory, for tests: assertions read the captured values rather than parsing output. ops records the span lifecycle in order — new:name#id, enter:id, exit:id, close:id, record:id:key, follows:id<-cause.

min: Level

Tier-3 threshold, so a test can exercise the sink's own gate.

events: List<Event>

spans: List<SpanAttrs>

Spans in id order: spans[i] is the span with id i + 1.

ops: List<String>

impl Log for CaptureSink

fn enabled(&self, level: Level) -> bool
fn current_span(&self) -> Option<SpanId>
fn new_span(&mut self, attrs: SpanAttrs) -> SpanId
fn record_fields(&mut self, span: SpanId, fields: Value)
fn follows_from(&mut self, span: SpanId, cause: SpanId)
fn enter(&mut self, span: SpanId)
fn exit(&mut self, span: SpanId)
fn close(&mut self, span: SpanId)
fn event(&mut self, event: Event)

pub struct Context<T: Serialize>

slog's With: prepend fixed fields to every event passing through. A key the event itself carries wins.

fields: T

impl Log for Context<T>

fn event(&self, event: Event) with Log

pub struct Directive

One EnvFilter directive: the lowest level admitted for targets under target. An empty target is the default for unmatched targets.

A target is matched by prefix against the event's, which defaults to the caller's #function — a bare name for a free function (handle_request), Type::method for a method, Log::event for an operation's default. Pass target explicitly at the call site to group events under a name of your own (a subsystem, say) rather than the function that emitted them.

target: String

level: Level

pub struct Filter

Per-target filtering, EnvFilter-style: the longest matching target prefix wins, and a target no directive matches is admitted. See [Directive] for what an event's target is.

directives: List<Directive>

pub fn threshold(&self, target: String) -> Level

The lowest level admitted for target.

pub fn floor(&self) -> Level

The lowest level any target could be admitted at. Log::enabled sees no target, so the gate has to be the floor over every directive — a narrower answer would drop events event would go on to admit.

impl Log for Filter

fn enabled(&self, level: Level) -> bool with Log
fn event(&self, event: Event) with Log

Enums

pub enum Level

Severity, ordered by declaration: a threshold is the lowest level emitted, so at Info both Trace and Debug are off.

Trace

Debug

Info

Warn

Error