A logging setup is never just the setup. You configure slog from the environment — fine, that’s one small package. Then you want the last few minutes of logs queryable from inside the process without shelling into anything. Then you want them in Loki. And now the “one small package” is three, scattered across two repos and a common-go subdirectory, and nobody can tell you which one owns what.
That’s the actual reason slogging exists. It isn’t new code so much as the same code finally living in one place: slog-configurator became slogconf/, its logring moved to handlers/logring/, and the Loki handler was dragged out of common-go into handlers/loki/.
slogconf/ configure slog from the environment
handlers/ Handler (the process output) + FanOutHandler (tees to many)
handlers/logring/ bounded in-memory ring you can search
handlers/loki/ push records to Loki's HTTP APIOne runtime dependency for the whole module: ctxerrors. No config loader, no HTTP framework. The Loki handler in particular shed gonfiguration and common-go on the way over and reads its two env vars through the standard library instead.
The blank import still does everything
None of the reorganising cost the thing that made it worth using:
import _ "github.com/psyb0t/slogging/slogconf"That’s it. LOG_LEVEL (debug/info/warn/error), LOG_FORMAT (text/json), LOG_ADD_SOURCE, and slog is configured before your main runs.
When the defaults don’t fit, Init takes them as arguments — and the important part is that you name the variables:
slogconf.Init(slogconf.Options{
LevelEnvVar: "MYAPP_LOG_LEVEL",
FormatEnvVar: "MYAPP_LOG_FORMAT",
})Only the variables you name get read. A stray LOG_LEVEL in the environment can’t sneak in behind yours — which matters the moment your binary runs inside somebody else’s container. DefaultLevel, DefaultFormat and DefaultAddSource move the fallbacks; the zero Options{} is exactly what the blank import does.
One trap worth knowing: call it early. slog.Logger.With snapshots the handler chain at the moment you call it, so a logger derived before Init keeps pointing at the old one and quietly ignores everything you configured.
The handler that was lying about its own name
Until v1.7 the default handler was called MultiWriterHandler, and it was not a multi-writer. It took exactly two writers and routed between them by level — roughly the opposite of what io.MultiWriter means. The thing that genuinely tees to many was FanOutHandler the whole time.
So it’s handlers.Handler now, named for what it actually is: the process’s output, sending every record to one of two writer sets chosen by level.
h, err := handlers.NewStd(handlers.Options{Format: handlers.FormatJSON})
slogconf.SetOutput(h)Warn and above to stderr, everything below to stdout. Which sounds cosmetic until something consumes your output: 2>/dev/null should hide the problems and leave the chatter, a pipeline wants stdout as data while a human watches stderr, and systemd, Docker and every log shipper treat the two differently by default. Write everything to one stream and you can’t get that back downstream without parsing levels out of formatted text.
Two things stopped being hardcoded. The split point is a parameter — Options.SplitAt, defaulting to slog.LevelWarn, rather than a branch buried inside Handle. And point both sets at the same writer and everything lands together, which is exactly what stdlib slog does, since it puts every level on stderr. Splitting is a configuration, not a separate kind of handler, so there’s no SplitHandler.
Several writers per stream finally works, through options:
h, err := handlers.New(
handlers.Options{Format: handlers.FormatJSON},
handlers.Stdout(os.Stdout, logFile),
handlers.Stderr(os.Stderr),
)The reason that’s options rather than parameters is a plain language constraint: Go allows only a function’s final parameter to be variadic, so one constructor taking two variadic writer lists isn’t expressible at all. Want different renderings per destination instead of the same bytes? That’s a different job — build a Handler each and tee them with handlers.NewFanOut(...).
The log level that silently never changed
Options.Level is a slog.Leveler, resolved on every record rather than read once at construction. That’s a bug fix, and a nasty one.
The old handler stored the resolved slog.Level — it called opts.Level.Level() once when you built it. So handing it a *slog.LevelVar, which is the standard library’s documented way to change level at runtime, did nothing at that layer. The inner handlers still honoured it, so it half-worked: invisible to the compiler, to vet, to the linter, and to every test that existed.
level := new(slog.LevelVar)
h, _ := handlers.NewStd(handlers.Options{Level: level})
slogconf.SetOutput(h)
level.Set(slog.LevelDebug) // takes effect immediately nowReintroducing the snapshot now fails a test that exists specifically to catch it.
Adding a sink and moving your output are different calls
This one was two silent bugs wearing a trench coat, and v1.7 splits them apart.
Init already installs an output writing to stdout and stderr. So under the old single AddHandler, adding a second console handler didn’t replace the first — both got every record and printed each line twice. And the only call that did replace it also deleted your ring buffer and your Loki shipper along with it.
The output now occupies a reserved slot, and there are three calls with three different jobs:
AddSink(h)— stack something alongside without touching where the process prints. A ring, a shipper, a test capture.SetOutput(h)— replace the output, keep the sinks. This is “send my logs somewhere else”.SetHandlers(...)— the escape hatch that discards everything and starts over.
Both failures were silent before, so both are now asserted by tests rather than warned about in a doc comment.
AddSink and SetOutput return a bool: false means something else had already replaced slog’s default, so there was no slot to swap and the whole chain got replaced instead. Your handler is still installed; what you lost is the stdout/stderr split. Worth noticing, not worth panicking over — and much better than finding out through absent logs.
Why a broken sink can’t take the rest down
Under the hood the chain is a FanOutHandler, and every handler gets the record whether or not an earlier one failed. Failures come back joined.
That isn’t politeness, it’s the only workable design: slog discards whatever Handle returns. A fan-out that bailed on the first error would let an unreachable Loki silently kill your stdout logging, with nothing anywhere to say why — logs go dark exactly when you need them.
It also keeps carrying the slog.Handler interface rather than a concrete type, which is precisely what lets a ring buffer and an HTTP shipper share one chain.
The ring: answering “what just happened” without leaving the process
handlers/logring is a bounded in-memory buffer that is a slog.Handler, so it stacks like anything else:
ring := logring.New(logring.Options{})
slogconf.AddSink(ring)
page := ring.Search(logring.SearchOptions{
Attrs: map[string]string{"request_id": "abc123"},
MinLevel: slog.LevelWarn,
Limit: 50,
})
fmt.Printf("showing %d of %dn", len(page.Entries), page.Total)Three design calls in there are worth pulling out.
It’s bounded by bytes, not by record count. 100 MiB by default, with a 1 MiB per-record cap. Count-bounding is the obvious choice and it’s wrong: one pathological 100 KB line evicts a hundred useful ones. Size() reports the bytes retained, Len() the number of records, and Stats() returns both plus the drop count under a single lock. A nonzero drop count means records were refused for exceeding the per-record cap — your search is running over an incomplete picture and you should know that.
Search returns a page, not a slice. Page{Entries, Total, Offset}, where Total is the match count before Limit and Offset apply, counted in the same locked walk that collected the entries. Getting the total from a separate Count call means two locks, and on a live ring the second one can describe a ring the page never came from — paging that skips or repeats records. Only the ring can hold one lock across both reads.
Attrs matches structured attributes, not substrings. It reads the attributes off the record, so it behaves identically in text or JSON mode, and it finds attributes bound upstream through logger.With(...) that never appear in the formatted line at all. Grouped attrs use dotted keys — WithGroup("http") logging status matches http.status.
Eleven filters in total: Contains, Exclude, Match (a compiled *regexp.Regexp), Attrs, MinLevel, Levels, Since, Until, Limit, Offset, Ascending. Plus Tail(n) when you just want the newest records unfiltered.
Loki, and the two things it deliberately refuses to do
client, _ := loki.NewClient() // reads SLOGGING_LOKI_URL
handler, _ := loki.NewHandler( // reads SLOGGING_LOKI_APPNAME
client,
slog.LevelInfo,
map[string]bool{"tenant": true}, // these attrs become Loki LABELS
)
slogconf.AddSink(handler)NewClientWithConfig and NewHandlerWithConfig take the same settings directly if you’d rather keep the environment out of it.
Pick LabelKeys carefully. Loki indexes by label and every distinct value creates a new stream — so labelling something like request_id gives you one stream per request, which is how people accidentally melt a Loki install. Attributes you don’t name go into the log line instead. app, level and service are always labels.
Pushes are best-effort and never block. Unreachable Loki, malformed payload, a 500 — all dropped with a Debug line and nothing else. Again: slog throws away the error, so surfacing one achieves nothing, and retrying would let a dead log aggregator stall an application whose only crime was trying to log.
Moving off slog-configurator
The module was github.com/psyb0t/slog-configurator through v1.5.0 and those versions still resolve, so nothing breaks until you choose to move. When you do, it’s a find-and-replace on import paths — every exported name is unchanged:
_ "github.com/psyb0t/slog-configurator" -> _ ".../slogging/slogconf"
slogconfigurator.Init(...) -> slogconf.Init(...)
".../slog-configurator/logring" -> ".../slogging/handlers/logring"
"github.com/psyb0t/common-go/slogging/loki" -> ".../slogging/handlers/loki"Only the paths and the package name move. No API to relearn.
105 tests against a 90% coverage floor, one runtime dependency, and a blank import that still does the whole job. Configure it from the environment, keep the last 100 MiB searchable in-process, ship the rest to Loki — four packages that were always going to end up in the same binary, finally shipped from the same module.
github.com/psyb0t/slogging