Status: Accepted
Date: 2026-05-24
Decision Owner: Architecture
Evolith disposition: Proposed for Evolith adoption — pattern is runtime-neutral; applicable to any .NET satellite using Serilog
Related:
UMS processes personally identifiable information (PII): email addresses, identity references, passwords, tokens, and national IDs. ADR-0053 mandates structured logging via Serilog, but unguarded structured logging creates a PII leakage risk:
// Risk: email leaks into every log sink
_logger.LogInformation("User {Email} activated by {ActorId}", user.Email, actorId);
Three layers of risk exist:
{@object} expansions log all properties of a class, including PII fieldsThe annotation approach ([Sensitive] on domain properties) couples the Domain layer to a logging library, violating domain purity. Property-name masking at the Serilog pipeline level requires no domain changes.
Apply PII masking at the Serilog pipeline level through two complementary mechanisms: a destructuring policy and a log event enricher.
PiiMaskingPolicy — Serilog IDestructuringPolicyRegistered via .Destructure.With<PiiMaskingPolicy>(). Intercepts object destructuring before any sink processes it.
The policy intercepts at the property-name level inside the enricher (see below) — the TryDestructure method returns false so Serilog continues normal destruction; the enricher then scans and redacts.
PiiSanitizerEnricher — Serilog ILogEventEnricherRegistered via .Enrich.With<PiiSanitizerEnricher>(). Runs after message template rendering, scanning all ScalarValue string properties:
// Masked property names (case-insensitive):
"email", "emailaddress", "mail",
"password", "passwordhash", "passwordtext",
"identityreference",
"token", "accesstoken", "refreshtoken", "bearertoken", "idtoken",
"secret", "apikey", "apisecret", "clientsecret",
"ssn", "nationalid", "taxid"
Email regex sweep — any free-text scalar that matches [^@\s]+@[^@\s]+\.[^@\s]+ is partially masked: jo***@***.com. This catches leakage through non-PII-named properties.
LoggingExtensions.ConfigureUmsSerilogSingle extension method wiring up the complete Serilog configuration:
builder.Host.UseSerilog((ctx, cfg) => cfg.ConfigureUmsSerilog(ctx));
Output strategy:
| Environment | Format | Rationale |
|---|---|---|
| Development | Coloured text console | Human-readable; trace/correlation prefix visible |
| Staging/Production | Compact JSON (CompactJsonFormatter) |
Machine-readable; consumed by Fluentd / container log drivers |
Configuration (appsettings.json):
"Observability": {
"Logging": {
"ConsoleFormat": "CompactJson", // "Text" or "CompactJson"
"MinimumLevel": "Information", // any Serilog level
"OutputTemplate": "..." // Text-mode only
}
}
Enrichers always applied:
Enrich.FromLogContext() — picks up ILogger scopes (CorrelationId, SessionTrackingId from middleware)Enrich.WithMachineName() — pod/host identity for Kubernetes deploymentsEnrich.WithThreadId() — correlates concurrent request logsEnrich.With<PiiSanitizerEnricher>() — PII maskingLevel overrides:
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Warning)
Forbidden:
// String concatenation — no structured fields
_logger.LogInformation("User " + userId);
// Unstructured object dump
_logger.LogInformation(user.ToString());
// Direct PII value in template
_logger.LogInformation("Email: {email}", user.Email);
Required:
// Structured fields with non-PII names
_logger.LogInformation("User {UserId} activated by {ActorId}", userId, actorId);
// When PII field name is unavoidable, enricher will mask it
_logger.LogInformation("Verified {Email}", maskedForDisplay);
ConfigureUmsSerilog provides a single, auditable configuration point; all sinks receive masked eventsuserEmailAddress (not in the list) will bypass masking. Code review must cover log field namingif level < Warning) for high-throughput paths if profiling reveals it as a hotspotTo ship logs to a remote sink (Seq, Elasticsearch, Application Insights, Loki):
Ums.Presentationappsettings.json under the "Serilog" sectionThe following are UMS-namespaced but trivially portable:
PiiMaskingPolicy — no product import, depends only on Serilog.CorePiiSanitizerEnricher — no product import, depends only on Serilog.CoreLoggingExtensions.ConfigureUmsSerilog — depends on environment + configuration, portable with minor rename| ADR Registry | CP-06 PII Logging | ADR-0053 OTel |