Implement nested log redaction (Telemetry-002)

RedactionEnricher now projects each property into a mutable view the ILogRedactor
can edit: scalars stay as their CLR value, while StructureValue/SequenceValue/
DictionaryValue become nested IDictionary<string,object?>/IList<object?> the
redactor descends into recursively. A field nested inside a destructured {@Object}
can now be masked or removed — closing the gap documented as a limitation.

- Project/Rebuild round-trip preserves StructureValue.TypeTag and original
  dictionary keys; redactor-synthesised plain dicts/lists are rebuilt too.
- Untouched properties are not reallocated: structural ValueEquals skips write-back
  unless a property actually changed. Scalar fast path and no-redactor/no-property
  short-circuits retained.
- +5 nested-reach tests (mask/remove a field, sequence element, dictionary value,
  two-levels-deep); the old 'cannot reach' limitation test replaced. Serilog 34, 0 warnings.
- ILogRedactor XML doc + library README updated to document the recursive reach.
This commit is contained in:
Joseph Doherty
2026-06-01 12:12:26 -04:00
parent ae0ccc9a3a
commit 05cc62aab3
4 changed files with 464 additions and 71 deletions
+13 -7
View File
@@ -55,13 +55,19 @@ Trace↔log correlation is automatic: `TraceContextEnricher` reads `Activity.Cur
log event and attaches `trace_id` and `span_id`, so log events produced inside a traced request
carry the same span identity as the trace backend.
**Redaction reach.** A registered `ILogRedactor` may **remove** or **replace** any top-level
property, and `RedactionEnricher` honours both (a removed key is dropped from the event). The seam
sees the unwrapped value of scalar properties only — a destructured `{@Object}` property is exposed
as its raw Serilog `StructureValue` wrapper, so a redactor can replace/remove the whole structured
property but **cannot** mask a field nested inside it. To protect a sensitive field of a logged
object, log it as its own scalar property (do not destructure it) or remove the whole property by
key. See the `ILogRedactor` XML doc for the full contract.
**Redaction reach.** A registered `ILogRedactor` may **remove** or **replace** any value, and
`RedactionEnricher` honours both (a removed key is dropped from the event). Scalar properties appear
as their unwrapped CLR value; **destructured** properties are projected into mutable views the
redactor can descend into — a `{@Object}` is an `IDictionary<string, object?>` of its fields, a
logged collection an `IList<object?>`, a logged dictionary an `IDictionary<string, object?>` — all
recursively, so a field **nested inside** a destructured object can be masked or removed:
```csharp
if (properties["command"] is IDictionary<string, object?> command) command["apiKey"] = "***";
```
Structure type tags and dictionary keys are preserved on rebuild, and untouched properties are left
intact (not reallocated). See the `ILogRedactor` XML doc for the full contract.
---