A while back I wrote about logrus-configurator — a small package that auto-configures logrus from environment variables so you stop copy-pasting the same setup boilerplate into every Go project. It works. It’s been in production across half my services for years.
But Go has a standard logger now. log/slog shipped in Go 1.21, and it’s actually good. Structured logging, context-aware handlers, attribute groups, real handler composition — all in the stdlib, no third-party dependency required. The right move is to use it. The wrong move is to spend twenty minutes wiring up handlers, level parsing, JSON-versus-text switching, and stdout/stderr separation in every new service.
So I ported the configurator. Meet slog-configurator — same idea, same env-var ergonomics, but for the standard library logger. The logrus version is now archived in favor of this one.
What It Does
Import it for the side effect. That’s the whole API for the basic case.
package main
import (
"log/slog"
_ "github.com/psyb0t/slog-configurator"
)
func main() {
slog.Debug("debug message", "key", "value", "number", 42)
slog.Info("info message", "user", "psyb0t", "action", "testing")
slog.Warn("warning", "code", "W001")
slog.Error("error", "code", "E001", "details", "broken")
}Configure it with environment variables:
export LOG_LEVEL="debug" # debug, info, warn, error
export LOG_FORMAT="text" # text or json
export LOG_ADD_SOURCE="true" # include file:line in recordsRun it and you get slog records routed properly:
time=2026-04-29T12:00:00.000Z level=DEBUG source=main.go:10 msg="debug message" key=value number=42
time=2026-04-29T12:00:00.000Z level=INFO source=main.go:11 msg="info message" user=psyb0t action=testing
time=2026-04-29T12:00:00.000Z level=WARN source=main.go:12 msg="warning" code=W001
time=2026-04-29T12:00:00.000Z level=ERROR source=main.go:13 msg="error" code=E001 details=brokenFlip LOG_FORMAT=json and the same calls produce structured JSON ready for whatever log aggregator you’re feeding. No code changes, no rebuilds — just env vars.
Stdout vs Stderr, Done Right
Here’s the part most slog setups screw up. By default, slog handlers write everything to a single writer — usually os.Stderr. Which means your operational tooling can’t distinguish “I’m telling you what’s happening” from “something is wrong, page someone.” On Kubernetes, on systemd, on anything that treats stderr as the alarm channel, you’re flooding the alert stream with debug spam and burying actual errors in noise.
slog-configurator ships a MultiWriterHandler by default. Debug and Info records go to stdout. Warn and Error go to stderr. That’s how proper logging is supposed to work, and it’s the default here because thinking about it once at the library level beats thinking about it in every service.
Want different writers? Build your own:
import slogconfigurator "github.com/psyb0t/slog-configurator"
handler, err := slogconfigurator.NewMultiWriterHandler(
"json",
&slog.HandlerOptions{Level: slog.LevelDebug, AddSource: true},
myStdoutWriter,
myStderrWriter,
)
slogconfigurator.SetHandlers(handler)Handler Stacking, Like logrus Hooks
The other thing I missed when moving from logrus to slog: hooks. logrus had a clean AddHook mechanism that let you bolt extra destinations onto your logger without touching the existing setup — Slack alerts on errors, metrics counters, database audit trails, whatever. slog has handler composition but it’s awkward in practice. You either replace the default handler entirely (and lose stdout/stderr routing) or build a custom dispatcher every time.
slog-configurator solves this with a FanOutHandler as the default top-level handler. Every record gets dispatched to all registered handlers. You stack new ones with AddHandler and they fire alongside the default routing:
import slogconfigurator "github.com/psyb0t/slog-configurator"
// stack new handlers — existing stdout/stderr handler keeps firing
slogconfigurator.AddHandler(myMetricsHandler)
slogconfigurator.AddHandler(mySlackAlertHandler)
slogconfigurator.AddHandler(myDBAuditHandler)
// or go nuclear — replace everything
slogconfigurator.SetHandlers(myCustomHandler1, myCustomHandler2)WithAttrs and WithGroup propagate through the fan-out correctly, so attribute scoping works the way slog expects. Same record reaches every handler with the same attrs and groups applied. No surprises, no half-configured downstream destinations.
The Design
The whole package is a few hundred lines of Go. The architecture is intentionally boring:
init()readsLOG_LEVEL,LOG_FORMAT,LOG_ADD_SOURCEvia gonfiguration, builds aMultiWriterHandler, wraps it in aFanOutHandler, callsslog.SetDefault. Done beforemain()runs.MultiWriterHandlerholds two underlying handlers (one per stream), routes records by level.WithAttrsandWithGrouprecurse into both.FanOutHandleris the dispatcher.AddHandlergrabs the current default, type-asserts it to*FanOutHandler, appends, and re-sets.SetHandlersblows the whole thing away and installs new ones.- Format picks between
slog.NewJSONHandlerandslog.NewTextHandlerfrom the standard library. Nothing exotic — just the stdlib handlers, used correctly.
That’s it. No magic, no global state beyond what slog already has, no surprise allocations on the hot path. The fan-out checks Enabled per handler before dispatching, so handlers that don’t care about a given level get skipped without paying for the call.
Testing
90%+ coverage, table-driven tests across every level, format, and handler-management path. The CI/CD pipeline fails the build below 90%. Edge cases tested include: invalid levels, invalid formats, custom writers, level filtering at the handler level, WithAttrs and WithGroup propagation through both MultiWriterHandler and FanOutHandler, and the type-assertion path in AddHandler when someone replaces the default with a non-FanOutHandler.
It’s tested harder than the code that uses it. That’s how it should be.
Migrating from logrus-configurator
If you’re on the logrus version, the migration is mechanical:
- Replace
github.com/psyb0t/logrus-configuratorimport withgithub.com/psyb0t/slog-configurator. - Replace
logrus.Info(...)calls withslog.Info(...)— keyword arguments instead of formatted strings. LOG_CALLERbecomesLOG_ADD_SOURCE. Same idea, slog’s name for it.- Levels: drop
trace,fatal,panic. slog ships withdebug,info,warn,erroronly. If you need fatal, callos.Exit(1)after logging. If you need panic, panic. AddHookbecomesAddHandler.SetHooksbecomesSetHandlers. The handlers you write conform toslog.Handlerinstead oflogrus.Hook.
The behavior of “import for side effect, configure with env vars, get sensible defaults” is preserved exactly. Your deployment pipelines that set LOG_LEVEL and LOG_FORMAT keep working with no changes.
What Happens to logrus-configurator
It’s archived. The package still works, the existing API is stable, and I’m not deleting it — services already depending on it can stay on it forever. But there will be no new features, and I’m not going to chase logrus releases. The standard library has everything logrus offered (and a better story for context propagation), so investing in a logrus-specific helper doesn’t make sense anymore.
If you’re starting a new service, use slog. Use this configurator. Skip the logrus dependency entirely.
Bottom Line
Same package, same ergonomics, same test coverage discipline — just on top of the right logger this time. Import it, set three env vars, get structured logs with proper stdout/stderr separation, handler stacking that doesn’t fight you, and source location reporting when you need to figure out who logged the weird thing.
Source: github.com/psyb0t/slog-configurator. MIT licensed. Vendored, tested, in use.
Now go build something that logs properly.