With slog.Any on a whole Customer struct, why does an API token field still print in full when its type has a LogValue method?
answer
- which value actually gets resolved
- the struct is not the field
- the encoder takes over after resolution
- resolution does not walk struct fields
- put LogValue on the outer type
basics
~20 sslog resolves the attribute's own value, not the fields inside it. The struct is not a LogValuer, so the handler's encoder walks its fields directly and never calls the token type's LogValue. Put LogValue on the struct.
solid answer
~50 s`Value.Resolve` is applied to the attribute's value, which here is the `Customer` struct. `Customer` does not implement `LogValuer`, so resolution is a no-op and the handler receives the struct itself. From that point the handler's encoder is in charge: the JSON handler passes it to `encoding/json`, which reflects over the exported fields and honours `json` tags, and the text handler formats it with `%+v`. Neither has any notion of `slog.LogValuer`, so the token field's `LogValue` is never called and the credential is written under whatever key the struct tag names. The fix is to implement `LogValue` on `Customer` and return a `slog.GroupValue` listing only the fields that may leave the process — a permit list, so a field added later is invisible until someone adds it here. Then lock it in with a test that encodes a record and asserts the token literal is absent.
code
go · 14 linestype Customer struct {
ID string `json:"id"`
Email string `json:"email"`
Token Secret `json:"api_token"`
}
// Without this, slog.Any("customer", c) hands the whole struct to the
// handler's encoder and Secret.LogValue is never consulted.
func (c Customer) LogValue() slog.Value {
return slog.GroupValue(
slog.String("id", c.ID),
slog.String("email", c.Email),
)
}go deeper
Take away the rule of thumb: log named attributes rather than whole domain structs, because a redaction method on one field's type does not protect a struct that contains it.
Explain precisely what Resolve applies to — the attribute's own value — and what happens next when that value is a plain struct and the encoder walks its fields.
Show the whole incident response: establish the blast radius by grepping shipped output for the credential's shape, rotate, then land a regression test that encodes through the real handler and asserts absence.
Decide whether the guarantee lives in each domain type or in a shared logging package, and who is accountable when a new field ships. A permit-list LogValue plus a library-level test is the answer that survives staff turnover.
## The scenario A nightly job loads customer records from a vendor drop. The record type is a plain struct with tags, because the same type is decoded from the vendor's JSON: ```go type Customer struct { ID string `json:"id"` Email string `json:"email"` Token Secret `json:"api_token"` } ``` `Secret` is the team's redacted string type and it has a `LogValue` method returning a placeholder. The loader logs progress with `logger.Info("customer loaded", slog.Any("customer", c))`. Days later somebody greps a day of shipped output for the vendor's key prefix and finds thousands of live tokens. ## Why the redaction did not fire An attribute is a key and a `slog.Value`. slog calls `Value.Resolve()` on **that** value. Here the value holds a `Customer`. `Customer` has no `LogValue` method, so its kind is `slog.KindAny`, `Resolve` returns it unchanged, and the whole struct is handed to the handler. Now the handler's encoder takes over, and encoders know nothing about slog's interfaces: - The JSON handler passes the struct to `encoding/json`. That package reflects over exported fields, honours `json` tags, and looks for `MarshalJSON` or `MarshalText` on each field's type. `Secret` has neither, so its underlying string is written under the key `api_token`. - The text handler formats it with `%+v`. `fmt` does consult `fmt.Stringer` on nested fields, so had `Secret` implemented `String` the text handler would have shown the placeholder. With only `LogValue`, `fmt` has nothing to call and prints the raw value. The general rule to state out loud: **resolution follows a chain of `LogValuer`s at the top of the value, and recurses into the members of a `slog.Group`, but it does not walk into the fields of an arbitrary struct.** A field's `LogValue` only fires if that field is passed as an attribute value in its own right. ## The fix that holds Move the decision to the type that is actually logged: ```go func (c Customer) LogValue() slog.Value { return slog.GroupValue( slog.String("id", c.ID), slog.String("email", c.Email), ) } ``` Three properties make this the right shape: 1. **It is a permit list.** Adding a `SSN` field to the struct next quarter changes nothing about what is logged — it is absent until someone edits this method, which is a reviewable diff in a file the security owner can watch. 2. **It is handler-independent.** Resolution happens before dispatch, so JSON, text and any custom handler all get the group. 3. **It composes.** The members of the returned group are themselves resolved, so a nested value that *is* passed as an attribute inside the group still gets its own `LogValue` applied. Check the receiver: declare it on the value receiver `func (c Customer)` unless you are certain every call site logs a `*Customer`. With a pointer receiver, logging a `Customer` value gives you a non-`LogValuer` and you are back where you started. ## Fixes that do not hold, and why - **`json:"-"` on the token field.** This works only for the JSON handler, because it is a directive to `encoding/json`. The text handler's `%+v` prints the field regardless. It also silently breaks decoding if the same struct is unmarshalled from the vendor payload. - **Unexporting the field.** `encoding/json` skips unexported fields, but `%+v` prints them, so the text handler still leaks. It also forces accessor churn through the rest of the loader. - **`ReplaceAttr` matching the key `api_token`.** A reasonable backstop, but here the attribute the handler sees is `customer`, one whole struct value — the encoder produces `api_token` deep inside a marshalled blob, well past any attribute-level hook. Key-based redaction only reaches keys slog itself created. - **"We will just not log whole structs."** Correct as a guideline, unenforceable as a control. Somebody adds a debug line during an incident and it is back. ## Proving it, and proving it stays fixed Two distinct activities: **Blast radius.** Grep a day of shipped output for the credential's shape — the vendor's key prefix, not a specific token — to find which lines, which services and which window are affected, and rotate accordingly. This is also how you discover the second and third code paths nobody remembered. **Regression.** Write a test that runs the real handler and asserts absence at the byte level: ```go var buf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&buf, nil)) logger.Info("customer loaded", slog.Any("customer", fixture)) if strings.Contains(buf.String(), "sk-live-9f2c") { t.Fatalf("token leaked: %s", buf.String()) } ``` The important detail is that it encodes through a real handler into a `bytes.Buffer` rather than asserting on the `LogValue` return. A unit test of `LogValue` passes even when nothing calls it — which is the exact failure being fixed. Run the same assertion against both the JSON and text handlers, since that mismatch is how the class of bug survives.
- Would a ReplaceAttr rule matching the key api_token have caught this?No. The only attribute slog created here has the key `customer`, and its value is one whole struct. `ReplaceAttr` is called with that attribute; the `api_token` key appears later, inside the bytes `encoding/json` produces from the struct. Key-based rules reach keys slog itself built — from `slog.String`, `slog.Group` or a `LogValue` returning a group — not keys invented by an encoder.
- Does slog resolve the members of a group returned by LogValue?Yes. Group members are appended as attributes in their own right, so each one is resolved and each one passes through `ReplaceAttr`. That is what makes the group form composable: a `Customer.LogValue` can include an attribute whose value is another `LogValuer` and the inner substitution still happens. It is only fields of an unresolved struct that are skipped.
- Why assert on encoded bytes rather than unit-testing the LogValue method?Because the bug is that `LogValue` is never called. A unit test on the method passes happily while the field leaks through a path that bypasses it. Encoding a fixture record through the real handler into a `bytes.Buffer` and asserting the literal is absent tests the property you actually care about: nothing with that shape leaves the process.
- The same struct is decoded from the vendor's JSON. Does that constrain the fix?It rules out the tempting shortcuts. A `json:"-"` tag on the token field would stop it being decoded from the vendor drop as well as encoded into logs, and unexporting the field breaks decoding outright. `LogValue` is the fix that leaves the wire format alone, because it is a slog-only hook that `encoding/json` never sees.
Redacting the envelope's contents does not help if you hand over the whole filing cabinet; only the cabinet can decide which drawers open.
saying these in an interview costs you the question
- Assumes slog walks struct fields looking for LogValuer
- Reaches for json:"-" without noticing it breaks decoding
- Thinks unexporting the field hides it from every handler
- Believes a ReplaceAttr key rule sees keys inside a marshalled struct
- Unit-tests LogValue instead of the encoded output