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 that declares
Provides[slog.Handler](). Three ship with the framework, each a
subpackage with its own config struct:
import ( _ "sxcli.dev/fw/sink/console" // stderr, text or json — AlwaysOn _ "sxcli.dev/fw/sink/file" // log file _ "sxcli.dev/fw/sink/syslog" // syslog / journald )
Because sinks are services, activation falls out of machinery you already
know — imports decide what is linked in, the closure and
enable/disable decide what runs, and each sink's settings are just
another config struct:
$ mytool --enable logfile --logfile-path /var/log/mytool.log $ mytool --enable syslog # journald picks this up under systemd
The console sink registers itself as AlwaysOn, so importing it is
enough — every invocation gets sane stderr output by default. The file
and syslog sinks stay cold until something pulls them in.
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.
Defaults, quiet, and deliberate silence#
- No enabled sink after resolution? The core force-pulls the console sink into the closure — unless the operator explicitly disabled it. Disabling every sink means deliberate silence, and the framework respects it as stated: your choice, your problem.
- Want quiet, not mute? Raise the threshold instead:
--console-level error. - Console sink not even linked in? The last resort is a plain stderr text handler — there is always somewhere for output to go.
Direct access#
A service that needs a specific sink — not the fan-out — can inject it like any other service:
type Auditor struct { Audit slog.Handler `inject:"logfile"` // this sink, not the multihandler }
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.