slogging: Delete Twenty Lines of slog Boilerplate With One Blank Import

Go 1.21 shipped log/slog and it’s good. What it doesn’t ship is any way to configure it, so every service starts with a version of this:

var level slog.Level
switch os.Getenv("LOG_LEVEL") {
case "debug":
	level = slog.LevelDebug
case "warn":
	level = slog.LevelWarn
case "error":
	level = slog.LevelError
default:
	level = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: level, AddSource: os.Getenv("LOG_ADD_SOURCE") == "true"}
var h slog.Handler
if os.Getenv("LOG_FORMAT") == "json" {
	h = slog.NewJSONHandler(os.Stdout, opts)
} else {
	h = slog.NewTextHandler(os.Stdout, opts)
}
slog.SetDefault(slog.New(h))

Twenty-odd lines, copy-pasted into every repo, subtly different in each one because somebody’s version handles LOG_LEVEL=DEBUG in caps and somebody’s doesn’t. And everything it produces goes to stdout, including the errors.
slogging — slog plus logging, say it out loud and the name is the whole joke — replaces all of it with an import that does nothing but exist:

import _ "github.com/psyb0t/slogging/slogconf"

What comes out

Nothing else in your code changes. Plain slog calls, no wrapper type, no logging.GetLogger():

slog.Info("this is an info message", "user", "psyb0t", "action", "testing")
slog.Error("this is an error message", "error_code", "E001")

Three environment variables drive it:

export LOG_LEVEL="debug"      # debug / info / warn / error
export LOG_FORMAT="json"      # json / text
export LOG_ADD_SOURCE="true"  # include source file/line/function
{"time":"2026-08-08T20:34:53.296Z","level":"INFO","msg":"this is an info message","user":"psyb0t","action":"testing"}

And the part you get without asking: info and debug go to stdout, warnings and errors go to stderr. Stdlib slog puts everything on stderr. Splitting means a container log collector captures both and tags them separately, so error noise stays out of your happy-path stream without anything parsing a level field back out of formatted text.
That’s the whole package for most services. Everything past this point is for when it isn’t.

Two kinds of thing, and the split is the design

slogconf/              configure slog from the environment
handlers/              Handler (the process's output) + FanOutHandler (tees to many)
handlers/logring/      bounded in-memory ring you can search
handlers/loki/         push records to Loki's HTTP API

handlers holds the structural pieces — the one that writes your output, and the one that copies a record to many. Its subpackages are destinations: a searchable ring, a Loki server. You configure one output and add as many destinations as you like.
Runtime dependencies for all of it: ctxerrors. No config loader, no HTTP framework.

Adding a destination and moving your output are different calls

This is the distinction most likely to bite, and it’s load-bearing rather than cosmetic:

slogconf.AddSink(ring)        // ALSO send records here — appends
slogconf.SetOutput(handler)   // send my output THERE instead — replaces, keeps sinks
slogconf.SetHandlers(h)       // start over: replaces the output AND every sink

Init already installed an output writing to stdout and stderr. So adding a second handler that also writes to the terminal doesn’t replace the first — both get every record and print each line twice. SetOutput is the call that swaps it, and it leaves your sinks alone. SetHandlers is the escape hatch, mostly for tests pointing everything at a buffer.
Both AddSink and SetOutput return false when something else had already replaced slog’s default. Still applied — what you lost is the stdout/stderr split, which is worth being told rather than discovering through absent logs.
Underneath, the chain is a fan-out, and a handler that fails doesn’t take the others with it. Every handler gets the record regardless and failures come back joined. That’s not politeness: slog discards whatever Handle returns, so 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.

The output handler, when the defaults don’t fit

h, err := handlers.NewStd(handlers.Options{Format: handlers.FormatJSON})
slogconf.SetOutput(h)

Point both sides somewhere else, or at several writers each:

h, err := handlers.New(
	handlers.Options{Format: handlers.FormatJSON},
	handlers.Stdout(os.Stdout, logFile),
	handlers.Stderr(os.Stderr),
)

Several writers on one side get the same bytes. Different renderings per destination is a different job — build a Handler each and tee them with handlers.NewFanOut(...). Point both sides at the same writer and everything lands together, which is exactly what stdlib slog does. Options.SplitAt moves the boundary; it defaults to slog.LevelWarn.
Options.Level is the one setting that stays live, and this was a real bug before v1.7. It’s a slog.Leveler, resolved on every record instead of read once at construction — so a *slog.LevelVar, the standard library’s documented way to change level at runtime, actually works:

level := new(slog.LevelVar)
h, _ := handlers.NewStd(handlers.Options{Level: level})
slogconf.SetOutput(h)
level.Set(slog.LevelDebug)   // takes effect immediately

The old handler stored the resolved level, so bumping a LevelVar did nothing at that layer while the inner handlers still honoured it. Half-working, silent, and invisible to the compiler, vet, the linter and every test that existed.

Answering “what just happened” without leaving the process

logring is a bounded in-memory ring 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 %d\n", len(page.Entries), page.Total)

Three things in there are deliberate.
It’s bounded by bytes, not 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() is the number that decides when records start disappearing, Len() is how many that is, and Stats() gives both plus a drop count under one lock. Nonzero drops mean records were refused for exceeding the per-record cap and your search is running over an incomplete picture.
Search returns a page, not a slice. Total is the match count before Limit and Offset, counted in the same locked walk that collected the entries — because taking it from a separate Count call means two locks, and on a live ring the second can describe a ring the page never came from. That’s paging that skips or repeats records.
Attrs matches structured attributes, not substrings. It reads them off the record, so it behaves identically in text or JSON mode and finds attributes bound upstream through logger.With(...) that never appear in the formatted line. Grouped attrs use dotted keys: WithGroup("http") logging status matches http.status. Eleven filters in total, plus Tail(n) for the newest records unfiltered.
And the caveat that matters: this is a debugging aid, not a log store. Per-process, bounded, and gone the moment the process dies. Ship your logs somewhere durable as well.

Which is what the Loki handler is for

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.
Choose LabelKeys carefully. Loki indexes by label and every distinct value creates a new stream — label something like request_id and you get one stream per request, which is how people melt a Loki install by accident. 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 — dropped with a Debug line. Same reasoning as the fan-out: slog throws the error away, so surfacing one achieves nothing, and retrying would let a dead log aggregator stall an application whose only crime was trying to log.

Coming from slog-configurator

This module was slog-configurator through v1.5.0, and those versions still resolve, so nothing breaks until you move. That move was a find-and-replace on import paths — every exported name came across unchanged:

_ "github.com/psyb0t/slog-configurator"       ->  _ ".../slogging/slogconf"
".../slog-configurator/logring"               ->  ".../slogging/handlers/logring"
"github.com/psyb0t/common-go/slogging/loki"   ->  ".../slogging/handlers/loki"

Since v1.7.0 the handler API moved too, and that one is not just paths:

slogconf.AddHandler(sink)        ->  slogconf.AddSink(sink)
—                                ->  slogconf.SetOutput(h)
slogconf.MultiWriterHandler      ->  handlers.Handler
slogconf.NewFanOutHandler        ->  handlers.NewFanOut

MultiWriterHandler never was one — it took exactly two writers and routed by level, which is roughly the opposite of what io.MultiWriter means, and the thing that genuinely tees to many was the fan-out all along.


105 tests against a 90% coverage floor, one runtime dependency, four packages. For most services it’s still one blank import and three environment variables — the rest is there for the day you need the last 100 MiB searchable, or the same records in Loki, or both at once.
github.com/psyb0t/slogging