sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.1.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 that declares Provides[slog.Handler](). Three ship with the framework, each a subpackage with its own config struct:

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

sh
sh
$ 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:

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

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:

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