Serilog Enrichers — Adding Context to Every Log Entry
Enrich Serilog log entries with contextual properties: machine name, environment, request ID, user ID, tenant ID, custom enrichers, LogContext.PushProperty, and Destructurama for complex objects.
What Enrichers Are
Enrichers attach properties to every log entry automatically.
Without enrichers: each log message must manually include context.
With enrichers: context is added once, appears on all messages.
Standard enrichers:
WithMachineName() → { MachineName: "prod-web-01" }
WithEnvironmentName() → { EnvironmentName: "Production" }
WithThreadId() → { ThreadId: 12 }
WithProcessId() → { ProcessId: 4321 }
FromLogContext() → properties pushed via LogContext.PushProperty()
Custom enrichers:
Tenant ID, User ID, Correlation ID, Ward ID — anything contextualSerilog Setup with Enrichers
// Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Warning)
.Enrich.FromLogContext() // enables LogContext.PushProperty()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Application", "SystemForge-Clinical")
.Enrich.WithProperty("Version", Assembly.GetExecutingAssembly().GetName().Version?.ToString())
.WriteTo.Console(new JsonFormatter())
.WriteTo.Seq("http://seq-server:5341")
.CreateLogger();
builder.Host.UseSerilog();LogContext.PushProperty for Request Context
// Middleware: push request-level properties onto the log context
public sealed class LogContextEnrichmentMiddleware : IMiddleware
{
private readonly ICurrentUser _currentUser;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
using (LogContext.PushProperty("RequestId", context.TraceIdentifier))
using (LogContext.PushProperty("UserId", _currentUser.UserId?.ToString()))
using (LogContext.PushProperty("TenantId", _currentUser.TenantId?.ToString()))
using (LogContext.PushProperty("UserAgent", context.Request.Headers.UserAgent.ToString()))
{
await next(context);
}
// Properties are removed when the using block ends (after the request)
}
}
// Register middleware
app.UseMiddleware<LogContextEnrichmentMiddleware>();
// Now every log entry in a request automatically includes RequestId, UserId, TenantId
// No need to pass these values to every _logger.LogXxx() callCustom Enricher
// Custom enricher: add the current ward context from a service
public sealed class WardContextEnricher : ILogEventEnricher
{
private readonly IWardContextAccessor _wardContext;
public WardContextEnricher(IWardContextAccessor wardContext)
=> _wardContext = wardContext;
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var wardId = _wardContext.CurrentWardId;
if (wardId.HasValue)
{
logEvent.AddOrUpdateProperty(
propertyFactory.CreateProperty("WardId", wardId.Value));
}
}
}
// Register with DI-aware Serilog setup
builder.Services.AddSingleton<WardContextEnricher>();
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.With<WardContextEnricher>()
// ...
.CreateLogger();Destructurama for Complex Objects
// By default, logging an object serializes it via ToString()
// Destructurama: serialize complex objects to structured properties
// NuGet: Destructurama.Attributed + Destructurama.JsonNet
[DontDestructureAttribute] // do not log this object's internals at all
public class PatientRecord { }
public class PrescriptionCommand
{
public Guid PatientId { get; set; }
public string Medication { get; set; } = default!;
[NotLogged] // Destructurama.Attributed: skip this property
public string PrescriberSignature { get; set; } = default!;
}
// Configuration
Log.Logger = new LoggerConfiguration()
.Destructure.UsingAttributes() // enables [NotLogged] and [DontDestructure]
.Destructure.JsonNetTypes() // handles JObject, JArray
.CreateLogger();
// Usage: @ prefix = destructure the object
_logger.LogInformation("Processing {@Command}", command);
// Logs: { PatientId: "...", Medication: "..." } — PrescriberSignature excludedClinical Data Masking
// Never log PII directly — mask or exclude it
// Option 1: log IDs only (preferred)
_logger.LogInformation(
"Prescription {PrescriptionId} created for patient {PatientId}",
prescriptionId, patientId);
// Logs GUIDs only — not names, DOB, MRN, or clinical values
// Option 2: Destructurama masking for DTO types that must be logged
public class PatientSummaryDto
{
[NotLogged] public string FirstName { get; set; } = default!;
[NotLogged] public string LastName { get; set; } = default!;
[NotLogged] public DateTime DateOfBirth { get; set; }
public Guid PatientId { get; set; } // safe to log
public string WardCode { get; set; } = default!; // safe to log
}
_logger.LogInformation("Patient summary: {@Summary}", summary);
// Only PatientId and WardCode appear in logsCorrelation ID Enricher
// Distributed tracing: add a correlation ID that spans multiple services
public sealed class CorrelationIdMiddleware : IMiddleware
{
private const string CorrelationIdHeader = "X-Correlation-Id";
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Response.Headers[CorrelationIdHeader] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next(context);
}
}
}
// All services that receive and forward X-Correlation-Id create a single
// queryable trace across service boundaries in your log aggregation tool.Production issue I've seen: A team's logs had
UserIdon about 70% of entries and missing on 30%. The missing 30% were background jobs and webhook handlers that didn't manually pass user context. After adding an enricher middleware that always pushed aUserId(with a "system" fallback for non-user contexts) viaLogContext.PushProperty, every log entry hadUserId. Querying logs by user now worked across 100% of entries instead of 70%.
Key Takeaway
Use
LogContext.PushProperty()in middleware to attach request-wide context (UserId, TenantId, RequestId) — every log call in the request automatically includes these. UseEnrich.WithProperty()for static process-wide properties (MachineName, ApplicationName, Version). Use Destructurama's[NotLogged]attribute to exclude PII fields when logging objects. Log IDs (GUIDs), not PII — patient names, DOBs, and MRNs do not belong in log files.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.