Expanding errors with ReplaceAttr

September 30, 2026

Let’s look at one other example where I reach for ReplaceAttr regularly: Logging additional error detail. (I talk more about this approach in my Boot.Dev course Learn Logging and Observability in Go (see below for a discount code).)

For this technique to be meaningful, I first need errors that contain additional details. And this can take many forms, but probably the most ubiquitous form is an error that contains a stack trace, such as those produced by the popular package github.com/pkg/errors:

import pkgerr "github.com/pkg/errors"

func frobnicate() error {
	if err := doSomething(); err != nil {
		return pkgerr.WithStack(err) // Wraps `err` so that it includes a stack trace
	}
}

Now to include that stack trace in my error logs, I can use a ReplaceAttr function something like this:

type stackTracer interface {
	error
	StackTrace() pkgerr.StackTrace
}

func expandErrors(_ []string, a slog.Attr) slog.Attr {
	if a.Key == "error" {
		err, ok := a.Value.Any().(error)
		if ok {
			if stackErr, ok := errors.AsType[stackTracer](err); ok {
				// The error contians a stack trace, so extract it
				stack := stackErr.StackTrace()
				// Replace the simple attribute with a group that contains
				// both the error message, and stack trace
				return slog.Group("error",
					slog.String("message", err.Error()),
					slog.Any("stacktrace", stack),
				)
			}
		}
	}
	return a
}

This is a very simple implementation, but it works, and produces a log that looks something like this:

time=2009-11-10T23:00:00.000Z level=ERROR msg=Failure! error.message="common error" error.stacktrace="\nmain.main\n\t/tmp/sandbox3996567400/src/prog.go:42\nruntime.main\n\t/usr/local/go-faketime/src/runtime/proc.go:302\nruntime.goexit\n\t/usr/local/go-faketime/src/runtime/asm_amd64.s:1264"

It does have one inherent limitation worth calling out: You must pass the error value as an interface, not a string. That is, the raw error value, or use slog.Any, never err.String():

// Will not be expanded!
logger.Error("failure!", "error", err.Error())
logger.Error("failure!", slog.String("error", err.Error()))
// Will allow expansion
logger.Error("failure!", "error", err)
logger.Error("failure!", slog.Any("error", err))

Once the error is converted to a string, there’s no stack trace or any other information that can be extracted from it.

You may also have noticed the stack trace output is a bit unruly—a long string, with embedded newlines. This is better than no stack trace, when you need to debug something, but it can be improved.

One simple way to improve, depending on your taste, can be to pass an array of stack frames to the logger, rather than the entire stack trace object. This will render as an array of stack frames.

This can be done by simply converting stack to []pkgerr.Frame:

					slog.Any("stacktrace", []pkgerr.Frame(stack)),
time=2009-11-10T23:00:00.000Z level=ERROR msg=Failure! error.message="common error" error.stacktrace="[main.main\n\t/tmp/sandbox3506719568/src/prog.go:42 runtime.main\n\t/usr/local/go-faketime/src/runtime/proc.go:302 runtime.goexit\n\t/usr/local/go-faketime/src/runtime/asm_amd64.s:1264]"

This still contains embedded newlines when rendered as text, but when rendered as JSON:

{"time":"2009-11-10T23:00:00Z","level":"ERROR","msg":"Failure!","error":{"message":"common error","stacktrace":["main.main /tmp/sandbox1825006785/src/prog.go:42","runtime.main /usr/local/go-faketime/src/runtime/proc.go:302","runtime.goexit /usr/local/go-faketime/src/runtime/asm_amd64.s:1264"]}}

If you want ultimate control over how it renders, you can do that, too:

				frames := make([]string, 0, len(stack))
				for _, frame := range stack {
					// Collapse newlines and tabs to a single space
					frames = append(frames, strings.Join(strings.Fields(fmt.Sprintf("%+v", frame)), " "))
				}
				return slog.Group("error",
					slog.String("message", err.Error()),
					slog.Any("stacktrace", frames),
				)

Sign up for Boot.Dev and save 25% off your first annual payment with code JHALL.


Share this

Direct to your inbox, daily. I respect your privacy .

Unsure? Browse the archive .

Related Content


Test logging with ReplaceAttr

Today’s topic is probably the most interesting, subtle, and complicated of the three HandlerOptions fields: ReplaceAttr. type HandlerOptions type HandlerOptions struct { … // ReplaceAttr is called to rewrite each non-group attribute before it is logged. // The attribute's value has been resolved (see [Value.Resolve]). // If ReplaceAttr returns a zero Attr, the attribute is discarded. // // The built-in attributes with keys "time", "level", "source", and "msg" // are passed to this function, except that time is omitted // if zero, and source is omitted if AddSource is false.


Handler level

Each handler has an optional Level value, which controls which logs to drop, and which to actually log: type HandlerOptions type HandlerOptions struct { … // Level reports the minimum record level that will be logged. // The handler discards records with lower levels. // If Level is nil, the handler assumes LevelInfo. // The handler calls Level.Level for each record processed; // to adjust the minimum level dynamically, use a LevelVar.


slog.HandlerOptions

Now we’ll begin looking at HandlerOptions. Despite the Handler-centricity, this is something we care heavily about as mere users of the slog library. Whenever you instantiate either slog.JSONHandler or slog.TextHandler, you have the option of providing a HandlerOptions object, to tweak the default handler behavior. We’ll look at each of the config options, one at a time. type HandlerOptions type HandlerOptions struct { // AddSource causes the handler to compute the source code position // of the log statement and add a SourceKey attribute to the output.

Get daily content like this in your inbox!

Subscribe