Skip to content

Logging

Three pieces turn log records into rich output:

  • StructuredEvent (rich_ext::event): one log record as data: a message, typed fields and context (time, severity, target, source location). It renders on its own as a compact or expanded line.
  • RichHandler: lays events out like Python rich's logging.RichHandler: time, level, message, and the source file on the right.
  • Adapters (features log and tracing): LogAdapter and EventLayer turn log records and tracing events into StructuredEvents and hand them to an EventSink, such as a RichHandler.
rs-rich-ext = { version = "…", features = ["log"] }        # the log facade
rs-rich-ext = { version = "…", features = ["tracing"] }    # tracing + tracing-subscriber

StructuredEvent and RichHandler need no feature.

log through RichHandler

Create the handler, then install a LogAdapter that sends records to it. Nothing is installed for you: your application decides which logger is global.

/// Route the `log` facade to `sink`. The application owns this decision:
/// constructing a `LogAdapter` never installs anything by itself.
fn install_logger(sink: Arc<dyn EventSink>) {
    let logger = Box::leak(Box::new(LogAdapter::new(sink, log::LevelFilter::Debug)));
    log::set_logger(logger).expect("no other logger is installed");
    log::set_max_level(log::LevelFilter::Debug);
}
// `time_format` pins the clock so this output is reproducible; the
// default is the current UTC time as `[HH:MM:SS]`.
let handler = RichHandler::new(Console::new()).time_format(|| "[12:00:00]".into());
let handler = Arc::new(handler);
install_logger(handler.clone());
log::info!("Server starting on 127.0.0.1:8080");
log::debug!("GET /index.html 200 in 0.4ms");
log::warn!("cache miss for key {:?}", "user:42");
log::error!("POST /upload failed: disk full");

Four log records: time, level, highlighted message and file:line

Reading a line from left to right:

  • Time: blanked when it repeats the previous line's time.
  • Level: padded to 8 cells in logging.level.<name>. Python's names are used (WARNING, CRITICAL); log::Level::Trace shows as TRACE.
  • Message: run through ReprHighlighter (numbers, strings, paths, IPs), with HTTP methods (GET, POST, …) in logging.keyword.
  • Path: the file name and line, linked to the full path.

LogAdapter::new(sink, level) drops records above level, and so should log::set_max_level. Leaking the adapter with Box::leak is the usual way to get the &'static logger log::set_logger wants; log has no other way to uninstall a logger anyway.

tracing through RichHandler

EventLayer is a tracing_subscriber::Layer. Compose it with a subscriber you own, globally or for a scope:

use tracing_subscriber::prelude::*;

let subscriber = tracing_subscriber::registry().with(EventLayer::new(handler.clone()));
tracing::subscriber::with_default(subscriber, || {
    tracing_lines();
});
tracing::info!(port = 8080u16, tls = true, "listening");
tracing::warn!(retries = 3i64, elapsed_ms = 1250.5, "upstream slow");
tracing::error!(path = "/upload", "request failed");

tracing events with fields appended as key=value

  • The message field becomes the message; every other field is appended as key=value.
  • Integers keep their type, including unsigned and 128-bit values; floats and bools too. Fields recorded with Debug (?value) become strings.

Spans

Every event carries the spans it happened in, outermost first, with their fields as last recorded (a later span.record(...) updates them). The layer keeps them in the subscriber's registry, so the subscriber must implement LookupSpan, as tracing_subscriber::registry() and fmt() do.

let request = tracing::info_span!("request", method = "GET", path = "/items");
let _request = request.enter();
tracing::info!("authorized");
{
    let _query = tracing::debug_span!("query", table = "items").entered();
    tracing::info!(rows = 42u64, "fetched");
}
tracing::info!(status = 200u16, "done");

By default RichHandler puts the span chain before the message:

Events prefixed with request{method=GET path=/items} and query{table=items}

With span_open(true) and span_close(true) the layer also reports each span opening, and closing with how long it was open. SpanView::Tree then draws the spans as branches, with each event indented under its span:

use tracing_subscriber::prelude::*;

// Report spans opening and closing, and draw them as a tree.
let layer = EventLayer::new(recorder.clone())
    .span_open(true)
    .span_close(true);
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, span_lines);
let tree = RichHandler::new(Console::new())
    .span_view(SpanView::Tree)
    .show_time(false);

A tree of the request and query spans, with open and close lines and timings

span_view Shows
SpanView::Inline (default) outer{a=1}:inner: message; open and close events as name{…} opened and name{…} closed 1.20ms
SpanView::Tree a │ guide per span, ┌ name field=value on open, └ name 1.20ms on close; ASCII consoles get \|, + and `
SpanView::Hidden the message alone

Any EventSink receives the same span data, through StructuredEvent::span_context() and, for open and close events, span_marker().

hyperlinker(Hyperlinker) links the path column through a Hyperlinker instead of a bare file:// URL. Its editor template, and a base directory for the relative paths tracing reports, then apply:

use rich_ext::hyperlink::Hyperlinker;

let _editor = RichHandler::new(Console::new()).hyperlinker(
    Hyperlinker::new()
        .base_dir(env!("CARGO_MANIFEST_DIR")) // tracing reports paths relative to the crate
        .editor("vscode://file/{path}:{line}"),
);

live(coordinator) prints through a LiveCoordinator instead of the console. Each log line goes above the live regions, which are repainted below it, so logging never tears a progress display:

use rich_ext::capabilities::Capabilities;
use rich_ext::live::LiveCoordinator;
use rich_ext::target::{RenderTarget, TargetKind};

let capabilities = Capabilities::system().to_target_capabilities();
let target = RenderTarget::new(
    TargetKind::Terminal,
    capabilities,
    rich::Theme::default_theme(),
);
let live = Arc::new(Mutex::new(LiveCoordinator::new(
    std::io::stdout(),
    target.clone(),
)));
let progress = live
    .lock()
    .unwrap()
    .add(vec![rich::Segment::new("uploading… 40%", None)])
    .expect("add a region");
live.lock().unwrap().refresh().expect("paint");

// Log lines print above the region, which is repainted below them.
let handler = RichHandler::new(target.console()).live(live.clone());
let subscriber = tracing_subscriber::registry().with(EventLayer::new(Arc::new(handler)));
tracing::subscriber::with_default(subscriber, || {
    tracing::info!(chunk = 4u64, "uploaded");
});
live.lock().unwrap().remove(progress).expect("remove");
live.lock().unwrap().finish().expect("finish");

A message containing control characters is still printed, with the controls shown as inert symbols (␛), rather than dropped.

Render with a console as wide as the coordinator's target, as target.console() is; longer lines fold.

Handler options

RichHandler mirrors upstream's constructor arguments as builder methods:

let handler = RichHandler::new(Console::new())
    .show_time(false)
    .show_path(false)
    .markup(true) // messages are console markup, not literal text
    .keywords(["DEPLOY", "ROLLBACK"]); // replaces the HTTP-method list
let event =
    StructuredEvent::new(Message::Literal("DEPLOY [bold]v2.4.1[/] to 3 hosts".into()))
        .context(EventContext {
            severity: Some(Severity::Warn),
            ..Default::default()
        });
console.print(&handler.render(&event));

A warning with markup and a custom keyword, without time and path

Builder Default Upstream
show_time(bool), show_level(bool), show_path(bool) all on show_time, show_level, show_path
omit_repeated_times(bool) on omit_repeated_times
level_width(Option<usize>) Some(8) fixed at 8 upstream
markup(bool) off markup: parse messages as console markup
highlighter(Option<Box<dyn Highlighter + Send + Sync>>) ReprHighlighter highlighter; None disables it
keywords(words) HTTP methods keywords
enable_link_path(bool) on enable_link_path
time_format(closure) UTC [HH:MM:SS] log_time_format
span_view(SpanView) Inline none: upstream has no spans (Spans)
hyperlinker(Hyperlinker) none (bare file://) none (Links)
live(coordinator) the console none (Live displays)

Any highlighter works, including a Hyperlinker that makes issue references, paths and URLs in messages clickable:

use rich_ext::hyperlink::Hyperlinker;

// Any `Highlighter + Send + Sync` replaces the default ReprHighlighter.
let linker = Hyperlinker::new().repository("https://github.com/acme/app");
let handler = RichHandler::new(Console::new())
    .show_time(false)
    .highlighter(Some(Box::new(linker)));
let event = StructuredEvent::new(Message::Literal(
    "retrying after #42; see src/net.rs:88 and https://acme.dev/status".into(),
));
console.print(&handler.render(&event));

A log message whose reference, path and URL are links

Differences from Python rich

  • Time is UTC by default: local time would need a time-zone dependency. Supply time_format for local time or another format; an event's own context.timestamp always wins.
  • rich_tracebacks has no counterpart. Render errors as diagnostics instead.

Beyond the facades, a handler can be used directly: handler.emit_event(&event) prints one event to the handler's console, and handler.render(&event) returns the laid-out row as a Table for you to print elsewhere.

Your own sink

The adapters talk to an EventSink, a one-method trait. Implement it to send events anywhere: a file, a channel, a test buffer.

/// Keeps events instead of printing them: handy in tests.
#[derive(Default)]
struct Recorder {
    events: Mutex<Vec<StructuredEvent>>,
}

impl EventSink for Recorder {
    fn emit(&self, event: StructuredEvent) -> std::io::Result<()> {
        self.events.lock().unwrap().push(event);
        Ok(())
    }
}

Pass it as Arc::new(Recorder::default()) to LogAdapter::new or EventLayer::new. The screenshots on this page were made that way: records go to a Recorder, and RichHandler::render lays them out on an SVG console.

Errors returned by emit are dropped, never logged: logging a logging failure could recurse forever.

Structured events

A StructuredEvent is plain data that renders itself. The adapters build them for you, but you can make them directly:

let event = StructuredEvent::new(Message::Literal("request finished".into()))
    .field("status", Value::Unsigned(200))
    .field("route", Value::String("/api/items".into()))
    .field("elapsed_ms", Value::Float(12.5))
    .field(
        "tags",
        Value::List(vec![
            Value::String("cache".into()),
            Value::String("gzip".into()),
        ]),
    )
    .context(EventContext {
        timestamp: Some("2026-09-23T12:00:00Z".into()),
        severity: Some(Severity::Info),
        target: Some("http".into()),
        source: Some(SourceLocation {
            path: "src/server.rs".into(),
            line: 88,
            column: None,
        }),
        ..Default::default()
    });

console.print(&event); // compact: one line, fields as key=value
console.print(
    &event
        .clone()
        .field_order(vec!["route".into()]) // this field first
        .hide_fields(vec!["tags".into()])
        .view(EventView::Expanded), // one field per line
);

An event in the compact view, then reordered in the expanded view

  • Message: Message::Literal(text) prints as written; Message::Markup(text) is parsed as console markup. Literal is the safe choice for anything user-supplied.
  • Fields: .field(key, Value) appends a field; setting an existing key replaces its value in place. Value covers null, bool, signed and unsigned integers (64 and 128 bits), floats, strings, lists and maps.
  • Order: fields render in insertion order; field_order(keys) moves the named ones first and hide_fields(keys) leaves some out.
  • Context: EventContext holds timestamp, severity, target, module, source, thread, task and correlation_id. All optional, and nothing is filled in implicitly: no clock, thread or environment reads.
  • View: Compact (default) puts fields on the message line; Expanded gives each field its own line and pretty-prints lists and maps.
  • Overflow: .overflow(OverflowPolicy::…) picks how long lines fit (default Fold); see bounded layout.

Theme keys: event.message, event.field (default cyan), event.value and event.severity.<trace|debug|info|warn|error|fatal>.

Attaching diagnostics

An event can carry any number of diagnostics, rendered under its line:

use rich_ext::diagnostic::{Diagnostic, Location};

let event = StructuredEvent::new(Message::Markup("[bold]config reload[/] rejected".into()))
    .context(EventContext {
        severity: Some(Severity::Error),
        ..Default::default()
    })
    .diagnostic(
        Diagnostic::error("unknown key `prot`")
            .code("CFG002")
            .location(Location::new("app.toml", Some(4), Some(1))),
    );
console.print(&event);

An error event followed by its diagnostic

Run the example

cargo run -p rs-rich-ext --example guide_logging --features log,tracing

See also