Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

log/slog, and the day go logging stopped being a religious argument

How log/slog in the standard library changed how I structure logs in Go, with handlers, context, and the mistakes I made along the way.

A developer's screen full of Go code

For years, "which logging library" was one of those Go questions that revealed more about a team than the answer mattered. logrus, zap, zerolog, the one someone wrote in-house in 2019 and never quite finished. Each had its partisans, each had its quirks, and every new service started with a small argument about which to import. Since log/slog landed in the standard library in Go 1.21, that argument has mostly evaporated, and I'm glad.

I want to talk about how I actually use it now, because the API rewards a couple of habits and punishes a couple of others, and I learned both the hard way.

structured first, message second

The whole point of structured logging is that the machine reads it before a human does. So the message is a stable, low-cardinality string, and everything that varies goes in the attributes:

logger.Info("request completed",
    "method", r.Method,
    "path", r.URL.Path,
    "status", status,
    "duration_ms", elapsed.Milliseconds(),
)

The temptation, always, is to write logger.Info(fmt.Sprintf("request to %s took %dms", path, ms)). Don't. The moment you interpolate, you've made the message high-cardinality and you can no longer group, count, or alert on it cleanly. The string is the index; keep it constant.

handlers are the whole game

slog splits the front end (the Logger you call) from the back end (the Handler that decides what to do with records). The standard library ships two: a text handler for humans and a JSON handler for machines. I wire them with an environment switch so local development is readable and production is parseable:

var handler slog.Handler
if os.Getenv("ENV") == "production" {
    handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
    handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
}
logger := slog.New(handler)

Because the handler interface is small and public, the ecosystem has filled in the gaps quickly. There are handlers that ship to your log aggregator, handlers that add colour, handlers that sample. You write against slog.Handler and swap the back end without touching call sites. That's the bit the old libraries never quite agreed on, and now it's settled in the standard library where it belongs.

context, and the trap I fell into

slog has context-aware methods: logger.InfoContext(ctx, ...). The natural assumption is that this magically pulls fields off the context. It does not, not by itself. The context is passed to the handler, and it's up to the handler to do something with it. If you want a request ID to appear on every line, you need a handler that reaches into the context and pulls it out.

I spent an embarrassing afternoon wondering why my trace IDs weren't showing up before I understood this. The fix is a small wrapping handler:

type contextHandler struct {
    slog.Handler
}

func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
    if id, ok := ctx.Value(requestIDKey).(string); ok {
        r.AddAttrs(slog.String("request_id", id))
    }
    return h.Handler.Handle(ctx, r)
}

Now anything that carries a request ID through the context gets it stamped on every log line, automatically, with no per-call ceremony. This is the pattern I wish someone had shown me on day one.

with, and not over-using it

logger.With(...) returns a child logger that carries some attributes forward. It's lovely for request-scoped loggers:

reqLog := logger.With("request_id", id, "user", user)

The trap is treating it as free. Each With allocates and copies, and if you chain it in a hot loop you'll find it in your allocation profile. For a per-request logger built once, it's exactly right. For something called a million times a second, think twice.

what I no longer do

I no longer reach for a third-party logger by reflex. I no longer write a logging wrapper that reinvents slog with worse ergonomics. And I no longer have the argument, the one about which library, because the answer is "the one in the standard library, unless you have a measured reason otherwise". zap is still faster in the extreme tail, and if you're logging at a volume where that matters you'll know. For the other 95% of services, slog is fast enough, sane, and already there.

The quiet win in all this is consistency. Every Go service I touch now logs the same way, parses the same way, and stamps context the same way. That's worth more than any single library's benchmark. The boring, standard answer turned out to be the right one, which is my favourite kind of outcome.