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

Writing a log sink

A log sink is a service that implements slog.Handler and declares Provides[slog.Handler]() — nothing more. This demo builds a useful one: a ring buffer that keeps the last N records in memory and flushes them to a file on shutdown. Cheap in the happy case, gold when a process dies and you want the last thing it saw.

The sink-author contract#

Four rules, all standard slog semantics stated explicitly:

  • Views, not copies. WithAttrs/WithGroup must return derived views sharing the underlying resource — never return s (loses the attrs), never a deep copy (duplicates the resource). Views are ephemeral; only the registered service instance owns the resource.
  • Concurrency-safe. Many derived loggers share one sink.
  • Prompt. Handle runs on the caller's goroutine — the multihandler fan-out is synchronous. A sink doing I/O applies its own deadlines.
  • Operational when Configured() returns. The startup buffer replays right after the Configured phase; a sink that is ready then captures the complete startup history. Start is typically a no-op — sinks stay Starters because only started Starters receive Stop, and Stop is where resources close.

The shared resource#

The ring itself doubles as an io.Writer, so the actual record formatting can be delegated to a stock slog.TextHandler writing into it:

go
go
package ringsink

import (
    "sync"
)

// ring is the shared resource behind every view of the sink.
type ring struct {
    mu    sync.Mutex
    lines [][]byte
    size  int
}

func (r *ring) Write(p []byte) (int, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    line := append([]byte(nil), p...) // p is only valid during the call
    if len(r.lines) == r.size {
        copy(r.lines, r.lines[1:])
        r.lines[len(r.lines)-1] = line
    } else {
        r.lines = append(r.lines, line)
    }
    return len(p), nil
}

The service#

go
go
import (
    "context"
    "log/slog"
    "os"

    fw "sxcli.dev/fw"
)

type Config struct {
    Size int    `json:"size" arg:"ring-size" usage:"records to keep"`
    Dump string `json:"dump" arg:"ring-dump" usage:"file to flush the ring to on exit"`
}

type Ring struct {
    cfg   Config
    ring  *ring
    inner slog.Handler
}

func (s *Ring) Configured() error {
    s.ring = &ring{size: s.cfg.Size}
    s.inner = slog.NewTextHandler(s.ring, &slog.HandlerOptions{
        Level: slog.LevelDebug, // keep everything; the ring is the filter
    })
    return nil // operational NOW — in time for the startup replay
}

func (s *Ring) Start() error { return nil } // resources opened in Configured

func (s *Ring) Stop() error { // this is where the ring pays off
    if s.cfg.Dump == "" {
        return nil
    }
    s.ring.mu.Lock()
    defer s.ring.mu.Unlock()
    f, err := os.Create(s.cfg.Dump)
    if err != nil {
        return err
    }
    for _, line := range s.ring.lines {
        if _, err := f.Write(line); err != nil {
            f.Close()
            return err
        }
    }
    return f.Close()
}

A Stop error is logged but never changes the process exit code and never prevents other services' Stop calls — shutdown reporting is best-effort by design.

The handler methods#

Delegation makes the contract almost free: the inner TextHandler already implements views correctly, and every view keeps writing into the one shared ring:

go
go
func (s *Ring) Enabled(ctx context.Context, l slog.Level) bool {
    return s.inner.Enabled(ctx, l)
}

func (s *Ring) Handle(ctx context.Context, r slog.Record) error {
    return s.inner.Handle(ctx, r)
}

func (s *Ring) WithAttrs(attrs []slog.Attr) slog.Handler {
    return s.inner.WithAttrs(attrs) // a view — shares the ring, owns nothing
}

func (s *Ring) WithGroup(name string) slog.Handler {
    return s.inner.WithGroup(name)
}

Registration and use#

go
go
func init() {
    s := &Ring{cfg: Config{Size: 200}} // field values are the defaults
    fw.Register("ringsink", s,
        fw.Provides[slog.Handler](),
        fw.WithConfig(&s.cfg),
        fw.WithMetadata(&fw.Metadata{
            Description: "In-memory ring of recent log records, " +
                "flushed to a file on exit.",
        }))
}

The metadata is optional but cheap: the description feeds Describe() on the introspection surface, and the shipped sinks declare theirs the same way. Fields with a fixed set of valid values would add fw.FieldMetadata entries — see Your config struct.

Blank-import the package from main and it behaves like the shipped sinks — cold until wanted:

sh
sh
$ mytool --enable ringsink --ring-dump /var/tmp/mytool-last.log
$ MYTOOL_RING_SIZE=500 mytool    # derived env names work as usual
$ cat /var/tmp/mytool-last.log   # after any run: the last records

Because it declares Provides[slog.Handler](), the multihandler picks it up automatically once it is in the closure — every slog call in the binary now also lands in the ring, including the replayed startup records. And any service that wants the ring specifically can inject:"ringsink" it, as shown in Logging.