Serilog: Enrichers for Better Context
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
💡 Tap nodes for details and documentation links💡 Hover over nodes for details and documentation links
Note: There are no enrichers in the baseline branch
005-enrichment(and they were present in004-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
| Category | Examples | Answers |
|---|---|---|
| Environment enrichers | MachineName, EnvironmentName, ProcessId | “Which instance produced this log?” |
| Execution enrichers | ThreadId, ActivityId/TraceId | “Which work unit or request is this part of?” |
| Request enrichers | RequestId, Path, Method, StatusCode | “What happened to this HTTP request?” |
| Domain enrichers | UserId, 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:
- ASP.NET Core logging scopes — Context propagated during request handling
- Framework diagnostics — Routing/action metadata included in framework logs
- 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_ENVIRONMENTenvironment 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.
- Create new folder named
Logging/Enrichersin root of your project. - Create new class
UserContextEnricher.cswith the following content:
- In appsettings, add the enricher:
- In
Program.csreplacevar app = builder.Build();with:
- 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)rolesorscope(authorization context)client_idor 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
LogContextorBeginScope. - 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
PushPropertyonly apply to log events emitted within the scope of theusingblock.
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

Serilog: Structured Logging Explained

