skip to content

How do you write a slog.Handler that adds a run id from the context to every record?

level: middleimportance: must knowfreq 60%

answer

  1. the handler is the single install point
  2. Handle receives more than the record
  3. wrap the real handler, delegate down
  4. AddAttrs, then call the inner handler
  5. re-wrap in WithAttrs or lose it

basics

~20 s

Wrap 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 lines
go
type 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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