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/WithGroupmust return derived views sharing the underlying resource — neverreturn 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.
Handleruns 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 theConfiguredphase; a sink that is ready then captures the complete startup history.Startis typically a no-op — sinks stayStarters because only started Starters receiveStop, andStopis 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:
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#
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:
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#
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:
$ 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.