BrandGhost
Serilog Enrichers: Adding Context to Every Log Entry

Serilog Enrichers: Adding Context to Every Log Entry

Serilog Enrichers: Adding Context to Every Log Entry

Serilog enrichers solve one of the most tedious problems in logging: making sure every log entry has the context needed to diagnose an issue. Without enrichers, you're constantly asking yourself "which server did this come from?", "what was the request ID for this error?", or "which user triggered this code path?" -- and then realizing you forgot to include that information when you wrote the log statement.

Enrichers automatically attach properties to every log event in Serilog's pipeline, before they reach any sink. You configure them once and forget about them. Whether you need machine name for multi-server debugging, correlation IDs for distributed tracing, or custom application metadata, enrichers handle it transparently.

This guide covers all the important Serilog enrichers: the standard library enrichers, LogContext for dynamic per-request enrichment, custom enrichers you can write yourself, and how to use logging scopes with Serilog for structured contextual grouping.

If you haven't set up Serilog yet, start with the Serilog setup guide and the complete Serilog overview.


What Are Serilog Enrichers?

An enricher is a component in the Serilog pipeline that adds, removes, or modifies properties on a log event before it reaches the sinks. Enrichers implement ILogEventEnricher and execute on every event that passes through the pipeline.

Source → [Enrichers] → Filters → Sinks

Enrichers follow a Decorator-style approach: they augment the log event with additional properties without the source code knowing. The source calls _logger.LogInformation("Order {OrderId} placed", orderId) -- the enricher silently appends MachineName=prod-01, ThreadId=15, CorrelationId=abc-123 to the same event.


Standard Serilog Enrichers

Environment Enrichers

dotnet add package Serilog.Enrichers.Environment
.Enrich.WithMachineName()     // Adds MachineName property
.Enrich.WithEnvironmentName() // Adds EnvironmentName (Development/Production)
.Enrich.WithEnvironmentUserName() // Adds EnvironmentUserName

WithMachineName() is valuable in any multi-server deployment -- when you have 10 pods running your service and an error appears, the first question is always "which pod?" Having MachineName on every log entry answers that immediately.

Thread Enrichers

dotnet add package Serilog.Enrichers.Thread
.Enrich.WithThreadId()   // Adds ThreadId (int)
.Enrich.WithThreadName() // Adds ThreadName (if named)

Thread IDs are particularly useful in Worker Service applications with parallel processing -- you can trace all log entries for a specific work item across its thread lifecycle.

Process Enrichers

dotnet add package Serilog.Enrichers.Process
.Enrich.WithProcessId()   // Adds ProcessId
.Enrich.WithProcessName() // Adds ProcessName

Correlation ID Enricher

dotnet add package Serilog.Enrichers.CorrelationId
.Enrich.WithCorrelationId() // Reads X-Correlation-ID header and adds CorrelationId property

Add the correlation ID middleware to ensure the header is propagated:

builder.Services.AddHttpContextAccessor();
// ...
app.Use(async (context, next) =>
{
    if (!context.Request.Headers.ContainsKey("X-Correlation-ID"))
        context.Request.Headers["X-Correlation-ID"] = Guid.NewGuid().ToString();
    await next();
});

Correlation IDs are critical for distributed systems: when a frontend sends a request that fans out to five microservices, a single correlation ID links all the log entries across all services.


Configuring Enrichers via appsettings.json

Standard enrichers can be configured entirely through appsettings.json:

{
  "Serilog": {
    "Enrich": [
      "FromLogContext",
      "WithMachineName",
      "WithThreadId",
      "WithProcessId",
      "WithEnvironmentName"
    ]
  }
}

The string values in Enrich correspond to method names on LoggerEnrichmentConfiguration. Enrichers with parameters (like WithProperty) need code-based configuration.


Enriching with Static Properties

Add a static value to every log event -- useful for application name, version, or deployment environment:

.Enrich.WithProperty("Application", "OrderApi")
.Enrich.WithProperty("Version", Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown")
.Enrich.WithProperty("DeploymentEnvironment", builder.Environment.EnvironmentName)

In appsettings.json:

{
  "Serilog": {
    "Enrich": [
      {
        "Name": "WithProperty",
        "Args": {
          "name": "Application",
          "value": "OrderApi"
        }
      }
    ]
  }
}

These static properties are invaluable when multiple applications ship logs to the same Seq instance or Elasticsearch cluster -- you can immediately filter to just Application = 'OrderApi'.


LogContext: Dynamic Per-Scope Enrichment

Serilog.Context.LogContext lets you push properties onto the current logical thread context for a scoped block. All log events emitted within the scope carry those properties:

using Serilog.Context;

public async Task<IActionResult> ProcessPayment(PaymentRequest request)
{
    // Push properties for the duration of this method
    using (LogContext.PushProperty("PaymentId", request.PaymentId))
    using (LogContext.PushProperty("CustomerId", request.CustomerId))
    using (LogContext.PushProperty("Amount", request.Amount))
    {
        _logger.LogInformation("Starting payment processing");

        var result = await _paymentGateway.ChargeAsync(request);

        if (result.Succeeded)
        {
            _logger.LogInformation("Payment succeeded with transaction {TransactionId}", result.TransactionId);
        }
        else
        {
            _logger.LogWarning("Payment declined: {DeclineReason}", result.DeclineReason);
        }
    }
    // Properties are removed from context here

    return Ok();
}

Every log entry inside the using blocks carries PaymentId, CustomerId, and Amount -- without those values appearing in the LogInformation message template. Your code stays clean; the context is added transparently.

LogContext in Middleware

A common pattern is pushing correlation IDs or user IDs in ASP.NET Core middleware so every request's logs are automatically annotated:

public class LogContextMiddleware
{
    private readonly RequestDelegate _next;

    public LogContextMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
            ?? context.TraceIdentifier;

        using (LogContext.PushProperty("CorrelationId", correlationId))
        using (LogContext.PushProperty("RequestPath", context.Request.Path))
        using (LogContext.PushProperty("UserId", context.User.FindFirst("sub")?.Value ?? "anonymous"))
        {
            await _next(context);
        }
    }
}

Register it in Program.cs:

app.UseMiddleware<LogContextMiddleware>();
app.UseSerilogRequestLogging(); // After LogContext middleware

Every log entry for the duration of the request now carries CorrelationId, RequestPath, and UserId -- regardless of which class or layer emits the log.


Logging Scopes with ILogger

The built-in ILogger.BeginScope() API is Serilog's LogContext.PushProperty() equivalent -- and with FromLogContext() enrichment, scopes pushed via BeginScope() are captured and included in Serilog's output:

public class OrderProcessor
{
    private readonly ILogger<OrderProcessor> _logger;

    public OrderProcessor(ILogger<OrderProcessor> logger)
    {
        _logger = logger;
    }

    public async Task ProcessBatchAsync(IEnumerable<Order> orders)
    {
        using var batchScope = _logger.BeginScope(new Dictionary<string, object>
        {
            ["BatchId"] = Guid.NewGuid().ToString("N"),
            ["BatchSize"] = orders.Count()
        });

        _logger.LogInformation("Starting batch processing");

        foreach (var order in orders)
        {
            using var orderScope = _logger.BeginScope(new Dictionary<string, object>
            {
                ["OrderId"] = order.Id,
                ["CustomerId"] = order.CustomerId
            });

            _logger.LogInformation("Processing order");
            await ProcessSingleOrderAsync(order);
            _logger.LogInformation("Order processed");
        }

        _logger.LogInformation("Batch processing complete");
    }
}

Important: For BeginScope() properties to appear in Serilog's output, two things must be true:

  1. Enrich.FromLogContext() must be in your Serilog configuration
  2. If using the Console sink with a text template, {Properties} must be in the template (or use a JSON formatter)

For the Console sink, this template shows all scope properties:

"[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"

Writing a Custom Enricher

When the standard enrichers don't cover your needs, implement ILogEventEnricher:

using Serilog.Core;
using Serilog.Events;

public class TenantEnricher : ILogEventEnricher
{
    private readonly ITenantAccessor _tenantAccessor;

    public TenantEnricher(ITenantAccessor tenantAccessor)
    {
        _tenantAccessor = tenantAccessor;
    }

    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
    {
        var tenantId = _tenantAccessor.GetCurrentTenantId();

        if (!string.IsNullOrEmpty(tenantId))
        {
            var property = propertyFactory.CreateProperty("TenantId", tenantId);
            logEvent.AddPropertyIfAbsent(property);
        }
    }
}

Register it with Serilog using the ReadFrom.Services() approach so it receives injected dependencies:

// Register in DI
builder.Services.AddSingleton<TenantEnricher>();
builder.Services.AddHttpContextAccessor();

// Register in Serilog via ReadFrom.Services()
builder.Host.UseSerilog((ctx, services, config) =>
    config
        .ReadFrom.Configuration(ctx.Configuration)
        .ReadFrom.Services(services) // ILogEventEnricher implementations are picked up automatically
        .Enrich.FromLogContext());

ReadFrom.Services(services) scans the DI container for all ILogEventEnricher registrations and adds them automatically -- no manual Enrich.With<TenantEnricher>() required.


Enricher Performance Considerations

Enrichers run on every log event. For properties that involve expensive operations (database queries, HTTP calls), avoid calling them in an enricher's Enrich() method. Instead:

  • Cache the value (machine name, process ID, application version never change)
  • Use request-scoped LogContext.PushProperty() from middleware (one call per request, not per log entry)
  • Store derived values as static enrichers with lazy initialization
public class CachedMachineEnricher : ILogEventEnricher
{
    // Computed once at class load time
    private static readonly LogEventProperty MachineNameProperty =
        new("MachineName", new ScalarValue(Environment.MachineName));

    private static readonly LogEventProperty AppVersionProperty =
        new("AppVersion", new ScalarValue(
            Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"));

    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
    {
        logEvent.AddPropertyIfAbsent(MachineNameProperty);
        logEvent.AddPropertyIfAbsent(AppVersionProperty);
    }
}

Enrichers and Design Patterns

Custom enrichers are a textbook application of the Proxy pattern: they intercept every log event transparently, adding information without the original caller's knowledge. The LogContext push/pop mechanism mirrors Chain of Responsibility -- each scope in the stack contributes its properties before the event is processed. For per-request context (tenant IDs, user IDs, correlation IDs), middleware-based LogContext follows the Template Method pattern: the middleware defines the enrichment skeleton, and each request fills in the specific values.


Frequently Asked Questions

What are Serilog enrichers?

Serilog enrichers are components in the logging pipeline that automatically add properties to every log event before it reaches the sinks. They're configured once and apply to all events globally. Standard enrichers add machine name, thread ID, process ID, environment name, and correlation IDs. Custom enrichers can add any application-specific context.

What is Enrich.FromLogContext()?

Enrich.FromLogContext() tells Serilog to include properties pushed onto the LogContext stack and ILogger.BeginScope() scopes in log events. Without it, calls to LogContext.PushProperty() and BeginScope() have no effect on Serilog's output. It should be in every Serilog configuration.

What is the difference between LogContext and Enrich.WithProperty()?

Enrich.WithProperty() adds a static value to every log event -- it never changes. LogContext.PushProperty() adds a dynamic value that's scoped to the current async/thread context and removed when the IDisposable is disposed. Use WithProperty for app name/version, use LogContext for per-request or per-operation values.

How do I add a correlation ID to all log entries?

Use Serilog.Enrichers.CorrelationId with WithCorrelationId(), or write middleware that calls LogContext.PushProperty("CorrelationId", ...) at the start of each request. The middleware approach gives you full control over the correlation ID source and format.

How do I access injected services in a custom enricher?

Register your enricher in the DI container and use ReadFrom.Services(services) in your Serilog configuration. This scans for all registered ILogEventEnricher implementations and includes them automatically.

Can enrichers access the current HTTP request?

Yes, via IHttpContextAccessor. Inject it into your custom enricher constructor, then access _httpContextAccessor.HttpContext?.Request in the Enrich() method. Register builder.Services.AddHttpContextAccessor() first.

What is logging scope in .NET?

A logging scope is a named context block created with ILogger.BeginScope() that attaches key-value properties to all log entries within the block. When Serilog is configured with Enrich.FromLogContext(), scope properties appear as structured fields on every event within the scope -- enabling correlation, grouping, and filtering in log aggregators.

Serilog Sinks: Console, File, Seq, and More

Master Serilog sinks in .NET: console, file, Seq, async, and production sinks. Learn to configure multiple output destinations with filtering and formatting.

Serilog in .NET: Complete Guide to Structured Logging

Learn Serilog in .NET from scratch. Setup, sinks, enrichers, appsettings configuration, and best practices for ASP.NET Core with .NET 9 and .NET 10.

How to Set Up Serilog in ASP.NET Core: Step-by-Step Guide

Learn how to set up Serilog in ASP.NET Core with two-stage initialization, appsettings.json configuration, and structured request logging. .NET 9 and .NET 10.

An error has occurred. This application may no longer respond until reloaded. Reload