Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20264 min read
LoggingSerilogEnrichersASP.NET Core.NETObservability
Share:𝕏

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 contextual

Serilog Setup with Enrichers

C#
// 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

C#
// 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() call

Custom Enricher

C#
// 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

C#
// 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 excluded

Clinical Data Masking

C#
// 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 logs

Correlation ID Enricher

C#
// 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 UserId on 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 a UserId (with a "system" fallback for non-user contexts) via LogContext.PushProperty, every log entry had UserId. 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. Use Enrich.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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.