skip to content

How does HandlerOptions.ReplaceAttr let one slog handler redact an attribute anywhere in a record?

level: middleimportance: should knowfreq 34%

answer

  1. a rewrite hook on the handler's options
  2. called once per attribute written
  3. the enclosing group keys arrive with it
  4. returning the zero Attr removes it
  5. func(groups []string, a slog.Attr) slog.Attr

basics

~20 s

ReplaceAttr is a function on slog.HandlerOptions that the handler calls for every non-group attribute it writes. It receives the enclosing group keys and the attribute, and returns a replacement; returning the zero slog.Attr drops the attribute entirely.

solid answer

~40 s

You set `ReplaceAttr func(groups []string, a slog.Attr) slog.Attr` on the `slog.HandlerOptions` you pass to `slog.NewJSONHandler` or `slog.NewTextHandler`. The handler calls it for each attribute it is about to write, and whatever it returns is written instead. Returning `slog.Attr{}` — the zero value — discards the attribute. The `groups` argument is the list of enclosing group keys, outermost first, so a rule can be scoped: redact `secret` only inside the `vendor` group, and leave a `secret` key elsewhere alone. It is also called for the built-in attributes with an empty `groups`, under the keys `slog.TimeKey`, `slog.LevelKey`, `slog.MessageKey` and `slog.SourceKey`, which is how people reformat timestamps or strip source locations. One caveat that trips people up: the attribute's value has already been resolved, so a `LogValuer` ran first and `ReplaceAttr` sees the substitute.

code

go · 9 lines
go
opts := &slog.HandlerOptions{
	ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
		if a.Key == "api_token" || a.Key == "national_id" {
			return slog.String(a.Key, "REDACTED")
		}
		return a
	},
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))

go deeper

for a junior

Know that a slog handler can be given options, and that one of them is a function that rewrites each attribute before it is written. Being able to point at where it is installed is enough.

for a middle

Be able to write the signature from memory, say that it is called per non-group attribute including the built-in time, level and message ones, and that returning a zero slog.Attr drops the attribute.

for a senior

Show where it stops: it matches keys slog itself built, so it cannot reach inside a struct value the encoder marshals or into the message text. Use it as a backstop, not as the boundary.

for a principal

Own it as a shared control. One rule in the logging package every service builds its logger from covers unreviewed code, at the cost of a central review queue and a key list that rots when fields are renamed.

## The knob `slog.HandlerOptions` has three fields, and one of them is a rewrite hook: ```go type HandlerOptions struct { AddSource bool Level Leveler ReplaceAttr func(groups []string, a Attr) Attr } ``` You pass the options to a constructor and the handler calls your function on the way out: ```go opts := &slog.HandlerOptions{ ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == "api_token" || a.Key == "national_id" { return slog.String(a.Key, "REDACTED") } return a }, } logger := slog.New(slog.NewJSONHandler(os.Stdout, opts)) ``` One function, installed once where the logger is constructed, applied to every record the process emits — including records written by code nobody on your team reviewed. That coverage property is the entire reason it exists alongside `LogValuer`. ## What it is called for - **Every attribute that is not itself a group.** Group attributes are not passed to it; the members of the group are, one at a time. - **The built-in attributes** — timestamp, level, message and, when `AddSource` is on, the source location — with an empty `groups` slice and the keys `slog.TimeKey`, `slog.LevelKey`, `slog.MessageKey`, `slog.SourceKey`. Comparing against those constants rather than the literal strings is the habit to have. - **Attributes added with `Logger.With`** as well as those given at the call site. ## What the groups argument is for `groups` is the list of group keys enclosing this attribute, outermost first. For an attribute written inside a group named `vendor`, `groups` is `[]string{"vendor"}`; nested one level deeper it is `[]string{"vendor", "auth"}`; for a top-level attribute it is empty. That matters because a bare key match is a blunt instrument. `id` is harmless on a request and sensitive on a person; `secret` in a vendor block is a credential and in a build block might be a boolean. Scoping the rule to a path keeps the rule honest: ```go func replace(groups []string, a slog.Attr) slog.Attr { if len(groups) > 0 && groups[0] == "vendor" && a.Key == "secret" { return slog.Attr{} // dropped from the record entirely } return a } ``` It also lets you write a rule that only applies at the top level (`len(groups) == 0`), which is how you safely rewrite the built-in attributes without accidentally matching a user attribute that happens to be called `time`. ## Replace, or drop The return value is the attribute that gets written. Three useful shapes: - **Replace the value**: `return slog.String(a.Key, "REDACTED")` keeps the key visible so a reader can see that a field existed and was withheld. - **Drop it**: `return slog.Attr{}` removes the attribute completely. A zero `Attr` is treated as empty and is not written. - **Transform it**: return the same key with a derived value — a hash, a length, or the last four characters — when support still needs to correlate records without holding the value. ## The ordering detail people get wrong The attribute handed to `ReplaceAttr` has **already been resolved**. If the value's type implements `LogValuer`, `LogValue` ran first and your function sees the substitute, not the original. Two consequences: 1. You cannot write a `ReplaceAttr` that inspects the original secret to decide whether to redact it — by then it may already be the string `REDACTED`. 2. The two mechanisms compose in a fixed order: type-level redaction wins first, handler-level rules apply to whatever survived. That is the right order, because the type knows more than the key name does. The replacement you return is itself resolved, so returning a `LogValuer` from `ReplaceAttr` also works. ## Where it stops `ReplaceAttr` sees attributes, and only attributes slog itself constructed. It cannot reach: - **The message text.** `logger.Info(fmt.Sprintf("token %s", tok))` puts the value in the message, and while the message is passed as an attribute under `slog.MessageKey`, matching it means pattern-matching free text — a losing game. - **Inside an opaque value.** If one attribute's value is a whole struct, the encoder produces its inner keys long after your function ran. Your function saw one attribute named `customer`, not a field named `api_token`. - **Anything the process writes outside slog** — a panic's stack trace, a `fmt.Println`, a third sink. So it is a backstop, not a boundary: broad in coverage, shallow in reach. Pair it with `LogValuer` on the types that carry the sensitive values, which is deep in reach and narrow in coverage.

  • Is ReplaceAttr called for a group attribute itself?
    No. The handler passes non-group attributes to it and recurses into a group's members, so you see each member with the group's key appended to `groups`. That means you cannot intercept and drop a whole group by matching its name in one call — you match its key as the first element of `groups` and drop each member, or you avoid emitting the group in the first place.
  • Can ReplaceAttr see the original value of something that implements LogValuer?
    No. The value is resolved before your function is called, so `LogValue` has already substituted its result. The ordering is deliberate: the type's own decision wins, and handler rules apply to what survives. If you need the original, the decision has to be made in `LogValue`, not at the handler.
  • How do you strip or reformat the timestamp of every record?
    Match the built-in key at the top level: check `len(groups) == 0 && a.Key == slog.TimeKey`, then return `slog.Attr{}` to drop it or a rewritten value to reformat it. The `len(groups) == 0` guard matters so you do not also match a user attribute that happens to be named `time` inside a group.

saying these in an interview costs you the question

  • Thinks ReplaceAttr can rewrite keys inside a marshalled struct
  • Expects it to receive group attributes as well as their members
  • Believes it runs before LogValuer resolution
  • Matches built-in keys by literal string instead of the constants
  • Treats a key deny list as complete coverage