go / caller

I use slog.Handler wrappers to make logging helpers transparent in stack traces.

The problem

When you wrap slog calls, the source location points to the wrapper, not the actual caller:

func LogError(err error) {
	slog.Error("operation failed", "error", err)
	// source shows: logger.go:15  ← the wrapper
	// you want:     handler.go:42 ← the actual caller
}

The pattern

Wrap the handler and adjust the program counter:

package logger

import (
	"context"
	"log/slog"
	"runtime"
)

// CallerHandler wraps a handler to adjust source locations,
// skipping wrapper functions.
type CallerHandler struct {
	slog.Handler
	skip int
}

// NewCallerHandler wraps h, skipping n additional frames
// when recording source locations.
func NewCallerHandler(h slog.Handler, skip int) *CallerHandler {
	return &CallerHandler{Handler: h, skip: skip}
}

func (h *CallerHandler) Handle(ctx context.Context, r slog.Record) error {
	// Adjust the PC to skip wrapper frames
	var pcs [1]uintptr
	runtime.Callers(h.skip+3, pcs[:]) // +3 for Callers, Handle, slog internals
	r.PC = pcs[0]
	return h.Handler.Handle(ctx, r)
}

func (h *CallerHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return &CallerHandler{Handler: h.Handler.WithAttrs(attrs), skip: h.skip}
}

func (h *CallerHandler) WithGroup(name string) slog.Handler {
	return &CallerHandler{Handler: h.Handler.WithGroup(name), skip: h.skip}
}

Usage

Create a logger with adjusted caller depth:

func init() {
	h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		AddSource: true,
	})
	// Skip 1 additional frame for our wrapper functions
	slog.SetDefault(slog.New(NewCallerHandler(h, 1)))
}

// Now wrapper functions are transparent
func LogError(msg string, err error) {
	slog.Error(msg, "error", err)
}

func main() {
	LogError("query failed", sql.ErrNoRows)
	// source shows main.go:XX, not the LogError wrapper
}

Alternative: pass skip explicitly

For simple cases, use slog.LogAttrs with explicit depth:

func LogError(msg string, err error) {
	// Skip 1 frame (this function)
	slog.Default().Handler().Handle(
		context.Background(),
		slog.NewRecord(time.Now(), slog.LevelError, msg, callerPC(1)),
	)
}

func callerPC(skip int) uintptr {
	var pcs [1]uintptr
	runtime.Callers(skip+2, pcs[:])
	return pcs[0]
}

When to use

← All articles