Writing a log sink
A log sink is a service that implements slog.Handler and declares
Provides(fw.Iface[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#
package ringsink import ( "log/slog" "os" ) type Config struct { Version uint32 `json:"version"` Size int `json:"size" conf:"ring-size" usage:"records to keep"` Dump string `json:"dump" conf:"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:
package ringsink import ( "context" "log/slog" ) 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#
package ringsink import ( "log/slog" "sxcli.dev/fw" ) const ID = "example.com/ringsink" func init() { fw.NewRegistration(ID, // the factory sets the defaults func() *Ring { return &Ring{cfg: Config{Version: 1, Size: 200}} }, func(r *Ring) *Config { return &r.cfg }, ). Alias("ringsink"). Provides(fw.Iface[slog.Handler]()). Metadata(&fw.Metadata{ Description: "In-memory ring of recent log records, " + "flushed to a file on exit.", }). Register() }
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.
Accept(ringsink.ID) in the composition and it behaves like the shipped
sinks — available, but 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(fw.Iface[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:"example.com/ringsink" it — naming the id, as shown in
Logging.