Logging
Logging is built on log/slog, and from a service's point of view there
is nothing to learn: call slog.Info, slog.Error and friends as in any
Go program. No logger is injected, no logging interface is imposed — the
framework's work happens behind slog.Default().
Sinks are services#
A log sink is an ordinary service declaring
Provides(fw.Iface[slog.Handler]()). Three ship with the framework, each
a subpackage with its own config struct and alias:
| Package | Alias | What it writes |
|---|---|---|
sxcli.dev/fw/sink/console | console | stderr or stdout, text or json |
sxcli.dev/fw/sink/file | logfile | a log file |
sxcli.dev/fw/sink/syslog | syslog | syslog / journald |
A sink joins the binary like any other service — the composition accepts it, and the operator activates it per invocation:
fw.Builder(). Accept(myapplet.ID, console.ID, file.ID). Main()
$ mytool --enable console --console-format json $ mytool --enable logfile --logfile-path /var/log/mytool.log $ mytool --enable syslog # journald picks this up under systemd
Accepting a sink makes it available; it stays cold until something
depends on it or --enable pulls it in. That is the whole activation
story now — there is no registration flag that makes a sink special.
The multihandler#
The core assembles all enabled sinks behind one slog.Handler:
Enabledanswers true if any sink accepts the level — so a debug record is produced if even one sink wants it.Handlefans out to every accepting sink. Errors are joined; one failing sink never blocks the rest.WithAttrs/WithGroupderive views of all children, soslog.With(…)works exactly as expected.
The fan-out is deliberately synchronous, on the caller's goroutine: a record either reaches the sinks or the failure surfaces where the log call happened. A sink is expected to be prompt and to apply its own I/O deadlines — a hung sink is the sink's bug, not something the core papers over with queues.
Startup buffering and replay#
Anything logged before the sinks exist would normally be lost — init()
registrations, dispatch, config loading all happen first. The framework
closes that gap:
- At startup,
slog.Default()is a buffering handler that collects every record. - After the
Configuredphase, the multihandler is assembled and the buffer replays into it — the sinks receive the complete startup history. - The default handler swaps over; from here records flow directly.
If startup fails before the swap, the buffer flushes to stderr — the diagnostics that explain the failure are never swallowed with it.
The logging floor#
Logging always works. A closure that ends up with no sink at all
falls back to a plain slog text handler on stderr — the framework's
unconditional logging floor. There is always somewhere for output to go,
including from a binary that accepted no sink packages whatsoever.
There is deliberately no silence switch. A binary that wants no
output redirects stderr itself; the framework will not offer an option
whose effect is to throw diagnostics away. Want quiet rather than mute?
Raise the threshold — --console-level error.
This replaces the v0.2.0 arrangement, where the console sink declared itself
AlwaysOnand the core force-pulled it when nothing else was enabled — with a--disable consoleescape that meant deliberate silence.AlwaysOnis gone, so the guarantee moved into the core where it cannot be switched off.
Direct access#
A service that needs a specific sink — not the fan-out — can inject it like any other service:
type Auditor struct { // by id — this sink, not the multihandler Audit slog.Handler `inject:"sxcli.dev/fw/sink/file"` }
Want your own sink? The writing a log sink demo builds a complete one — a ring buffer flushed on exit — and walks the sink-author contract along the way.