How does HandlerOptions.ReplaceAttr let one slog handler redact an attribute anywhere in a record?
answer
- a rewrite hook on the handler's options
- called once per attribute written
- the enclosing group keys arrive with it
- returning the zero Attr removes it
- func(groups []string, a slog.Attr) slog.Attr
basics
~20 sReplaceAttr 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 sYou 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 linesopts := &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
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.
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.
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.
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