slog-configurator: Same Zero-Config Logging, Now on the Standard Library

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 records

Run 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=broken

Flip 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() reads LOG_LEVEL, LOG_FORMAT, LOG_ADD_SOURCE via gonfiguration, builds a MultiWriterHandler, wraps it in a FanOutHandler, calls slog.SetDefault. Done before main() runs.
  • MultiWriterHandler holds two underlying handlers (one per stream), routes records by level. WithAttrs and WithGroup recurse into both.
  • FanOutHandler is the dispatcher. AddHandler grabs the current default, type-asserts it to *FanOutHandler, appends, and re-sets. SetHandlers blows the whole thing away and installs new ones.
  • Format picks between slog.NewJSONHandler and slog.NewTextHandler from 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-configurator import with github.com/psyb0t/slog-configurator.
  • Replace logrus.Info(...) calls with slog.Info(...) — keyword arguments instead of formatted strings.
  • LOG_CALLER becomes LOG_ADD_SOURCE. Same idea, slog’s name for it.
  • Levels: drop trace, fatal, panic. slog ships with debug, info, warn, error only. If you need fatal, call os.Exit(1) after logging. If you need panic, panic.
  • AddHook becomes AddHandler. SetHooks becomes SetHandlers. The handlers you write conform to slog.Handler instead of logrus.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.