Skip to content

Logging

Coming from Python / Node? Instead of a global logger object you configure at import time, Hale logging is built on the message bus: a Logger publishes typed log events, and a sink subscribes to them and decides what to do (print, write a file, ship to a collector). It’s your first look at the bus — the mechanism the whole next tier is built on.

Two pieces: something that emits log events, and something that consumes them.

fn main() {
// The sink must exist before anything logs to it.
let sink = std::log::StdoutSink { };
let log = std::log::Logger { name: "app" };
log.info("starting up");
log.warn("disk almost full");
log.error("connection refused");
}

StdoutSink subscribes to all log events and prints them; Logger emits them. The ordering matters — instantiate the sink first, because a subscriber has to exist before a publisher sends, or the early events have nowhere to go.

Loggers carry the usual levels: trace, debug, info, warn, error. Call the matching method:

log.debug(f"cache size = {n}");
log.error(f"request {id} failed: {reason}");

Each Logger has a name, which becomes the event’s topic (log.app, log.db, log.http). You can give every component its own named logger and still have a single sink see everything:

fn main() {
let sink = std::log::StdoutSink { };
let app_log = std::log::Logger { name: "app" };
let db_log = std::log::Logger { name: "db" };
app_log.info("ready");
db_log.warn("slow query");
}

A custom sink subscribes to a subtreelog.db.** to capture only database logs, log.** to capture all of them — without the loggers knowing who’s listening. Publisher and subscriber never reference each other; they only share the topic name.

StdoutSink is the minimal sink; two richer drop-ins subscribe to the same log.**, so swapping is a one-line change at the wiring site:

std::log::FileSink { path: "logs/app.log" }; // append + rotate
std::log::ConsoleSink { }; // colored badges

FileSink appends every event and rotates by size — app.logapp.log.1 → … up to keep_files, oldest evicted, all atomic renames. I/O failures never crash your program; they land in the sink’s last_error_kind() / last_error_errno() / last_error_path() accessors. ConsoleSink renders 14:02:07 WARN app.db retry 1/3 with colored level badges — automatically disabled when output isn’t a terminal (and NO_COLOR always wins). Both send WARN/ERROR to stderr so shell pipelines and CI capture keep the signal lane separate. Run several sinks at once — they’re all just subscribers.

That decoupling — emitters publish, sinks subscribe, neither holds a reference to the other — is the bus, Hale’s typed publish/subscribe channel. Logging is a small, friendly instance of it: Logger publishes a LogEvent on a topic, StdoutSink subscribes. The same mechanism carries any typed message between any two loci in your program.

At this level you’ve used the bus without declaring one. The services tier makes it first-class: you declare your own topics, subscribe and publish them in a locus’s bus { } block, and use them to wire concurrent components together. Everything you just saw — emit, subscribe to a subtree, no direct references — is exactly how it works at scale.


That’s the everyday tier. With loci, collections, files, JSON, HTTP, config, and logging, you can build real applications — CLIs, web services, data tools. The next tier is for programs that run over time and coordinate: long-lived services, a typed bus you design, concurrency, and supervision.

Next: The lifecycle.