skip to content

Should redaction be enforced by LogValuer on the domain types or by ReplaceAttr in one handler?

level: principalimportance: nice to knowfreq 20%

answer

  1. two controls, opposite failure modes
  2. deep and narrow versus broad and shallow
  3. who is accountable for unreviewed code
  4. a permit list beats a deny list
  5. resolution first, handler rule second

basics

~20 s

Both, in a fixed order. LogValuer on the domain types is deep but narrow: it covers only types you own and route through slog. ReplaceAttr in one handler is broad but shallow, reaching every record including unreviewed code.

solid answer

~50 s

Treat them as two controls with opposite failure modes. `LogValuer` is precise: the type decides what it may say, ideally as a permit list returned from `LogValue`, so a field added later is invisible until someone edits a reviewable method. It fails where the value never reaches slog as that type — a conversion to `string`, a `map[string]any` from a vendor payload, a struct from a package you do not own. `ReplaceAttr` is the opposite: one function on the handler your service constructs, applied to every record, so it covers code nobody reviewed — but it matches key names and silently does nothing when a field arrives under an unexpected key. My position: the handler rule plus a test on encoded output is the floor the security owner sets and can overrule a team on; the `LogValue` methods are the team's own, and are about signal quality as much as safety.

go deeper

for a junior

You are not expected to set the policy, but know that both places exist: a method on the type and a rewrite function on the handler. Follow whichever your codebase already uses rather than inventing a third.

for a middle

Be able to say what each mechanism covers and misses, and note that slog resolves the value before the handler rule runs, so type-level redaction always wins first.

for a senior

Argue the operational case: the handler rule is the only thing covering code you did not review, and the assertion that keeps it honest is a test on encoded output, not a test on the rule list.

for a principal

Own the split and the accountability. Say who sets the mandatory floor, who owns type-level detail, who decides what redacted means across services, and what the bottleneck and rot costs of the central rule are.

## Two controls, two failure modes The question is not which mechanism is better. It is which failure you are willing to own. **`LogValuer` on the domain type** is deep and narrow. - *Deep*: the type knows what it is. `Customer.LogValue` returning a `slog.GroupValue` of the two safe fields is a permit list, and a permit list is the only construct that stays correct when somebody adds a field. It survives every handler because slog resolves before dispatch, and it can produce useful output — an id and a region rather than a hole where a customer used to be. - *Narrow*: it fires only when the value reaches slog as that type. `string(tok)` defeats it. `fmt.Sprintf` into the message defeats it. A `map[string]any` decoded straight from a vendor drop was never a domain type at all. A pointer-receiver method on a value defeats it silently. And it covers only types your team declares — nothing from a package you merely import. **`ReplaceAttr` on the handler** is broad and shallow. - *Broad*: it is installed once, where the service builds its logger, and runs for every attribute of every record the process emits. That includes the debug line somebody added at 3am and the library that logs more than you expected. Coverage of unreviewed code is the property nothing else gives you. - *Shallow*: it matches key names and group paths. It cannot see inside a value that is an opaque struct, because the encoder produces those inner keys after the hook has run. It cannot reach the message text. It sees values after resolution, so it cannot detect a raw secret that a `LogValue` already turned into a placeholder — or, more importantly, one that arrives under a key nobody thought to list. A deny list is only as good as its last update. ## The order they compose in slog fixes the order for you: resolution first, then `ReplaceAttr`. That is the right order and worth stating, because it means the type's own judgment wins and the handler rule is the net underneath. It also means you cannot implement "redact anything that looks like a credential" at the handler by inspecting the original value — the original may already be gone. ## The organisational shape The defensible split, and the one I would argue for in a design review: **The security owner sets a mandatory floor** and can overrule a service team on it. That floor is the handler-level rule plus its enforcement, shipped as a shared logging package that every service constructs its logger from, plus a test in that package that encodes fixture records and asserts known sensitive shapes never appear in the bytes. The justification is coverage of the unreviewed path: only the handler layer protects against code that was never looked at, and "we reviewed every log call" is not a claim any organisation can keep making after the second team joins. **The service team owns the `LogValue` methods** on its own domain types. That is where signal quality lives: what a customer, an order or a session is worth saying in a log line is a product decision, and forcing it through a central deny list produces logs that are safe and useless. **One decision must be centralised beyond either**: what "redacted" means. Dropping the attribute, writing a fixed placeholder, or writing a stable keyed hash or the last four characters are three different products. The hash lets support correlate two records without holding the value; the placeholder does not; dropping the field makes the record's schema unstable for whatever queries it. Different services choosing differently is how you end up unable to join logs during an incident. That is a policy call, not an engineering preference, and it belongs with whoever owns the log schema. ## The costs to be honest about - **The central rule is a bottleneck.** Every team that wants a new key covered goes through one review of one file. If that queue is slow, teams route around it, and the control decays into theatre. - **Key lists rot.** Renaming a field silently removes protection, with no compile error and no test failure unless the test asserts on the encoded output rather than on the rule. Prefer the test that greps encoded fixtures for the *shape* of a credential. - **Neither control covers the message.** If your incident is a `fmt.Sprintf` into the log message, both layers were bypassed, and the only fixes are review discipline and a lint-style check. - **The permit list has a real cost.** It removes fields people were using. Expect the first week after enforcement to be spent adding fields back, one reviewed line at a time — and treat that as the mechanism working. ## What I would not accept "We use `LogValuer` everywhere, so we do not need a handler rule." That claim is only checkable by reviewing every type and every call site, forever, in code you do not all own. And "we have a central deny list, so types do not need `LogValue`" is the mirror failure: it protects the fields you already thought of and nothing else. The floor is the handler rule because it covers the unreviewed path; the depth is `LogValue` because it covers the fields nobody named.

  • A team says LogValuer on every type makes the handler rule unnecessary. What is your response?
    Ask how the claim is verified. It holds only if every value that can reach a log call is a type you own, with a value-receiver `LogValue`, never converted and never embedded in a message — a property no review can guarantee across teams and imported packages. The handler rule is cheap and covers the path nobody looked at. Keep both, and keep the test that checks encoded output rather than the rules.
  • How do you decide between dropping an attribute, a fixed placeholder, and a stable hash?
    By what the log is for. A fixed placeholder keeps the field's existence visible and is the safe default. A stable keyed hash lets support correlate two records for the same subject without holding the value, at the cost of a key to manage and a linkage risk. Dropping is right when the field's presence is itself sensitive. The decision must be one decision across services, or your logs stop joining.
  • What do you actually put in CI to keep this from decaying?
    A test in the shared logging package that builds records from fixtures containing credential-shaped values and asserts, on the encoded bytes from both the JSON and text handlers, that none appear. Assert on shapes such as the vendor's key prefix rather than one literal, so a renamed field or a new code path still fails. Testing the rules instead of the output is how this rots quietly.

The type-level method is a lock on each filing cabinet; the handler rule is the guard on the only exit. Neither alone is a security posture.

saying these in an interview costs you the question

  • Picks one mechanism and calls the other redundant
  • Treats a key deny list as complete coverage
  • Leaves redaction to reviewer discipline at each call site
  • Ignores who owns the failure when a new field ships
  • Lets each service choose its own meaning of redacted