Serilog: Enrichers for Better Context

    Created: 2026-02-14
    Layer 1
    Serilog

    This post is a continuation of the Serilog series (previous part: Serilog: Grafana + Loki for the Win).

    In this part, we will:

    • Turn logs into narratives using enrichers
    • Add meaningful context without blowing up Loki labels
    • Trace a real user flow in Grafana using enriched properties

    Let's get started! 🚀

    Prerequisites

    • This episode starts from branch 005-enrichment. You will find it here.
    • From this post onwards, we will always utilize infrastructure (database, APIs, Grafana, Loki) running in Docker. Make sure you have Docker and Docker Compose installed and available on your machine.
    • Make sure you have .NET CLI installed. We will install NuGet packages along the way.

    Quick recap: SerilogDemo in one minute

    • Multi-instance .NET API behind NGINX
    • Logs go to file, JSON file, and Grafana Loki
    • Grafana provides visual exploration of the logs
    • Key structured properties already in the system: UserId, OrderId, OrderNumber, BasketId, ItemId
    k6
    file_type_nginxNginx
    API Instance 1
    API Instance N...
    PostgreSQL
    Layer 1Loki
    Grafana

    💡 Tap nodes for details and documentation links

    Note: There are no enrichers in the baseline branch 005-enrichment (and they were present in 004-grafana-loki). We will add them and demonstrate their impact, which is the focus of this post.

    What are enrichers (and why you should care)

    Enrichers add context that your logs cannot infer from the message alone. A log message says what happened. Enrichers reveal where, for whom, and under which conditions.

    A structured log with no context is still a story without a plot.

    Enricher taxonomy

    CategoryExamplesAnswers
    Environment enrichersMachineName, EnvironmentName, ProcessId“Which instance produced this log?”
    Execution enrichersThreadId, ActivityId/TraceId“Which work unit or request is this part of?”
    Request enrichersRequestId, Path, Method, StatusCode“What happened to this HTTP request?”
    Domain enrichersUserId, BasketId, OrderId, PaymentProvider“Which business action was this about?”

    Built-in ASP.NET Core enrichment

    Before we talk about adding enrichers, it's important to know that ASP.NET Core already enriches logs automatically. If you've seen properties like ActionId, RequestId, ConnectionId, ActionName, or RequestPath in your log events, those aren't coming from custom enrichers—they come from the framework itself.

    When you integrate Serilog with ASP.NET Core (typically via Serilog.AspNetCore), the framework and middleware can attach HTTP context information to logs emitted within a request scope:

    • RequestId — ASP.NET Core request tracking identifier (HttpContext.TraceIdentifier)
    • ConnectionId — The HTTP connection identifier
    • ActionId / ActionName — Added by ASP.NET Core's action execution middleware when routing/executing controller actions
    • RequestPath — The HTTP request path
    • Method / StatusCode — Commonly added when request logging middleware is enabled (e.g., UseSerilogRequestLogging())
    • SourceContext — The logger name, typically the class name

    This enrichment typically comes from a mix of:

    1. ASP.NET Core logging scopes — Context propagated during request handling
    2. Framework diagnostics — Routing/action metadata included in framework logs
    3. Serilog request logging middleware — Additional HTTP request/response fields

    So when you expand a log event in Grafana, you're seeing this automatic ASP.NET Core enrichment plus whatever you add with Serilog's enrichers.

    Initial log snippet (without custom enrichers)

    I run 3 instances of the API using:

    TIP

    We will change our .NET code multiple times in this post, so I recommend keeping --build in your docker compose command to ensure you always run the latest version of the code. This ensures that any changes you make to the enrichers or configuration are reflected when you restart the services.

    Then, I mimicked a user adding two items to the basket and placing an order through the API (using SerilogDemo.http). Below is an example log event from SerilogDemo before adding custom enrichers.

    Note: This log represents a user adding an item (4K Monitor 27\") to their basket.

    Can you tell which API instance produced this log? Can you easily connect it to the user's entire journey? Not really. This is where enrichers come in.

    But, let's start with something simple 😀

    Basic enrichers: environment and execution context

    Add the following enrichers to your appsettings.json:

    Stop current services, rebuild, and restart:

    Hit the API again to generate new logs. Here we go! New logs now include... nothing new?

    Verify in logs

    Now event logs should include the new enriched properties:

    • MachineName: Identifies which API instance produced the log (useful when you have multiple instances running). In our case, it will be the container name (e.g., 22f0e9ab1e04).
    • ThreadId: Identifies OS thread that produced the log. Useful for debugging multi-threaded code or understanding concurrency.
    • EnvironmentName: Identifies the environment (e.g., Development, Staging, Production). This is helpful for filtering logs by environment in Grafana. Value is typically set by the ASPNETCORE_ENVIRONMENT environment variable (check package documentation for details).

    To avoid unnecessary noise in logs, I recommend only adding enrichers that provide meaningful context for your application. For our case it would be MachineName. ThreadId and EnvironmentName are less valuable for our scenario.

    TIP

    ThreadId enricher might be useful in multi-threaded applications, but in our case, it does not add much value. You need to know your application and choose enrichers that fit your scenario.

    Remove the WithThreadId and WithEnvironmentName enrichers from your configuration. Uninstall Serilog.Enrichers.Thread package to keep your project clean.

    Custom enrichers: business context that pays off

    Built-in enrichers are helpful, but the real win is domain context. In SerilogDemo, the best example is the X-User-Id header. It already exists in requests - we just need to attach it everywhere.

    1. Create new folder named Logging/Enrichers in root of your project.
    2. Create new class UserContextEnricher.cs with the following content:
    1. In appsettings, add the enricher:
    1. In Program.cs replace var app = builder.Build(); with:
    1. Rebuild and restart your services again.

    Now, every time a request comes in with the X-User-Id header, the UserId property will be automatically added to all log events generated during that request.

    To see this in action, modify SerilogDemo.http file to include the X-User-Id header in one of the requests. For example, when checking delivery options:

    NOTE

    Enriching logs with UserId is a little bit tricky. From one side, it is very useful to have it in every log event without manually pushing it to LogContext. On the other hand, it has performance implications, because it reads from HttpContext for every log event. In a real application, you would want to benchmark this approach and consider alternatives (e.g., pushing UserId to LogContext at the beginning of the request with middleware). For our demo, this approach is sufficient to demonstrate the concept of custom enrichers.

    How it should look in Grafana

    Here is a screenshot of a log event in Grafana after adding the UserId enricher. You can see the new UserId property in the log details, which allows you to filter and correlate logs by user.

    Enterprise perspective: claims-based enrichment

    In many enterprise systems, JWT tokens carry high-value identity context. A common pattern is a custom enricher (for example, JwtClaimsEnricher) that reads selected claims from HttpContext.User (after authentication middleware validates the token) and attaches them to log events.

    Typical claims to consider:

    • sub (user identifier)
    • tenantId (multi-tenant partition)
    • roles or scope (authorization context)
    • client_id or application identifier (machine-to-machine tracing)

    This approach makes it much easier to investigate incidents, authorization failures, and tenant-specific issues without passing these values manually in every log call.

    TIP

    Enterprise applications often utilize cloud solutions like AWS, Azure, or GCP for hosting and often use their native logging solutions (CloudWatch, Azure Monitor, Google Cloud Logging). In those cases, you most probably will not use Grafana + Loki, but the concept of enrichers and the need for context in logs remains the same. Things to consider:

    • Check whether your cloud logging stack supports custom enrichment or structured scope propagation.
    • Avoid sensitive data in logs (PII, tokens, secrets), especially in production.
    • Align enrichment with data retention and compliance requirements (GDPR, SOC 2, internal policies).

    Enrichers vs LogContext.PushProperty

    Serilog provides two main ways to add context to logs: enrichers and LogContext.PushProperty. Both have their use cases, but they serve different purposes.

    • Enrichers add properties globally to all logs, ideal for app-wide, stable, low-cardinality data (e.g., Version, MachineName). For scoped context (e.g., per-request OrderId/BasketId), use LogContext or BeginScope.
    • LogContext.PushProperty: Manually add properties to specific log events or within a specific code block. This is useful for adding high-cardinality or dynamic context that may not be relevant for every log event (e.g., a specific order ID, basket ID, a unique correlation ID for a single operation). Properties added with PushProperty only apply to log events emitted within the scope of the using block.

    If you want to use LogContext.PushProperty, remember to configure the "FromLogContext" enricher in your appsettings:

    Do it yourself 💡

    The best way of learning is to try it yourself. Use LogContext.PushProperty so that whenever a user performs a basket operation, you push BasketId into the log context. This way you can easily filter logs by BasketId in Grafana and see the full flow of that basket.

    The example below showcases user operations on a basket over time - user added items to basket, ordered them, few minutes later added another item (to the new basket), and then ordered again. Thanks to BasketId in log context, we can easily filter out the noise and see the full flow of each basket separately (just filter by BasketId in Grafana).

    Performance & storage implications (in Loki)

    Every extra property increases log size. The trick is to enrich with what you search for, not with everything you can.

    Guardrails:

    • Avoid huge objects
    • Avoid non-descriptive values (e.g., random GUIDs) as labels
    • Avoid PII in labels
    • Prefer labels for stable dimensions (service, environment, machine), and keep volatile values as fields

    Summary

    Enrichers bridge the gap between “a log message” and “a narrative you can investigate”. In real-world applications, thoughtful enrichment is the difference between logs that are noise and logs that are a powerful tool for understanding your system. Use enrichers to add meaningful context, but always be mindful of the trade-offs in terms of log size and cardinality, especially when using solutions like Grafana Loki.

    In next post, we will pair Serilog logging with OpenTelemetry traces to get the best of both worlds.

    In the mean time - happy coding! 🚀

    Recommended for you