Context Enrichment
The Problem
Section titled “The Problem”A bare log entry like "Order placed" tells you what happened but not who triggered it, which HTTP request it belongs to, or what machine produced it. When you are debugging a production incident across dozens of service replicas, you need every log entry to carry ambient context — correlation IDs, user identifiers, request paths, machine names, and thread IDs — without requiring the developer to pass these values explicitly at every call site.
Context enrichment solves this by automatically attaching ambient information to every log entry. Providers are registered once at startup, and every subsequent log call picks up the current context without any changes to business code.
1. IContextProvider
Section titled “1. IContextProvider”The IContextProvider interface is the extension point for injecting ambient data into the logging pipeline. Each provider has a name, a priority that controls evaluation order, and two methods: one to supply properties and one to indicate whether the provider is available in the current environment.
public interface IContextProvider{ /// Unique name for identification and configuration filtering. string Name { get; }
/// Priority order -- lower values are evaluated first. int Priority { get; }
/// Returns the context properties this provider can supply. IReadOnlyDictionary<string, object?> GetContextProperties();
/// Returns false when the provider cannot supply context /// (e.g., HttpContextProvider outside an HTTP request). bool IsAvailable();}Priority Ordering
Section titled “Priority Ordering”Providers are sorted by Priority in ascending order. When two providers supply a property with the same key, the provider with the lower priority value wins. This lets you override defaults with higher-priority providers.
| Priority Range | Convention |
|---|---|
| 1-99 | Application-specific overrides |
| 100-499 | Request-scoped providers (HTTP, correlation) |
| 500-899 | Thread and runtime providers |
| 900-999 | Process-level providers |
| 1000+ | Machine-level (static) providers |
ContextProviderBase
Section titled “ContextProviderBase”The ContextProviderBase abstract class provides a constructor that accepts name and priority, and a helper method CreatePropertiesDictionary for building read-only dictionaries from tuples. Most providers extend this base class rather than implementing the interface directly.
public abstract class ContextProviderBase : IContextProvider{ protected ContextProviderBase(string name, int priority = 100) { ... }
public string Name { get; } public int Priority { get; }
public abstract IReadOnlyDictionary<string, object?> GetContextProperties(); public virtual bool IsAvailable() => true;
protected static IReadOnlyDictionary<string, object?> CreatePropertiesDictionary( params (string name, object? value)[] properties);}2. IContextManager
Section titled “2. IContextManager”The IContextManager interface is the central registry that holds all providers, collects their properties, and exposes cache invalidation and change notifications. It supports both synchronous and asynchronous property collection, targeted queries against specific providers, and bulk registration.
Key Methods
Section titled “Key Methods”| Method | Description |
|---|---|
RegisterProvider(provider) | Add or replace a provider (auto-sorts by priority) |
RegisterProviders(providers) | Bulk add (sorts once after all registrations) |
UnregisterProvider(name) | Remove a provider by name |
GetProviders() | Get all registered providers sorted by priority |
GetContextProperties() | Synchronous: collect from all available providers |
GetAggregateContextAsync(ct) | Async: collect from all providers including async-only ones |
GetProviderContextAsync(name, ct) | Get properties from a specific provider |
GetContextPropertyAsync(provider, property, ct) | Get a single property from a specific provider |
InvalidateCache() | Force fresh retrieval on next access |
HasProvider(name) | Check if a provider is registered |
ProviderCount | Number of registered providers |
ProvidersChanged Event
Section titled “ProvidersChanged Event”The ProvidersChanged event fires whenever providers are added, removed, or cleared. Components that cache context data can subscribe to this event and refresh their caches.
contextManager.ProvidersChanged += (sender, args) =>{ switch (args.ChangeType) { case ContextProviderChangeType.Added: logger.LogInformation("Context provider added: {Name}", args.ProviderName); break; case ContextProviderChangeType.Removed: logger.LogInformation("Context provider removed: {Name}", args.ProviderName); break; }};Dependency Injection
Section titled “Dependency Injection”The ContextManager concrete class implements IContextManager and is registered as a singleton. You can inject it and register custom providers at startup or from hosted services.
services.AddSingleton<IContextManager>(sp =>{ var contextManager = new ContextManager(); contextManager.RegisterProvider(new TenantContextProvider( sp.GetRequiredService<ITenantContext>())); return contextManager;});ContextManagerOptions
Section titled “ContextManagerOptions”| Option | Type | Default | Description |
|---|---|---|---|
EnableDefaultProviders | bool | true | Register Machine, Process, Thread providers |
CacheTimeout | TimeSpan | 1 min | Duration before cached context expires |
EnableMetrics | bool | true | Collect provider performance metrics |
MaxAsyncCacheSize | int | 1000 | Max async context cache entries |
3. Built-in Providers
Section titled “3. Built-in Providers”Pragmatic.Logging ships with five built-in context providers that cover the most common ambient data sources.
Provider Summary
Section titled “Provider Summary”| Provider | Name | Priority | Scope | Key Properties |
|---|---|---|---|---|
HttpContextProvider | HttpContext | 100 | Per-request | RequestPath, RequestMethod, UserId, RemoteIpAddress |
CorrelationIdProvider | CorrelationId | 50 | Per-request | CorrelationId, TraceId, SpanId |
ThreadContextProvider | Thread | 500 | Per-call | ThreadId, ThreadName, IsBackground, Culture |
ProcessContextProvider | Process | 900 | Per-process | ProcessId, ProcessName, AppVersion, StartTime |
MachineContextProvider | Machine | 1000 | Per-machine | MachineName, OSVersion, CLRVersion, RuntimeIdentifier |
HttpContextProvider
Section titled “HttpContextProvider”Extracts request, response, connection, and user information from IHttpContextAccessor. Only available during HTTP request processing.
Properties provided:
| Property | Example Value | Description |
|---|---|---|
RequestId | 0HN3V... | ASP.NET Core trace identifier |
RequestPath | /api/orders | Request URL path |
RequestMethod | GET | HTTP method |
RequestScheme | https | URL scheme |
RequestHost | api.example.com | Host header |
RemoteIpAddress | 192.168.1.100 | Client IP |
IsAuthenticated | true | Whether user is authenticated |
UserId | user-42 | From NameIdentifier claim |
UserEmail | user@example.com | From Email claim |
UserRole | Admin | From Role claim |
Header_User-Agent | Mozilla/5.0... | Selected request headers |
Header_X-Forwarded-For | 10.0.0.1 | Proxy chain |
The provider also extracts selected headers (User-Agent, X-Forwarded-For, X-Real-IP, X-Correlation-ID, X-Request-ID, Accept-Language, Referer) and adds them with a Header_ prefix.
CorrelationIdProvider
Section titled “CorrelationIdProvider”Manages correlation IDs for distributed request tracing. Supports three sources in priority order: existing HttpContext.Items, the X-Correlation-ID request header, and the W3C traceparent header via Activity.Current. If none are found, generates a new GUID.
Properties provided:
| Property | Source | Description |
|---|---|---|
CorrelationId | Header / Activity / Generated | Request correlation identifier |
TraceId | Activity.Current.TraceId | W3C trace ID (when Activity exists) |
SpanId | Activity.Current.SpanId | W3C span ID (when Activity exists) |
The correlation ID is automatically propagated to the response via the X-Correlation-ID header, enabling clients to include it in bug reports.
MachineContextProvider
Section titled “MachineContextProvider”Provides static machine-level information that is computed once (via Lazy<T>) and cached for the lifetime of the process. Useful for identifying which replica produced a log entry in multi-instance deployments.
Properties provided: MachineName, UserName, OSVersion, ProcessorCount, Is64BitOperatingSystem, Is64BitProcess, CLRVersion, RuntimeIdentifier, OSDescription, FrameworkDescription.
ProcessContextProvider
Section titled “ProcessContextProvider”Provides static process-level information, also computed once via Lazy<T>. Identifies the running application and its version.
Properties provided: ProcessId, ProcessName, ApplicationName, ApplicationVersion, StartTime, WorkingDirectory, CommandLine.
ThreadContextProvider
Section titled “ThreadContextProvider”Provides per-call thread information. Unlike Machine and Process providers, this one is evaluated on every call because the logging thread can change between requests.
Properties provided: ThreadId, ThreadName, IsBackground, IsThreadPoolThread, CurrentCulture, CurrentUICulture.
4. Creating a Custom Provider
Section titled “4. Creating a Custom Provider”When your application needs domain-specific context — tenant ID, feature flags, or business-specific metadata — you create a custom provider by extending ContextProviderBase.
Example: TenantContextProvider
Section titled “Example: TenantContextProvider”In a multi-tenant application, every log entry should carry the current tenant identifier so operators can filter logs by tenant during troubleshooting.
public sealed class TenantContextProvider : ContextProviderBase{ private readonly ITenantContext _tenantContext;
public TenantContextProvider(ITenantContext tenantContext) : base("Tenant", priority: 80) // High priority: tenant is critical context { _tenantContext = tenantContext; }
public override bool IsAvailable() { // Only available when a tenant has been resolved for the current request return _tenantContext.CurrentTenantId is not null; }
public override IReadOnlyDictionary<string, object?> GetContextProperties() { return CreatePropertiesDictionary( ("TenantId", _tenantContext.CurrentTenantId), ("TenantName", _tenantContext.CurrentTenantName), ("TenantPlan", _tenantContext.CurrentPlan?.ToString()) ); }}Registration
Section titled “Registration”Register the custom provider during service configuration:
services.AddPragmaticLogging(logging =>{ logging.ConfigureContext(ctx => { ctx.EnableEnrichment = true; ctx.IncludeCorrelationId = true; ctx.IncludeMachineContext = true; ctx.CustomProviders.Add(typeof(TenantContextProvider)); });
logging.AddConsole(); logging.AddFile("logs/app-{Date}.log");});Or register it directly on the IContextManager:
services.AddSingleton<IContextManager>(sp =>{ var manager = new ContextManager(); manager.RegisterProvider(new TenantContextProvider( sp.GetRequiredService<ITenantContext>())); manager.RegisterProvider(new FeatureFlagContextProvider( sp.GetRequiredService<IFeatureFlagService>())); return manager;});5. ASP.NET Core Middleware
Section titled “5. ASP.NET Core Middleware”Two middleware components wire context enrichment into the HTTP request pipeline.
LoggingEnrichmentMiddleware
Section titled “LoggingEnrichmentMiddleware”This middleware runs early in the pipeline and creates a logging scope that includes correlation ID, request path, request method, and user identity. It optionally logs request start and completion events with timing information and maps HTTP status codes to log levels (5xx = Error, 4xx = Warning, 2xx/3xx = Information).
app.UseMiddleware<LoggingEnrichmentMiddleware>(new LoggingEnrichmentOptions{ LogRequestStart = true, LogRequestCompletion = true, LogUnhandledExceptions = true, ContextEnricher = httpContext => new[] { new KeyValuePair<string, object?>("Environment", "Production"), new KeyValuePair<string, object?>("Region", "eu-west-1") }});LoggingEnrichmentOptions:
| Option | Type | Default | Description |
|---|---|---|---|
LogRequestStart | bool | true | Log when request begins |
LogRequestCompletion | bool | true | Log when request completes (with status code and elapsed time) |
LogUnhandledExceptions | bool | true | Log unhandled exceptions with correlation context |
ContextEnricher | Func<HttpContext, IEnumerable<KVP>>? | null | Custom enricher delegate |
BaggagePropagationMiddleware
Section titled “BaggagePropagationMiddleware”Distributed systems need context to flow across service boundaries. This middleware propagates selected context values into Activity.Baggage, which is automatically forwarded to downstream services via W3C Baggage headers.
app.UseMiddleware<BaggagePropagationMiddleware>(new BaggagePropagationOptions{ PropagateUserId = true, PropagateTenantId = true, UserIdClaimType = "sub", TenantIdHeaderName = "X-Tenant-ID", CustomBaggageHeaders = new[] { new BaggageHeaderMapping("X-Region", "region"), new BaggageHeaderMapping("X-Feature-Flags", "feature.flags") }});When PropagateUserId is enabled, the middleware extracts the user ID from the authenticated principal and sets it as user.id in Activity baggage. When PropagateTenantId is enabled, it reads the tenant ID from the configured header or HttpContext.Items and sets it as tenant.id.
Middleware Order
Section titled “Middleware Order”Place enrichment middleware early in the pipeline so that all downstream middleware and handlers benefit from the enriched context:
app.UseMiddleware<BaggagePropagationMiddleware>();app.UseMiddleware<LoggingEnrichmentMiddleware>();// ... other middlewareapp.UseAuthentication();app.UseAuthorization();app.MapControllers();6. Configuration
Section titled “6. Configuration”Context enrichment is configured through ContextOptions in PragmaticLoggingOptions, which can be set via code or appsettings.json.
Code Configuration
Section titled “Code Configuration”services.AddPragmaticLogging(logging =>{ logging.ConfigureContext(ctx => { ctx.EnableEnrichment = true; ctx.EnableCorrelationId = true; ctx.IncludeUserContext = true; ctx.IncludeRequestContext = true; ctx.IncludeMachineContext = true; ctx.IncludeProcessContext = true; ctx.IncludeThreadContext = true; });
logging.AddConsole();});appsettings.json Configuration
Section titled “appsettings.json Configuration”{ "PragmaticLogging": { "Context": { "IncludeCorrelationId": true, "IncludeUserContext": true, "IncludeRequestContext": true, "IncludeMachineContext": true, "CustomProperties": { "ServiceName": "OrderService", "Environment": "Production", "Region": "eu-west-1" } } }}ContextOptions Reference
Section titled “ContextOptions Reference”| Option | Type | Default | Description |
|---|---|---|---|
IncludeCorrelationId | bool | true | Add CorrelationId, TraceId, SpanId |
IncludeUserContext | bool | true | Add UserId, UserEmail, UserRole |
IncludeRequestContext | bool | true | Add RequestPath, RequestMethod, StatusCode |
IncludeMachineContext | bool | true | Add MachineName, OSVersion, CLRVersion |
CustomProperties | Dictionary<string, string> | Empty | Static key-value pairs added to every entry |
Per-Provider Context Filtering
Section titled “Per-Provider Context Filtering”Each provider can independently control which context properties it includes via ContextFilterConfiguration. This lets you add full context to file logs while keeping console output compact.
logging.AddConsole(config =>{ config.IncludeContextEnrichment = true; config.ContextFilter = new ContextFilterConfiguration { Mode = ContextFilterMode.Include, PropertyNames = new HashSet<string> { "CorrelationId", "UserId" } };});
logging.AddFile("logs/app.log", config =>{ config.IncludeContextEnrichment = true; config.ContextFilter = new ContextFilterConfiguration { Mode = ContextFilterMode.Exclude, PropertyNames = new HashSet<string> { "CommandLine", "WorkingDirectory" } };});ContextFilterMode values:
| Mode | Behavior |
|---|---|
All | Include all context properties (default) |
Include | Include only properties in PropertyNames or matching PropertyPatterns |
Exclude | Include all except properties in PropertyNames or matching PropertyPatterns |
None | Include no context properties |
How Context Flows Through the Pipeline
Section titled “How Context Flows Through the Pipeline”When a log entry is produced, PragmaticLoggerProviderBase.WriteLog() enriches it with context from two sources before passing it to WriteLogCore:
-
LogContextScope — Ambient properties pushed by middleware via
LogContextScope.PushContext(). These are scoped to the current async flow and automatically pop when the scope is disposed. -
ContextManager.Instance — All registered
IContextProviderinstances are queried (respectingIsAvailable()) and their properties are merged in priority order.
Both sources are filtered through the provider’s ContextFilterConfiguration before being attached to the LogEntry.Properties dictionary. This means each provider can see a different subset of context properties, matching its output requirements.
Log Output Example
Section titled “Log Output Example”With enrichment enabled, a structured JSON log entry includes both business properties and ambient context:
{ "@timestamp": "2026-03-22T14:30:00.123Z", "@level": "INFO", "@logger": "Booking.Orders.PlaceOrderHandler", "@message": "Order placed successfully", "@properties": { "OrderId": "a1b2c3", "Total": 150.00, "CorrelationId": "7f3d2a1b-4e5f-6789-abcd-ef0123456789", "TraceId": "0af7651916cd43dd8448eb211c80319c", "SpanId": "b7ad6b7169203331", "RequestPath": "/api/orders", "RequestMethod": "POST", "UserId": "user-42", "TenantId": "acme-corp", "MachineName": "web-server-03", "ProcessId": 12345, "ThreadId": 7 }}Without enrichment, only OrderId and Total would appear. The rest is injected automatically by context providers.