How do you write a slog.Handler that adds a run id from the context to every record?
answer
- the handler is the single install point
- Handle receives more than the record
- wrap the real handler, delegate down
- AddAttrs, then call the inner handler
- re-wrap in WithAttrs or lose it
basics
~20 sWrap another slog.Handler. In Handle(ctx, record), look the run id up with ctx.Value, attach it with Record.AddAttrs, then call the wrapped handler's Handle. Callers must use the Context-taking log methods for the id to arrive.
solid answer
~40 s`slog.Handler` has four methods: `Enabled(ctx, level)`, `Handle(ctx, record)`, `WithAttrs`, `WithGroup`. I write a small type holding an inner handler and implement `Handle` to read the id out of the context — `ctx.Value(runKey{})`, keyed by an unexported type — add it with `r.AddAttrs(slog.String("run_id", id))`, and delegate to the inner handler. `Enabled` forwards straight through; `WithAttrs` and `WithGroup` must return my wrapper around the inner handler's result, otherwise a derived logger silently loses the stamping. I install it once with `slog.New(myHandler)` and, if the codebase logs through the package default, `slog.SetDefault`. The value in the context is set at the top of the run, and from then on every `InfoContext`/`ErrorContext` call in that call tree correlates without threading an id parameter through every function.
code
go · 26 linestype runKey struct{}
func WithRunID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, runKey{}, id)
}
type runHandler struct{ inner slog.Handler }
func (h runHandler) Enabled(ctx context.Context, l slog.Level) bool {
return h.inner.Enabled(ctx, l)
}
func (h runHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(runKey{}).(string); ok {
r.AddAttrs(slog.String("run_id", id))
}
return h.inner.Handle(ctx, r)
}
func (h runHandler) WithAttrs(as []slog.Attr) slog.Handler {
return runHandler{h.inner.WithAttrs(as)}
}
func (h runHandler) WithGroup(name string) slog.Handler {
return runHandler{h.inner.WithGroup(name)}
}go deeper
Know that the handler, not the log call, is where a run id gets attached, and that the record's fields come from an interface method called Handle that receives a context.
Be able to write the wrapper live: the four interface methods, the comma-ok lookup, AddAttrs, delegation to the inner handler, and re-wrapping in WithAttrs and WithGroup.
Talk about the silent failure modes — a dropped wrapper, a call site using the non-context method — and the test that catches each before an incident does.
Own the convention: one handler installed at the process root, a fixed set of identity fields, and a clear rule on what may ride in the context versus what stays an explicit parameter.
## The shape of the problem A migration tool walks a table in batches. Every line it emits over the next two hours belongs to one run, and a support engineer chasing one customer's run through a day of output needs a `run_id` on all of them. Threading a `runID string` parameter into every function that might log is invasive and gets forgotten. The standard answer in `log/slog` is to put the identity in the `context.Context` once and have the *handler* stamp it. ## The Handler interface ```go type Handler interface { Enabled(context.Context, Level) bool Handle(context.Context, Record) error WithAttrs(attrs []Attr) Handler WithGroup(name string) Handler } ``` Two of those methods receive a context, and `Handle` is the one that matters here: it is called with the context the caller passed to `InfoContext`, `ErrorContext` or `Logger.Log`, and it is handed the `slog.Record` about to be written. ## The wrapper The idiom is a decorator over a real handler: ```go type runKey struct{} type runHandler struct{ inner slog.Handler } func (h runHandler) Handle(ctx context.Context, r slog.Record) error { if id, ok := ctx.Value(runKey{}).(string); ok { r.AddAttrs(slog.String("run_id", id)) } return h.inner.Handle(ctx, r) } ``` Points worth stating in an interview: - **The comma-ok assertion is not optional.** Plenty of log lines will be emitted from contexts that never carried a run id — startup, shutdown, tests. A bare `.(string)` assertion panics on those. - **`Record.AddAttrs` appends to the record you were given.** If your handler fans the same record out to more than one destination, take `r = r.Clone()` first so the copies do not share attribute storage. - **Forward, do not swallow.** `Handle` must call the inner handler; it is the thing that actually formats and writes. ## The three methods people forget `Enabled` is a straight delegation: `return h.inner.Enabled(ctx, l)`. `WithAttrs` and `WithGroup` are the trap. It is tempting to embed `slog.Handler` in the struct and let the promoted methods handle those two — but the promoted `WithAttrs` returns the *inner* handler, so any logger derived from yours drops the wrapper and quietly stops stamping run ids. Implement both to re-wrap: ```go func (h runHandler) WithAttrs(as []slog.Attr) slog.Handler { return runHandler{h.inner.WithAttrs(as)} } ``` This is the single most common defect in hand-rolled context handlers, and it fails silently: the lines still appear, the field is just missing on some of them. ## Installing it and feeding it ```go h := runHandler{inner: slog.NewJSONHandler(os.Stdout, nil)} slog.SetDefault(slog.New(h)) ``` `slog.New` builds a `*slog.Logger` over the handler; `slog.SetDefault` makes the package-level `slog.InfoContext` shorthand use it too, which matters in a codebase where not every component is handed a logger. The other half is the context. At the top of the run you derive one context that carries the id and pass it down: ```go ctx = context.WithValue(ctx, runKey{}, id) ``` From then on the obligation on call sites is exactly one thing: use the Context-taking log methods. `logger.Info("...")` gets `context.Background()` and comes out unstamped no matter how good your handler is. ## What belongs in the context, and what does not Keep the context value small and identity-shaped: a run id, a batch number, a customer id, a request id. It is data other things want too — an error message, a metric label, the id you print to the operator at the end. Do not push whole subsystems through it. ## Testing it The handler is easy to test without a running service: build one over `slog.NewJSONHandler` writing into a `bytes.Buffer`, log once with a context carrying an id and once with `context.Background()`, and assert the field is present in the first line and absent from the second. Add a case that derives a logger through `WithAttrs` and logs again — that is the case that catches the dropped-wrapper bug, and it is the one people leave out.
- What breaks if you embed slog.Handler in the wrapper struct instead of implementing WithAttrs and WithGroup?The promoted `WithAttrs`/`WithGroup` return the *inner* handler, not your wrapper. Any logger derived from the original keeps logging, but silently stops stamping the run id, so you get a mix of correlated and uncorrelated lines from the same process. Implement both methods and re-wrap the inner handler's result.
- A helper deep in the call chain logs without the run's context. What can the handler do about it?Nothing. The handler can only read the context it is handed, so a call using `Info` or one that was passed a fresh `context.Background()` produces an unstamped line. The fix is upstream: thread the run's context into that helper as its first parameter and log with the Context-taking methods.
- Does the handler get the context when the level is being tested, not just when a record is written?Yes. `Handler.Enabled(ctx, level)` also receives it, so a handler can make an admission decision per context — for example letting one flagged run through at debug while everything else stays at info. Keep that logic cheap: `Enabled` runs for every candidate line.
saying these in an interview costs you the question
- Uses a bare type assertion on the context value and panics when absent
- Embeds the handler and never re-wraps in WithAttrs
- Formats the id into the message string instead of an attribute
- Expects the id to appear on plain Info calls too
- Puts a whole request object in the context for the handler to read