skip to content

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?

level: seniorimportance: should knowfreq 26%

answer

  1. which value actually gets resolved
  2. the struct is not the field
  3. the encoder takes over after resolution
  4. resolution does not walk struct fields
  5. put LogValue on the outer type

basics

~20 s

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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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