SolTechnology.Core.Logging
1.2.0
dotnet add package SolTechnology.Core.Logging --version 1.2.0
NuGet\Install-Package SolTechnology.Core.Logging -Version 1.2.0
<PackageReference Include="SolTechnology.Core.Logging" Version="1.2.0" />
<PackageVersion Include="SolTechnology.Core.Logging" Version="1.2.0" />
<PackageReference Include="SolTechnology.Core.Logging" />
paket add SolTechnology.Core.Logging --version 1.2.0
#r "nuget: SolTechnology.Core.Logging, 1.2.0"
#:package SolTechnology.Core.Logging@1.2.0
#addin nuget:?package=SolTechnology.Core.Logging&version=1.2.0
#tool nuget:?package=SolTechnology.Core.Logging&version=1.2.0
SolTechnology.Core.Logging
Production-ready logging primitives for ASP.NET Core on top of
Microsoft.Extensions.Logging. Pull it in and your app gets a W3C-compliant
correlation id on every request, status-aware request envelope logs, declarative
log-scope enrichment, allocation-free operation lifecycle events, and an
OpenTelemetry-friendly ActivitySource — with zero ambient state and no Serilog
or Application Insights dependency required.
Features
- W3C correlation id —
traceparent+X-Correlation-Idresolved from the inbound request and echoed on the response. Same id flows through the log scope, downstream HTTP calls, andActivity.Current. - Request envelope logs — one
Started/Finishedpair per request with log levels aligned to the status code (Information< 400,Warning4xx,Error5xx) andWarningfor client aborts. - Declarative scope enrichment — promote a header, route value, query
parameter, or JSON body field into the per-request log scope with one DI line:
services.LogDetail("X-Tenant-Id", asName: "TenantId", source: LogDetailSource.Header). - Custom enrichers — drop in
ILogScopeEnricherfor anything the declarative form can't express (claims, async lookups, multi-source values). - Request-header scope with PII masking — opt-in dump of every inbound
header into the scope; sensitive values (
Authorization,Cookie, anything starting withBearer) are masked before they reach any sink. - Operation lifecycle events —
OperationStarted/OperationSucceeded/OperationFailedwith stableEventIds (2137–2140) and[LoggerMessage]source generators (allocation-free, short-circuits when disabled). - Per-request timing diagnostics —
ITimingService.StartContext("name")records named sub-context durations; the aggregated map is emitted in the "Finished request" log automatically. Lightweight "where did the time go?" without a full APM. - OpenTelemetry tracing —
CoreLoggingActivitySources.OperationsNameready to plug intoWithTracing(...).AddSource(...). Zero cost when no listener attaches. - Skip-paths for liveness / metrics noise — silences envelope logs for
/health,/metrics,/swagger, etc., while still propagating correlation.
Registration
// Program.cs
builder.Services.AddSolLogging(); // or .AddSolLogging(opts => { ... })
// or .AddSolLogging(builder.Configuration)
var app = builder.Build();
app.UseSolLogging(); // EARLY — before UseRouting / UseEndpoints
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
AddSolLogging is idempotent; safe to call from multiple module installers.
Place UseSolLogging before UseRouting so requests that fail authentication
still get a correlation id and an envelope log entry.
Configuration
LoggingOptions bind from Logging:Core:
| Option | Default | Purpose |
|---|---|---|
MaxLoggedJsonBodyBytes |
65536 |
Cap on JSON body buffered for LogDetailSource.Body enrichment. |
LogClientCorrelationParseErrors |
true |
Emit a Warning when an inbound X-Correlation-Id header is malformed. |
LogRequestHeaders |
false |
Project every inbound header into the scope as RequestHeaders (masked). |
SkipPaths |
[] |
Path prefixes for which the request envelope logs and enrichers are skipped. Correlation is still set and echoed. |
MaskedHeaders |
LoggingDefaults.SensitiveHeaders |
Header names whose values are replaced with ***MASKED***. Case-insensitive. |
{
"Logging:Core": {
"MaxLoggedJsonBodyBytes": 65536,
"LogRequestHeaders": false,
"SkipPaths": [ "/health", "/alive", "/metrics", "/swagger" ],
"MaskedHeaders": [ "Authorization", "Cookie", "X-Api-Key" ]
}
}
builder.Services.AddSolLogging(builder.Configuration);
// or:
builder.Services.AddSolLogging(o => o.SkipPaths = LoggingDefaults.InfrastructurePaths.ToList());
Usage
Correlation id from anywhere
Inbound resolution order: current Activity.TraceId → inbound
X-Correlation-Id (only when no Activity is in scope) → a freshly generated
32-character hex id. Both traceparent and X-Correlation-Id are echoed on the
response.
public sealed class AuditWriter(ICorrelationIdService correlation, ILogger<AuditWriter> logger)
{
public Task WriteAsync(string action, CancellationToken ct)
{
var id = correlation.GetOrGenerate(); // creates one for non-HTTP entry points
logger.LogInformation("Audit [{Action}] correlation [{Correlation}]", action, id);
return Task.CompletedTask;
}
}
Declarative scope enrichment — LogDetail
// Header → scope["TenantId"]
builder.Services.LogDetail("X-Tenant-Id", asName: "TenantId", source: LogDetailSource.Header);
// Query / route key → scope["UserId"]
builder.Services.LogDetail("userId", source: LogDetailSource.Url);
// JSON body field "name" → scope["CityName"], only on these endpoints
builder.Services.LogDetail(
"name",
asName: "CityName",
source: LogDetailSource.Body,
endpoints: ["/api/v1/FindLocationOfCity", "/api/FindCityByName"]);
Body parsing is opt-in (only when a LogDetailSource.Body registration exists),
bounded by MaxLoggedJsonBodyBytes, runs only on application/json, restores
the request stream so MVC model binding still works, and silently no-ops on
malformed JSON.
Custom enricher — ILogScopeEnricher
public sealed class UserScopeEnricher : ILogScopeEnricher
{
public void Enrich(HttpContext context, IDictionary<string, object?> scope)
{
if (context.User?.Identity?.IsAuthenticated == true)
scope["UserId"] = context.User.FindFirst("sub")?.Value;
}
}
builder.Services.AddSolLogScopeEnricher<UserScopeEnricher>();
A faulty enricher cannot take a request down — the middleware catches and warns on enricher failures.
Request headers in the scope (opt-in, PII-safe)
builder.Services.AddSolLogging(o =>
{
o.LogRequestHeaders = true;
o.MaskedHeaders = LoggingDefaults.SensitiveHeaders
.Concat(new[] { "X-Internal-Token" })
.ToList();
});
Masking rules:
| Trigger | Outcome |
|---|---|
Header name listed in MaskedHeaders (case-insensitive) |
Value → ***MASKED***. |
Any value starting with Bearer (regardless of header name) |
Value → ***MASKED***. |
When LogRequestHeaders = false the enricher short-circuits in O(1) without
iterating headers.
Operation lifecycle events
| EventId | Method | Level | Template |
|---|---|---|---|
| 2137 | OperationStarted |
Information | Operation: [{OperationName}]. Status: [START] |
| 2138 | OperationSucceeded |
Information | Operation: [{OperationName}]. Status: [SUCCESS]. Duration: [{DurationMs} ms] |
| 2139 | OperationFailed |
Error | Operation: [{OperationName}]. Status: [FAIL]. Duration: [{DurationMs} ms]. Message: [{Message}] |
| 2140 | (user message) | Information | [{Message}] |
var sw = ValueStopwatch.StartNew();
logger.OperationStarted(nameof(ImportInvoices), message: "batch=123");
try
{
await ImportAsync(ct);
logger.OperationSucceeded(nameof(ImportInvoices), sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
logger.OperationFailed(nameof(ImportInvoices), sw.ElapsedMilliseconds, ex);
throw;
}
Inside a CQRS pipeline these are emitted automatically via the [LogScope]
attribute — see SolTechnology.Core.CQRS.
OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddSource(CoreLoggingActivitySources.OperationsName) // MediatR operations
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter());
Result in App Insights / structured sink
| Timestamp | Message | CorrelationId | CityName |
|---|---|---|---|
| 2026-05-06T08:00:10.738Z | Started request [POST] [/api/v1/FindLocationOfCity] | 8fa1… | Warsaw |
| 2026-05-06T08:00:10.745Z | Operation: [FindLocationOfCity]. Status: [START] | 8fa1… | Warsaw |
| 2026-05-06T08:00:12.859Z | Operation: [FindLocationOfCity]. Status: [SUCCESS]. Duration: [2114 ms] | 8fa1… | Warsaw |
| 2026-05-06T08:00:12.860Z | Finished request [POST] [/api/v1/FindLocationOfCity] → [200] in [2122 ms] | 8fa1… | Warsaw |
Testing
No dedicated fixture — use Microsoft.Extensions.Logging.Testing.FakeLogger (or
NSubstitute on ILogger<T>) and assert on the captured entries. For component
tests, APIFixture<TEntryPoint> from SolTechnology.Core.API.Testing already wires
AddSolLogging + UseSolLogging, so correlation flows end-to-end out of the
box.
[Test]
public async Task Endpoint_AcceptsCorrelationId_AndEchoesItBack()
{
// Arrange
var client = _fixture.CreateClient();
var correlation = "0123456789abcdef0123456789abcdef";
client.DefaultRequestHeaders.Add("X-Correlation-Id", correlation);
// Act
var response = await client.GetAsync("/api/trips/42");
// Assert
response.Headers.GetValues("X-Correlation-Id").Should().ContainSingle().Which.Should().Be(correlation);
}
Scope helpers
using SolTechnology.Core.Logging;
using var scope = logger.PushToScope("TenantId", tenantId);
// or multiple:
using var scope = logger.PushToScope(("TenantId", tenantId), ("Region", region));
PII masking
using SolTechnology.Core.Logging.Masking;
PiiMask.Full("secret@email.com"); // "***MASKED***"
PiiMask.Partial("secret@email.com", 3); // "sec***com"
On CQRS requests, combine [LogScope] with [Masked]:
[LogScope, Masked(MaskMode.Partial, keepChars: 4)]
public string Email { get; set; } = null!;
// scope["Email"] = "john***.com"
Conventions
UseSolLoggingruns beforeUseRouting. Otherwise pre-routing failures (auth challenges, 404s) leave production without a correlation id.IncludeScopes: truein the console formatter — any sink that should see correlation / enrichment properties (Console JSON, App Insights, Loki, Datadog) requires it.{PascalCase}placeholders — matches MEL / Serilog / App Insights and the KQL queries consumers will write. SeeClaudeCodingGuide.md§11.- Wrap values in
[]in every placeholder. Empty becomes[]instead of invisible. - Never log secrets. Add the header to
MaskedHeaders; never craft a bespoke log line that bypasses the enricher. - Production levels:
Default: Warning,Microsoft.Hosting.Lifetime: Information,SolTechnology.Core.Logging.Middleware.LoggingMiddleware: Information,SolTechnology.Core.CQRS.PipelineBehaviors.LoggingPipelineBehavior: Information.
What ships in DI
AddSolLogging registers:
ICorrelationIdService— ambient correlation accessor (singleton, noAsyncLocaltraps).ITimingService— per-request timing diagnostics (scoped). Handlers wrap sub-operations inStartContext("name")and the map is emitted on finish.TimeProvider—TimeProvider.System(singleton, TryAdd — your custom registration wins for testing).LoggingOptions— bound and validated on application start.LoggingMiddleware— request envelope + scope composition, activated byUseSolLogging.ILogScopeEnricherset — declarativeLogDetailregistrations plus any custom enricher you add viaAddSolLogScopeEnricher<T>().CoreLoggingActivitySources—OperationsNameactivity source for OpenTelemetry plumbing.
All registrations are TryAdd* so a consumer's custom registration always wins.
Per-request timing diagnostics (ITimingService)
A lightweight "where did the time go in this request?" breakdown — without a full APM. Handlers
wrap sub-operations in using (timingService.StartContext("name")) and the aggregated
{ name → elapsed ms } map is emitted automatically in the "Finished request" log:
Finished request [GET] [/api/v2/cities/find] -> [200] in [156 ms] — timings: [{"http": 120, "cache": 2}]
Registration
Registered automatically by AddSolLogging() — no extra call needed.
Usage (in a handler or service)
public class FindCityByNameHandler(
ICityDomainService cityDomainService,
ITimingService timingService) : IQueryHandler<FindCityByNameQuery, City>
{
public async Task<Result<City>> Handle(FindCityByNameQuery query, CancellationToken ct)
{
City result;
using (timingService.StartContext("http"))
{
result = await cityDomainService.Get(query.Name);
}
using (timingService.StartContext("cache"))
{
result = await cityDomainService.Get(query.Name);
}
return result;
}
}
API
| Method | Behaviour |
|---|---|
StartContext(name) |
Starts a named timer. Returns IDisposable — disposing stops and accumulates. Multiple calls with the same name sum (e.g. two DB calls both named "db" aggregate into one total). |
GetTimings() |
Returns IDictionary<string, long> — name → total elapsed ms. Called by the middleware on request finish. |
Reset() |
Clears all accumulated timings. Called by the middleware at request start. |
Design notes
- Scoped — each HTTP request gets its own
TimingServiceinstance (no cross-request leakage). TimeProvider-sourced — all timing reads go throughTimeProvider(testable withFakeTimeProvider).- Idempotent dispose — double-disposing a handle is safe (second dispose is a no-op).
- Zero cost when unused — if no handler calls
StartContext, the "Finished request" log omits thetimingsfield entirely (no empty{}noise). - Aggregates, not traces — this is a request-level summary, not a span tree. For full
distributed tracing, wire
CoreLoggingActivitySourcesinto OpenTelemetry.
Recipe: Singleton→scoped correlation bridge (background workers)
This is a pattern, not shipped code. The shape is too tenant-model-coupled to generalise into a library primitive. Copy and adapt.
A singleton background worker (Hangfire job, BackgroundService) has no HTTP request scope — so
ICorrelationIdService is empty and DI scoped services aren't available. The recipe spins a DI
scope manually and attaches correlation + principal context:
public class SettlementPollerJob(IServiceScopeFactory scopeFactory, ICorrelationIdService correlation)
{
public async Task ExecuteAsync(CancellationToken ct)
{
// 1. Generate a correlation for this background unit-of-work
var correlationId = CorrelationId.Generate();
correlation.Set(correlationId);
// 2. Spin a DI scope (so scoped services like DbContext are fresh)
await using var scope = scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<ISettlementHandler>();
// 3. Push a log scope so every log in this UoW carries the correlation
using var logScope = scope.ServiceProvider
.GetRequiredService<ILogger<SettlementPollerJob>>()
.BeginScope(new Dictionary<string, object?>
{
["CorrelationId"] = correlationId.Value,
["JobName"] = "SettlementPoller"
});
// 4. Do the work
await handler.ProcessAsync(ct);
}
}
Key points:
ICorrelationIdServiceis singleton — set it before starting work so downstream HTTP calls (CorrelationPropagatingHandler) pick it up.- Dispose the scope when done —
DbContext,IIdempotencyStore, etc. are released. - For Hangfire jobs:
Core.Hangfire'sCorrelationIdJobFilterdoes steps 1+3 automatically. Use this pattern only for rawBackgroundServiceor custom polling loops.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages (5)
Showing the top 5 NuGet packages that depend on SolTechnology.Core.Logging:
| Package | Downloads |
|---|---|
|
SolTechnology.Core.CQRS
A complete, dependency-light CQRS toolkit: an in-house mediator, the Result pattern, automatic FluentValidation, fire-and-forget notifications, and logging pipeline behaviors — everything to keep your application layer clean, decoupled and testable. |
|
|
SolTechnology.Core.MessageBus
A clean, strongly-typed Azure Service Bus wrapper for event-driven architectures. Publish and subscribe to messages with minimal boilerplate — queues, topics and pub/sub for decoupled microservices. |
|
|
SolTechnology.Core.Api
Everything you need to ship a clean ASP.NET Core web API: an RFC 7807 ProblemDetails error pipeline, header-based API versioning, and MVC filters that auto-convert Result<T> to the wire format. Bootstrap in one call with AddSolApiCore + AddSolApiCoreFilters + UseSolSwaggerWithVersioning, with CorrelationId propagation through SolTechnology.Core.Logging. |
|
|
SolTechnology.Core.HTTP
A resilient, strongly-typed HTTP client wrapper built on Microsoft.Extensions.Http.Resilience / Polly v8: retry with exponential backoff, circuit breaker, idempotent-only retry by default, correlation propagation, opt-in response-body capture, ValidateOnStart configuration, and first-class metrics on the 'SolTechnology.Core.HTTP' meter. |
|
|
SolTechnology.Core.Hangfire
Durable, persistent event dispatch and recurring jobs for SolTechnology.Core.CQRS. Opt in with AddPersistentEvents() and your events survive process restarts via Hangfire storage; schedule recurring work with AddSolRecurringJob<TJob>(cron). |
GitHub repositories
This package is not used by any popular GitHub repositories.