DEV Community

Cover image for slog Beyond Basics: Group, ReplaceAttr, and Custom Handlers
Gabriel Anhaia
Gabriel Anhaia

Posted on

slog Beyond Basics: Group, ReplaceAttr, and Custom Handlers


Most slog posts stop at slog.Info("hello", "user_id", 42). That covers the first week. Then somebody opens a bug report asking why email is showing up in cleartext in CloudWatch, and a week later the SRE team asks why your error logs aren't reaching Sentry.

slog has the answers wired into the API. They live in three corners of the package most posts skip: slog.Group, HandlerOptions.ReplaceAttr, and the Handler interface itself. This post is a tour of those three corners with patterns that survive real codebases: request-scoped grouping, PII redaction, source-path shortening, time formatting, error routing, and OpenTelemetry trace-id injection.

slog.Group: nested attributes, not flat soup

The default slog output is flat:

slog.Info("login",
    "user_id", 42,
    "user_email", "alice@example.com",
    "request_id", "r-91",
    "request_method", "POST",
    "request_path", "/login",
)
Enter fullscreen mode Exit fullscreen mode
{"msg":"login","user_id":42,"user_email":"alice@example.com",
 "request_id":"r-91","request_method":"POST","request_path":"/login"}
Enter fullscreen mode Exit fullscreen mode

That works until somebody asks "give me everything inside request" in a log query and you have to enumerate every prefix. slog.Group collapses related fields into a nested object:

slog.Info("login",
    slog.Group("user",
        "id", 42,
        "email", "alice@example.com",
    ),
    slog.Group("request",
        "id", "r-91",
        "method", "POST",
        "path", "/login",
    ),
)
Enter fullscreen mode Exit fullscreen mode
{"msg":"login",
 "user":{"id":42,"email":"alice@example.com"},
 "request":{"id":"r-91","method":"POST","path":"/login"}}
Enter fullscreen mode Exit fullscreen mode

In structured-log backends (Loki, Datadog, CloudWatch JSON, OpenSearch) the nested form indexes as request.method and queries cleanly. The flat form makes you remember every field name on its own.

The bigger win is WithGroup on the logger itself. A request-scoped logger pre-attaches the group:

reqLog := slog.With(
    "request_id", reqID,
).WithGroup("request")

reqLog.Info("started",
    "method", r.Method,
    "path", r.URL.Path,
)
// → request.method, request.path
// request_id stays at the top level
Enter fullscreen mode Exit fullscreen mode

WithGroup only nests attributes added after the call. The request_id from With stays at the root. Pass reqLog down through your handlers and every log line they emit lands under request.* automatically. No manual prefixing, no string concatenation, no leaks when somebody forgets.

One gotcha. WithGroup("") is a no-op — passing an empty group name does nothing, the docs are explicit. If you compute group names from config, guard against the empty string or your nesting silently flattens.

ReplaceAttr: the surgical knife you reach for daily

Every slog.Handler accepts *slog.HandlerOptions. The interesting field is ReplaceAttr:

type HandlerOptions struct {
    AddSource   bool
    Level       Leveler
    ReplaceAttr func(groups []string, a Attr) Attr
}
Enter fullscreen mode Exit fullscreen mode

ReplaceAttr runs once per attribute, before the handler writes it. You return the attribute you want emitted — or slog.Attr{} to drop it entirely. The groups slice tells you the nesting path so you can target attributes inside a specific group.

Three patterns earn their keep.

Redact PII

var sensitive = map[string]struct{}{
    "email": {},
    "phone": {},
    "ssn":   {},
    "token": {},
    "password": {},
}

func redact(_ []string, a slog.Attr) slog.Attr {
    if _, ok := sensitive[a.Key]; ok {
        return slog.String(a.Key, "[REDACTED]")
    }
    return a
}

h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    ReplaceAttr: redact,
})
slog.SetDefault(slog.New(h))
Enter fullscreen mode Exit fullscreen mode
slog.Info("login",
    slog.Group("user",
        "id", 42,
        "email", "alice@example.com",
    ),
)
// {"msg":"login","user":{"id":42,"email":"[REDACTED]"}}
Enter fullscreen mode Exit fullscreen mode

The redaction runs at the handler boundary, which means it covers every code path that writes a log — including third-party libraries that call slog.Default() behind your back. You don't have to remember to call a wrapper.

If you need group-scoped redaction (redact email inside user, not inside merchant), check groups:

func redactUserOnly(groups []string, a slog.Attr) slog.Attr {
    if len(groups) > 0 && groups[0] == "user" && a.Key == "email" {
        return slog.String(a.Key, "[REDACTED]")
    }
    return a
}
Enter fullscreen mode Exit fullscreen mode

Shorten source paths

AddSource: true emits the file and line, which is great in dev and ugly in prod:

{"source":{"file":"/Users/gabriel/work/svc/internal/users/handler.go",
           "line":142}}
Enter fullscreen mode Exit fullscreen mode

Trim it to the repo-relative form with ReplaceAttr:

func shortenSource(groups []string, a slog.Attr) slog.Attr {
    if a.Key != slog.SourceKey {
        return a
    }
    src, ok := a.Value.Any().(*slog.Source)
    if !ok {
        return a
    }
    if i := strings.LastIndex(src.File, "/svc/"); i >= 0 {
        src.File = src.File[i+1:]
    }
    return slog.Any(slog.SourceKey, src)
}
Enter fullscreen mode Exit fullscreen mode

Now the file reads svc/internal/users/handler.go — short enough to be useful, long enough to be unique.

Format time the way your stack expects

Datadog wants RFC3339Nano. Some collectors want epoch milliseconds. The default is RFC3339 with seconds:

func epochMillis(_ []string, a slog.Attr) slog.Attr {
    if a.Key != slog.TimeKey {
        return a
    }
    t, ok := a.Value.Any().(time.Time)
    if !ok {
        return a
    }
    return slog.Int64(slog.TimeKey, t.UnixMilli())
}
Enter fullscreen mode Exit fullscreen mode

You compose them with a small helper. The function is just a function. Chaining is a for loop.

func chain(fns ...func([]string, slog.Attr) slog.Attr) func([]string, slog.Attr) slog.Attr {
    return func(groups []string, a slog.Attr) slog.Attr {
        for _, fn := range fns {
            a = fn(groups, a)
            if a.Key == "" {
                return a
            }
        }
        return a
    }
}

h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,
    ReplaceAttr: chain(redact, shortenSource, epochMillis),
})
Enter fullscreen mode Exit fullscreen mode

The early return on empty key respects an attribute that an earlier function dropped — slog.Attr{} has zero key, and the JSON handler skips it.

Custom Handler: routing, fan-out, and trace IDs

slog.Handler is a four-method interface:

type Handler interface {
    Enabled(context.Context, Level) bool
    Handle(context.Context, Record) error
    WithAttrs(attrs []Attr) Handler
    WithGroup(name string) Handler
}
Enter fullscreen mode Exit fullscreen mode

Anything that satisfies it plugs into slog.New. That's the door to three patterns the stdlib doesn't ship.

Route by level (info → stdout, error → Sentry)

type LevelRouter struct {
    Info  slog.Handler
    Error slog.Handler
}

func (r *LevelRouter) Enabled(ctx context.Context, l slog.Level) bool {
    return r.Info.Enabled(ctx, l) || r.Error.Enabled(ctx, l)
}

func (r *LevelRouter) Handle(ctx context.Context, rec slog.Record) error {
    if rec.Level >= slog.LevelError {
        return r.Error.Handle(ctx, rec)
    }
    return r.Info.Handle(ctx, rec)
}

func (r *LevelRouter) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &LevelRouter{
        Info:  r.Info.WithAttrs(attrs),
        Error: r.Error.WithAttrs(attrs),
    }
}

func (r *LevelRouter) WithGroup(name string) slog.Handler {
    return &LevelRouter{
        Info:  r.Info.WithGroup(name),
        Error: r.Error.WithGroup(name),
    }
}
Enter fullscreen mode Exit fullscreen mode

The two methods you must not skip are WithAttrs and WithGroup. They return new handlers with the attribute or group baked in. If you forget to propagate them to the underlying handlers, every logger.With(...) call upstream silently drops its attributes when the record reaches the wrong branch. It is the bug that bites custom-handler code most often.

Wire it up:

infoH  := slog.NewJSONHandler(os.Stdout, nil)
errorH := sentryslog.New(sentryClient) // any sentry-shaped slog.Handler

slog.SetDefault(slog.New(&LevelRouter{Info: infoH, Error: errorH}))

slog.Info("user logged in", "id", 42)         // → stdout
slog.Error("payment failed", "order", "o-7")  // → Sentry
Enter fullscreen mode Exit fullscreen mode

Errors land in Sentry without the application code knowing the routing rule. The same logger value works for both paths.

Fan-out: write everywhere

Same shape, different Handle:

type FanOut struct{ Hs []slog.Handler }

func (f *FanOut) Enabled(ctx context.Context, l slog.Level) bool {
    for _, h := range f.Hs {
        if h.Enabled(ctx, l) {
            return true
        }
    }
    return false
}

func (f *FanOut) Handle(ctx context.Context, rec slog.Record) error {
    var firstErr error
    for _, h := range f.Hs {
        if err := h.Handle(ctx, rec.Clone()); err != nil && firstErr == nil {
            firstErr = err
        }
    }
    return firstErr
}

func (f *FanOut) WithAttrs(attrs []slog.Attr) slog.Handler {
    out := make([]slog.Handler, len(f.Hs))
    for i, h := range f.Hs {
        out[i] = h.WithAttrs(attrs)
    }
    return &FanOut{Hs: out}
}

func (f *FanOut) WithGroup(name string) slog.Handler {
    out := make([]slog.Handler, len(f.Hs))
    for i, h := range f.Hs {
        out[i] = h.WithGroup(name)
    }
    return &FanOut{Hs: out}
}
Enter fullscreen mode Exit fullscreen mode

rec.Clone() is required. The Record carries a backing array; if two handlers add attributes to the same record they stomp on each other. Clone is cheap — a shallow copy and a fresh attribute slice.

Wrap a handler to inject OTel trace IDs

This is the pattern that retires the "log line and trace span don't connect" problem.

import "go.opentelemetry.io/otel/trace"

type TraceWrap struct{ Inner slog.Handler }

func (t *TraceWrap) Enabled(ctx context.Context, l slog.Level) bool {
    return t.Inner.Enabled(ctx, l)
}

func (t *TraceWrap) Handle(ctx context.Context, rec slog.Record) error {
    sc := trace.SpanContextFromContext(ctx)
    if sc.IsValid() {
        rec.AddAttrs(
            slog.String("trace_id", sc.TraceID().String()),
            slog.String("span_id",  sc.SpanID().String()),
        )
    }
    return t.Inner.Handle(ctx, rec)
}

func (t *TraceWrap) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &TraceWrap{Inner: t.Inner.WithAttrs(attrs)}
}

func (t *TraceWrap) WithGroup(name string) slog.Handler {
    return &TraceWrap{Inner: t.Inner.WithGroup(name)}
}
Enter fullscreen mode Exit fullscreen mode

Every log line that runs inside a span now carries trace_id and span_id automatically. The application code didn't change. Click-through from log to trace works in Datadog, Grafana, Honeycomb, and any backend that knows the OTel id format.

Compose the three:

base := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    ReplaceAttr: chain(redact, shortenSource),
})
errorH := sentryslog.New(sentryClient)

h := &TraceWrap{
    Inner: &LevelRouter{
        Info:  base,
        Error: &FanOut{Hs: []slog.Handler{base, errorH}},
    },
}
slog.SetDefault(slog.New(h))
Enter fullscreen mode Exit fullscreen mode

Trace IDs at the outer layer. Routing in the middle. Redaction at the leaf. Each layer is a small, testable object.

Migration anti-patterns from logrus and zap

Two traps catch teams coming from logrus or zap.

Don't reach for slog.With(ctx). It exists in some logrus muscle-memory but slog keeps context out of With. Pass ctx to the log call instead: slog.InfoContext(ctx, "msg", ...). The handler reads from ctx (that's how TraceWrap works above). Stuffing ctx into With discards it.

Don't recreate the global on every request. Logrus encouraged log := logrus.WithFields(...) per request and a global config. In slog, your custom handler is the global; per-request you only call slog.With(...) on a derived logger to attach request-scoped attrs. Building a new Handler per request defeats WithAttrs precomputation and turns every log into an allocation.

Don't check level outside the logger. if cfg.Debug { logger.Debug(...) } is a pre-slog reflex from when most Go loggers evaluated args eagerly. slog.Debug is already gated on the handler's Enabled; the arguments are evaluated eagerly only if the level passes. The exception is when one of the arguments is expensive to compute — then guard it explicitly with if logger.Enabled(ctx, slog.LevelDebug).

Once you have a LevelRouter, a TraceWrap, and a redact function in your codebase, every new logging concern is a 30-line addition instead of a one-day refactor.


If this was useful

slog is one of the parts of the stdlib that grows on you the longer you use it. The Complete Guide to Go Programming has a long chapter on it — Group, ReplaceAttr, the Handler interface, request-scoped loggers, and the testing patterns that come with slog.NewTextHandler for golden-output tests. The book covers the rest of the language at the same level.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)