Skip to content

Context Enrichment

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.


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();
}

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 RangeConvention
1-99Application-specific overrides
100-499Request-scoped providers (HTTP, correlation)
500-899Thread and runtime providers
900-999Process-level providers
1000+Machine-level (static) providers

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);
}

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.

MethodDescription
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
ProviderCountNumber of registered providers

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;
}
};

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;
});
OptionTypeDefaultDescription
EnableDefaultProvidersbooltrueRegister Machine, Process, Thread providers
CacheTimeoutTimeSpan1 minDuration before cached context expires
EnableMetricsbooltrueCollect provider performance metrics
MaxAsyncCacheSizeint1000Max async context cache entries

Pragmatic.Logging ships with five built-in context providers that cover the most common ambient data sources.

ProviderNamePriorityScopeKey Properties
HttpContextProviderHttpContext100Per-requestRequestPath, RequestMethod, UserId, RemoteIpAddress
CorrelationIdProviderCorrelationId50Per-requestCorrelationId, TraceId, SpanId
ThreadContextProviderThread500Per-callThreadId, ThreadName, IsBackground, Culture
ProcessContextProviderProcess900Per-processProcessId, ProcessName, AppVersion, StartTime
MachineContextProviderMachine1000Per-machineMachineName, OSVersion, CLRVersion, RuntimeIdentifier

Extracts request, response, connection, and user information from IHttpContextAccessor. Only available during HTTP request processing.

Properties provided:

PropertyExample ValueDescription
RequestId0HN3V...ASP.NET Core trace identifier
RequestPath/api/ordersRequest URL path
RequestMethodGETHTTP method
RequestSchemehttpsURL scheme
RequestHostapi.example.comHost header
RemoteIpAddress192.168.1.100Client IP
IsAuthenticatedtrueWhether user is authenticated
UserIduser-42From NameIdentifier claim
UserEmailuser@example.comFrom Email claim
UserRoleAdminFrom Role claim
Header_User-AgentMozilla/5.0...Selected request headers
Header_X-Forwarded-For10.0.0.1Proxy 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.

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:

PropertySourceDescription
CorrelationIdHeader / Activity / GeneratedRequest correlation identifier
TraceIdActivity.Current.TraceIdW3C trace ID (when Activity exists)
SpanIdActivity.Current.SpanIdW3C 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.

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.

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.

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.


When your application needs domain-specific context — tenant ID, feature flags, or business-specific metadata — you create a custom provider by extending ContextProviderBase.

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())
);
}
}

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;
});

Two middleware components wire context enrichment into the HTTP request pipeline.

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:

OptionTypeDefaultDescription
LogRequestStartbooltrueLog when request begins
LogRequestCompletionbooltrueLog when request completes (with status code and elapsed time)
LogUnhandledExceptionsbooltrueLog unhandled exceptions with correlation context
ContextEnricherFunc<HttpContext, IEnumerable<KVP>>?nullCustom enricher delegate

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.

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 middleware
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Context enrichment is configured through ContextOptions in PragmaticLoggingOptions, which can be set via code or appsettings.json.

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();
});
{
"PragmaticLogging": {
"Context": {
"IncludeCorrelationId": true,
"IncludeUserContext": true,
"IncludeRequestContext": true,
"IncludeMachineContext": true,
"CustomProperties": {
"ServiceName": "OrderService",
"Environment": "Production",
"Region": "eu-west-1"
}
}
}
}
OptionTypeDefaultDescription
IncludeCorrelationIdbooltrueAdd CorrelationId, TraceId, SpanId
IncludeUserContextbooltrueAdd UserId, UserEmail, UserRole
IncludeRequestContextbooltrueAdd RequestPath, RequestMethod, StatusCode
IncludeMachineContextbooltrueAdd MachineName, OSVersion, CLRVersion
CustomPropertiesDictionary<string, string>EmptyStatic key-value pairs added to every entry

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:

ModeBehavior
AllInclude all context properties (default)
IncludeInclude only properties in PropertyNames or matching PropertyPatterns
ExcludeInclude all except properties in PropertyNames or matching PropertyPatterns
NoneInclude no context properties

When a log entry is produced, PragmaticLoggerProviderBase.WriteLog() enriches it with context from two sources before passing it to WriteLogCore:

  1. 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.

  2. ContextManager.Instance — All registered IContextProvider instances are queried (respecting IsAvailable()) 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.


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.