sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.3.0). Switch to the current version →

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:

PackageAliasWhat it writes
sxcli.dev/fw/sink/consoleconsolestderr or stdout, text or json
sxcli.dev/fw/sink/filelogfilea log file
sxcli.dev/fw/sink/syslogsyslogsyslog / journald

A sink joins the binary like any other service — the composition accepts it, and the operator activates it per invocation:

go
go
fw.Builder().
    Accept(myapplet.ID, console.ID, file.ID).
    Main()
sh
sh
$ 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:

  • Enabled answers true if any sink accepts the level — so a debug record is produced if even one sink wants it.
  • Handle fans out to every accepting sink. Errors are joined; one failing sink never blocks the rest.
  • WithAttrs / WithGroup derive views of all children, so slog.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:

  1. At startup, slog.Default() is a buffering handler that collects every record.
  2. After the Configured phase, the multihandler is assembled and the buffer replays into it — the sinks receive the complete startup history.
  3. 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 AlwaysOn and the core force-pulled it when nothing else was enabled — with a --disable console escape that meant deliberate silence. AlwaysOn is 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:

go
go
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.