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.