The missing tracking number
A request hits your API. It validates a token, queries a database, and calls an external payment service. The payment service times out. Your logs fill up with level=ERROR msg="payment failed". You have fifty requests running at the same time. Which log belongs to which user? You need a trace ID. Go does not hand you one out of the box. You have to wire it yourself.
How context and slog actually talk
Think of a trace ID like a shipping label. Every package gets a unique sticker at the warehouse door. As the package moves through sorting facilities, scanners read the sticker and stamp it onto every internal receipt. When the package arrives damaged, you look up the sticker number and see exactly where it went wrong.
In Go, the request is the package. The context.Context is the envelope that travels with it. The log/slog package is the scanner. By default, slog does not look inside the envelope. It only prints what you hand it directly. You need a custom handler that opens the envelope, reads the trace ID, and stamps it onto every log line automatically.
The standard library gives you the pieces. context.WithValue attaches data to the envelope. slog.Handler intercepts log records before they hit the terminal. You just have to connect them.
The minimal handler
Here is the simplest way to attach a trace ID to a logger using a custom handler.
package main
import (
"context"
"log/slog"
)
// traceHandler wraps a base handler to inject context values into log records.
type traceHandler struct {
base slog.Handler
}
// Handle reads the trace ID from context and attaches it to the record.
func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
// Check if context carries a trace ID. Type assertion is safe here.
if id, ok := ctx.Value("trace_id").(string); ok {
r.AddAttrs(slog.String("trace_id", id))
}
// Pass the enriched record to the underlying handler for actual output.
return h.base.Handle(ctx, r)
}
The slog.Handler interface requires three additional methods to maintain the logging chain. You implement them to delegate to the wrapped handler.
// Enabled delegates to the base handler to respect log level filtering.
func (h traceHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.base.Enabled(ctx, level)
}
// WithAttrs preserves the handler chain when static attributes are added.
func (h traceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return traceHandler{base: h.base.WithAttrs(attrs)}
}
// WithGroup preserves the handler chain when nested JSON groups are created.
func (h traceHandler) WithGroup(name string) slog.Handler {
return traceHandler{base: h.base.WithGroup(name)}
}
You wrap the standard JSON handler, attach it to a logger, and pass a context carrying the trace ID.
func main() {
// Wrap the standard JSON handler with our trace-aware layer.
handler := traceHandler{base: slog.NewJSONHandler(os.Stdout, nil)}
logger := slog.New(handler)
// Generate a unique ID and attach it to the context.
ctx := context.WithValue(context.Background(), "trace_id", "abc-123")
logger.InfoContext(ctx, "request started")
}
Context travels with the request. The handler stamps it onto every line.
What happens under the hood
When you call logger.InfoContext(ctx, "request started"), the logger creates a slog.Record containing the timestamp, level, and message. It immediately calls your Handle method, passing both the context and the record. Your handler checks ctx.Value("trace_id"), finds the string, and calls r.AddAttrs. The record now carries the trace ID as a structured attribute. Finally, h.base.Handle serializes the record to JSON and writes it to stdout.
The compiler enforces the slog.Handler interface strictly. If you forget to implement Enabled, you get cannot use traceHandler{} as slog.Handler value in argument: traceHandler does not implement slog.Handler (missing method Enabled). The interface contract leaves no room for partial implementations.
Notice the receiver naming convention. The struct is traceHandler, so the receiver is (h traceHandler). Go idioms favor one or two letter receivers that match the type name. You will rarely see (this traceHandler) or (self traceHandler) in idiomatic code. Stick to the convention and your code will read like the standard library.
The context always arrives as the first parameter in your Handle method. This matches the broader Go convention: functions that accept a context take it as the first argument, conventionally named ctx. If you write a function that needs to log, pass ctx first, then extract or pass the logger. Do not reverse the order. The compiler will not stop you, but every Go developer will frown.
Wiring it into an HTTP server
In production, you generate the trace ID at the HTTP boundary. Middleware creates the context, builds a context-aware logger, and passes it down the chain.
// traceMiddleware creates a trace ID and binds it to a context-aware logger.
func traceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate a unique identifier for this request.
traceID := uuid.New().String()
ctx := context.WithValue(r.Context(), "trace_id", traceID)
// Wrap the base handler to automatically inject the trace ID.
handler := traceHandler{base: slog.NewJSONHandler(os.Stdout, nil)}
logger := slog.New(handler)
// Attach the logger to context for downstream handlers.
ctx = context.WithValue(ctx, "logger", logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Downstream handlers retrieve the pre-configured logger and call InfoContext. The trace ID flows automatically.
// checkoutHandler retrieves the pre-configured logger from context.
func checkoutHandler(w http.ResponseWriter, r *http.Request) {
// Extract the logger. Fall back to default if middleware was skipped.
logger, ok := r.Context().Value("logger").(*slog.Logger)
if !ok {
logger = slog.Default()
}
// The trace ID is automatically pulled from context by our handler.
logger.InfoContext(r.Context(), "checkout initiated")
w.WriteHeader(http.StatusOK)
}
You do not need to pass the trace ID manually to every function. The context carries it. The handler extracts it. The logs stay consistent.
Where things go wrong
Context values use string keys in the examples above for brevity, but the Go community strongly prefers unexported types to avoid collisions. If two packages both use "trace_id", they overwrite each other silently. The compiler will not stop you. You get a runtime collision instead. Define a private key type like type ctxKey string and use ctxKey("trace_id"). This is a small convention that prevents hard-to-debug log mixing.
Another trap is forgetting to pass the context down. If a handler spawns a goroutine without passing ctx, the new goroutine loses the trace ID. The log lines suddenly go blank. The compiler complains with context.Context not passed to function only if you run a linter like staticcheck. The runtime just gives you orphaned logs. Always pass context to goroutines. Always respect cancellation. Context is plumbing. Run it through every long-lived call site.
You also cannot use slog.With to attach context values dynamically. slog.With binds static attributes at creation time. It does not re-evaluate context on every call. That is why the custom handler intercepts Handle instead. If you try to force static binding, you will write logger.With(slog.String("trace_id", id)) and wonder why the ID never updates across request boundaries. It does not. The handler pattern exists for this exact reason.
Error handling follows the same explicit style. If your handler fails to write, return the error. Do not swallow it. The standard library expects Handle to return an error so the logger can fall back to stderr. Write if err != nil { return err } when you need to propagate failures. The boilerplate is verbose by design. It makes the unhappy path visible.
Choosing your logging strategy
Use a custom slog.Handler when you want zero external dependencies and full control over log formatting. Use a wrapper function like log.Info(ctx, msg) when your team prefers explicit context passing over interface implementation. Use OpenTelemetry when you need distributed tracing across multiple services and languages. Use plain slog without context when you are building a CLI tool or a short-lived script where request boundaries do not exist.