A reusable NuGet package for standardized, structured logging in .NET applications.
Integrates naturally with the Microsoft.Extensions.Logging pipeline.
Using an AI coding assistant? Point it to AI-INTEGRATION.md. The same guide ships inside the NuGet package, at
~/.nuget/packages/tjidde.logging/<version>/AI-INTEGRATION.md(on Windows:%UserProfile%\.nuget\packages\tjidde.logging\<version>\AI-INTEGRATION.md).
Tjidde.Logging provides a consistent logging experience across .NET applications — web APIs, workers, background services, and more. It standardizes log output format, enriches entries with customer/tenant context, masks sensitive data, and formats exceptions in a clear and readable way.
| Version | Supported |
|---|---|
| .NET 8 | ✅ |
| .NET 9 | ✅ |
| .NET 10 | ✅ |
Multi-targeting (net8.0;net9.0;net10.0) is configured in the package project and intended for CI builds where all SDKs are available.
dotnet add package Tjidde.LoggingOr via PackageReference:
<PackageReference Include="Tjidde.Logging" Version="*" />services.AddLogging(builder => builder.AddTjiddeLogger());services.AddLogging(builder => builder.AddTjiddeLogger(options =>
{
options.IncludeScopes = true;
options.IncludeStackTrace = true;
options.IncludeInnerExceptions = true;
options.EnableSensitiveDataMasking = true;
options.MaskPlaceholder = "[REDACTED]";
options.AdditionalSensitiveKeys = ["internalToken", "tenantSecret"];
}));Filter log levels with the standard Logging section of appsettings.json. Logging:Tjidde:LogLevel applies to Tjidde.Logging only (the provider alias is Tjidde); Logging:LogLevel applies to every provider:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Tjidde": {
"LogLevel": {
"Default": "Information",
"MyCompany.MyApp.Services.OrderService": "Debug",
"MyCompany.MyApp.Polling": "Warning"
}
}
}
}In code, builder.AddFilter<TjiddeLoggerProvider>("MyCompany.MyApp", LogLevel.Debug) does the same.
TjiddeLoggerOptions.MinimumLevel and CategoryMinimumLevels (TjiddeLogger:MinimumLevel, TjiddeLogger:CategoryMinimumLevels) are obsolete and will be removed in a future major version. They still work: the Logging filter runs first, then Tjidde's own filter, so an entry must pass both and the stricter level wins. Move these values to Logging:Tjidde:LogLevel.
The logger reads the customer context and the runtime masked keys through two DI abstractions: ICustomerContextAccessor and IMaskedKeysAccessor. For testable code, use these instead of the static classes:
- Customer context: register your own
ICustomerContextAccessor(for example one that reads the HTTP context, see below). In tests, register a fake that returns a fixed value. - Masked keys: call
UseIsolatedMaskedKeys()and injectMaskedKeysStore. Each host (and each test'sServiceProvider) gets its own keys; changes apply immediately to that host's existing loggers.
services.AddLogging(builder => builder.AddTjiddeLogger().UseIsolatedMaskedKeys());
public sealed class VaultService(MaskedKeysStore maskedKeys)
{
public void OnSecretLoaded(string secret) => maskedKeys.Add(secret);
}The static CustomerContext and MaskedKeysContext remain the defaults and are the convenience variant: no DI needed, but MaskedKeysContext is shared by every host and test in the process.
Use CustomerContext.Set(...) at the start of a request, job, or operation. It flows through the async call chain automatically. This works with the default AsyncLocalCustomerContextAccessor.
// In middleware, a job handler, or service entry point:
CustomerContext.Set("AcmeCorp");
// Later in any service in the same async flow:
_logger.LogInformation("Order placed successfully.");
// Output: 2024-03-17: 14:22:01: [INFORMATION] Class=>OrderService: Client=>AcmeCorp: Order placed successfully.
// Clear when done (optional — AsyncLocal is scoped to the flow):
CustomerContext.Clear();For HTTP-based apps you may want to derive the customer from the HTTP context. Implement ICustomerContextAccessor and register it before calling AddTjiddeLogger:
public class HttpCustomerContextAccessor : ICustomerContextAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpCustomerContextAccessor(IHttpContextAccessor httpContextAccessor)
=> _httpContextAccessor = httpContextAccessor;
public string? GetCustomerContext()
=> _httpContextAccessor.HttpContext?.User?.FindFirst("tenant")?.Value;
}
// Registration (must be before AddTjiddeLogger):
services.AddSingleton<ICustomerContextAccessor, HttpCustomerContextAccessor>();
services.AddLogging(builder => builder.AddTjiddeLogger());public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger) => _logger = logger;
public void PlaceOrder(int orderId)
{
_logger.LogInformation("Placing order {OrderId}", orderId);
}
}Output:
2024-03-17: 14:22:01: [INFORMATION] Class=>OrderService: Client=>AcmeCorp: Placing order 42
try
{
// ...
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order {OrderId}", orderId);
}Output:
2024-03-17: 14:22:01: [ERROR] Class=>OrderService: Client=>AcmeCorp: Failed to process order 42 | Exception: [System.InvalidOperationException: Payment gateway timeout | StackTrace: at OrderService.ProcessPayment() in OrderService.cs:line 42] -> [System.TimeoutException: The operation timed out]
The console is the default destination. Extra destinations implement ILogSink (namespace Tjidde.Logging.Sinks) and receive every entry as an immutable TjiddeLogEntry: Timestamp, Level, EventId, Category, ClassName, MethodName, Customer, Message, FormattedException, RenderedLine (text or JSON, as on the console), OutputFormat and IsMetrics. All text in the entry is masked; it never contains unmasked values.
builder.Logging
.AddTjiddeLogger()
.AddTjiddeInMemorySink(capacity: 500) // built-in: inject InMemoryLogSink to read the entries
.AddTjiddeSink<MyFileSink>(); // your own sink, created by the container
// or register directly:
builder.Services.AddSingleton<ILogSink, MyFileSink>();public sealed class MyFileSink : ILogSink
{
public void Write(TjiddeLogEntry entry) { /* fast, thread-safe */ }
}- Sinks are called synchronously on the logging thread, in registration order. Keep
Writefast and thread-safe; queue slow I/O yourself. - An exception in a sink is ignored: logging never throws, and the console and the other sinks still get the entry. An entry logged from inside a sink is not sent to the sinks again.
- A sink must not take
ILogger<T>in its constructor (circular dependency). InMemoryLogSinkkeeps the newestCapacityentries (default 1000), withGetSnapshot(),Count,Clear()and the eventsEntryAddedandCleared. Useful for UIs and tests.- Set
WriteToConsole = false(or"TjiddeLogger": { "WriteToConsole": false }) to write only to the sinks, for example in a desktop or terminal UI. It applies immediately when the options reload.
YYYY-MM-DD: HH:mm:ss: [LEVEL] Class=>ClassName Method=>MethodName: Client=>Customer: Message
LEVELis one of:TRACE,DEBUG,INFORMATION,WARNING,ERROR,CRITICAL,METRICSMETRICSis shown for entries written with_logger.LogMetrics(...). They are logged atLogLevel.Informationwith eventMetrics(id 10000), so other providers such asAddConsole()see a normal information entry. For real application metrics, useSystem.Diagnostics.Metrics.Method=>is included when set viausing (_logger.BeginMethodScope())(or a scope with keyMethodName)Client=>is omitted when no customer context is active- The timestamp is local time by default; set
UseUtcTimestamp = truefor UTC. JSON output writes@timestampas ISO-8601 with the offset (for example2026-10-02T10:15:00.0000000+00:00) - The clock is the
TimeProviderregistered in DI (for example aFakeTimeProviderin tests), orTimeProvider.Systemwhen none is registered
| Option | Type | Default | Description |
|---|---|---|---|
WriteToConsole |
bool |
true |
Write entries to the console; false writes only to the registered sinks |
IncludeScopes |
bool |
true |
Include scope information in output |
UseUtcTimestamp |
bool |
false |
Write timestamps in UTC instead of local time |
ResolveMethodNameFromStackTrace |
bool |
false |
Fall back to the stack trace for the method name when no MethodName scope is active (slow) |
IncludeStackTrace |
bool |
true |
Include stack trace in exception output |
IncludeInnerExceptions |
bool |
true |
Include inner exception chain |
EnableSensitiveDataMasking |
bool |
true |
Apply sensitive data masking |
MaskPlaceholder |
string |
[REDACTED] |
Replacement text for masked values |
AdditionalSensitiveKeys |
IList<string> |
[] |
Extra keys to treat as sensitive |
MinimumLevel (obsolete) |
LogLevel |
Trace |
Use Logging:Tjidde:LogLevel instead; will be removed in a future major version |
CategoryMinimumLevels (obsolete) |
IDictionary<string, LogLevel> |
{} |
Use Logging:Tjidde:LogLevel instead; will be removed in a future major version |
The following field names and message patterns are masked by default:
password, wachtwoord, token, access_token, refresh_token, secret,
client_secret, api_key, x-api-key, authorization, bearer, cookie, set-cookie
Masking is applied to:
- Plain log message strings (pattern:
key=value,key: value) - Structured log property names
- Dictionary/state collections passed to the logger
Values that are only known at runtime (a token from a vault, a customer code) can be added while the app runs. They are masked as literal values anywhere in the output and as key names, and apply immediately to existing loggers.
- Recommended:
UseIsolatedMaskedKeys()and injectMaskedKeysStore(Add,Remove,Clear,GetKeys). The keys belong to that host only. - Convenience: the static
MaskedKeysContext.Add(...)/Remove(...)/Clear(), used by default. These keys are process-wide. - Custom: register your own
IMaskedKeysAccessor. KeepGetKeys()cheap and thread-safe; returning an immutable snapshot is fastest.
Important: Sensitive data masking significantly reduces the risk of leaking secrets in logs, but it is not foolproof. It cannot detect:
- Secrets embedded in arbitrary string concatenations
- Secrets in custom object
.ToString()output- Obfuscated or encoded values
- New field names not in the known list
Always review log output when integrating new data sources. Do not rely solely on this package as a security boundary.
The following capabilities are intentionally not implemented in v1.0 but the package is designed to support them:
- Method argument logging — The
ICustomerContextAccessorand scope design allow future decorator/interceptor-based enrichment. Attribute-based exclusion ([SensitiveArgument]) can be layered on top. - Additional structured outputs — JSON is built in; OTLP/log-record output can be added as an alternative formatter or sink.
- Automatic argument logging — Explicitly deferred due to privacy, security, and performance risks. When added, it will require opt-in per method or argument.
Tjidde.Logging/
├── Context/
│ ├── ICustomerContextAccessor.cs # Abstraction for customer context
│ ├── AsyncLocalCustomerContextAccessor.cs # Default AsyncLocal implementation
│ └── CustomerContext.cs # Static AsyncLocal store
├── Extensions/
│ └── TjiddeLoggingBuilderExtensions.cs # AddTjiddeLogger() extension methods
├── Formatting/
│ ├── IExceptionFormatter.cs # Exception formatting contract
│ └── ExceptionFormatter.cs # Structured exception output
├── Logging/
│ ├── ConsoleLogProcessor.cs # Background console writer (default sink)
│ ├── TjiddeLogger.cs # ILogger implementation
│ └── TjiddeLoggerProvider.cs # ILoggerProvider implementation
├── Sinks/
│ ├── ILogSink.cs # Extra destinations for entries
│ ├── TjiddeLogEntry.cs # Immutable, masked entry passed to sinks
│ └── InMemoryLogSink.cs # Built-in bounded in-memory sink
├── Masking/
│ ├── IMaskedKeysAccessor.cs # Runtime keys abstraction (+ GlobalMaskedKeysAccessor)
│ ├── MaskedKeysStore.cs # Per-host runtime keys (UseIsolatedMaskedKeys)
│ ├── MaskedKeysContext.cs # Static, process-wide runtime keys
│ ├── ISensitiveDataMasker.cs # Masking contract
│ └── SensitiveDataMasker.cs # Default masking implementation
└── Options/
└── TjiddeLoggerOptions.cs # Configuration options
Licensed under the Apache License 2.0. Copyright © 2026 Tjidde Nieuwenhuizen.
If you redistribute this package or a modified version of it, keep the NOTICE file, as the license requires.