Output and fan-out handlers
handlers holds the two structural pieces a chain is built from. Handler writes the process’s own output. FanOutHandler passes one record to several handlers. The destinations, the ring and Loki, live in subpackages.
Handler
Handler sends every record to one of two writer sets, chosen by level. NewStd builds the usual one, stdout for informational records and stderr for warnings and above:
import "github.com/psyb0t/slogging/handlers"
h, err := handlers.NewStd(handlers.Options{Format: handlers.FormatJSON})
if err != nil {
panic(err)
}
slogconf.SetOutput(h)
New takes the writers as options. Stdout and Stderr are variadic and can be passed more than once:
h, err := handlers.New(
handlers.Options{Format: handlers.FormatJSON},
handlers.Stdout(os.Stdout, logFile),
handlers.Stderr(os.Stderr),
)
A side you leave out defaults to the matching os stream. Several writers on one side are joined with io.MultiWriter, so they all get the same bytes. If you want a different rendering per destination, such as text on the terminal and JSON in a file, build one Handler for each and tee them with a FanOutHandler.
Options
| Field | Meaning | Zero value means |
|---|---|---|
Format | handlers.FormatJSON or handlers.FormatText | handlers.DefaultFormat, which is text |
Level | Minimum level emitted, a slog.Leveler | slog.LevelInfo |
AddSource | Attach file, line and function to every record | off |
SplitAt | Level at and above which records go to the stderr side, a slog.Leveler | handlers.DefaultSplitLevel, which is slog.LevelWarn |
Any other format string makes New and NewStd return an error wrapping handlers.ErrInvalidFormat.
Point both sides at the same writer and everything lands together, which is what stdlib slog does: it puts every level on stderr. Splitting is the default here so a log collector can separate error noise from happy-path noise without parsing.
h, err := handlers.New(
handlers.Options{},
handlers.Stdout(os.Stderr),
handlers.Stderr(os.Stderr),
)
Changing the level at runtime
Options.Level is the one setting that stays live. It is a slog.Leveler that the handler reads on every record instead of once at construction, so a *slog.LevelVar you change later takes effect at once:
level := new(slog.LevelVar)
h, err := handlers.NewStd(handlers.Options{Level: level})
if err != nil {
panic(err)
}
slogconf.SetOutput(h)
level.Set(slog.LevelDebug) // takes effect immediately
SplitAt is read per record in the same way. Format, writers and AddSource are fixed once the handler is built.
FanOutHandler
FanOutHandler holds a list of slog.Handler values and passes each record to all of them. It holds the interface, not this package’s Handler, so the ring, the Loki handler and any handler you wrote yourself can share one chain.
console, _ := handlers.NewStd(handlers.Options{})
file, _ := handlers.New(
handlers.Options{Format: handlers.FormatJSON},
handlers.Stdout(logFile),
handlers.Stderr(logFile),
)
slog.SetDefault(slog.New(handlers.NewFanOut(console, file)))
How it behaves:
Enabledis true when any child wants the level. A ring keepingdebugbeside a console atinfois a normal setup, and the fan-out must not veto a record one child asked for.Handlepasses the record to each child whose ownEnabledis true for that level, so the noisier child does not force output on the quieter one.- A failing child does not stop the rest. Every enabled child gets the record, and the failures come back joined into one error, each wrapped with
handler N failed. This matters because slog throws away whateverHandlereturns. A fan-out that stopped at the first error would let one broken destination silence everything after it, with nothing saying why. WithAttrsandWithGroupapply to every child.Lenreports how many children it has.Handlersreturns a copy of the list, so changing the copy does not change where the process logs.
slogconf builds its default logger on a fan-out whose first slot is the output Handler. Its AddSink and SetOutput calls work on that list.