Tjidde.Logging
2.0.0
dotnet add package Tjidde.Logging --version 2.0.0
NuGet\Install-Package Tjidde.Logging -Version 2.0.0
<PackageReference Include="Tjidde.Logging" Version="2.0.0" />
<PackageVersion Include="Tjidde.Logging" Version="2.0.0" />
<PackageReference Include="Tjidde.Logging" />
paket add Tjidde.Logging --version 2.0.0
#r "nuget: Tjidde.Logging, 2.0.0"
#:package Tjidde.Logging@2.0.0
#addin nuget:?package=Tjidde.Logging&version=2.0.0
#tool nuget:?package=Tjidde.Logging&version=2.0.0
Tjidde.Logging
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).
Purpose
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.
Supported .NET Versions
| 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.
Installation
dotnet add package Tjidde.Logging
Or via PackageReference:
<PackageReference Include="Tjidde.Logging" Version="*" />
Usage
Basic registration
services.AddLogging(builder => builder.AddTjiddeLogger());
With options
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"];
}));
Log levels
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.
Accessors and DI (recommended)
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.
Setting customer context
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();
Custom customer context accessor
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());
Logging a normal message
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
Logging an exception
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]
Sinks: writing to more than the console
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.
Output Format
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
Options Reference
| 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 |
Sensitive Data Masking
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
Runtime keys
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.
Security notes
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.
Future Extensibility
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.
Architecture
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
License
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.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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
- Microsoft.Extensions.Configuration.Binder (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Logging (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
-
net8.0
- Microsoft.Extensions.Configuration.Binder (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Logging (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
-
net9.0
- Microsoft.Extensions.Configuration.Binder (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Logging (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
## [2.0.0] - 2026-10-04
### Added
- `MetricsLoggerExtensions.MetricsEventId` (`Id = 10000`, `Name = "Metrics"`), plus the constants `MetricsEventName` and `MetricsEventIdValue`. Tjidde.Logging shows every entry with event name `Metrics` as `[METRICS]` in magenta.
- `TjiddeLoggerOptions.UseUtcTimestamp` (default `false`): write timestamps in UTC instead of local time. JSON `@timestamp` is ISO-8601 with the offset (`+00:00` in UTC).
- Timestamps come from `System.TimeProvider`: the one registered in DI, or `TimeProvider.System` when none is registered, so tests can use a `FakeTimeProvider`. New constructor overload `TjiddeLoggerProvider(IOptionsMonitor<TjiddeLoggerOptions>, ICustomerContextAccessor, IMaskedKeysAccessor, TimeProvider)`; the existing constructor still works and uses `TimeProvider.System`.
- `MaskedKeysStore`: a non-static, thread-safe `IMaskedKeysAccessor` with `Add`, `Remove`, `Clear` and `GetKeys` that returns immutable snapshots, and the builder extension `UseIsolatedMaskedKeys()`, which registers one store per host as the `IMaskedKeysAccessor` (replacing an earlier registration, in any order relative to `AddTjiddeLogger`). Inject `MaskedKeysStore` to add keys that apply immediately to that host's loggers only, so hosts and tests in one process no longer share runtime keys. The static `MaskedKeysContext` stays the default and now uses a `MaskedKeysStore` internally. The README, wikis and AI integration guide now recommend the accessors and DI for testable code and describe the static classes as the convenience variant.
- Sinks: the public `ILogSink` interface (namespace `Tjidde.Logging.Sinks`) receives every entry as an immutable `TjiddeLogEntry` with `Timestamp`, `Level`, `EventId`, `Category`, `ClassName`, `MethodName`, `Customer`, `Message`, `FormattedException`, `RenderedLine` (text or JSON), `OutputFormat` and `IsMetrics`; all text in it is masked. Register sinks with `AddTjiddeSink<TSink>()`, `AddTjiddeSink(instance)` or `services.AddSingleton<ILogSink, ...>()`. Sinks are called synchronously on the logging thread; an exception in a sink is ignored and never affects logging, the console or other sinks, and entries logged from inside a sink are not dispatched again. New constructor overload `TjiddeLoggerProvider(..., TimeProvider, IEnumerable<ILogSink>?)`.
- `InMemoryLogSink`: a built-in, thread-safe sink that keeps the newest `Capacity` entries (default 1000), with `GetSnapshot()`, `Count`, `Clear()` and the events `EntryAdded` and `Cleared`. Register it with `AddTjiddeInMemorySink(capacity)`.
- `TjiddeLoggerOptions.WriteToConsole` (default `true`): set to `false` to write only to the sinks. Applies immediately when the options reload.
- XML documentation for `TjiddeLogOutputFormat.Text` and `Json`.
- Samples: the Blazor, Avalonia and terminal samples use the built-in `InMemoryLogSink` instead of their own in-memory logger providers, recognize metrics entries by their event ID instead of the obsolete `Metrics` level, and log metrics with `LogMetrics`. The terminal sample turns the console off, so console output no longer draws over its UI.
### Changed
- Dropped support for .NET 7. The package now targets `net8.0`, `net9.0` and `net10.0`.
- Options changes now apply to existing loggers immediately, without a restart: the provider listens to `IOptionsMonitor<TjiddeLoggerOptions>.OnChange`, and `AddTjiddeLogger(IConfiguration)` / `AddTjiddeLogger(IConfigurationSection)` rebind the options when the configuration reloads (for example `appsettings.json` with `reloadOnChange`). Options, masker, exception formatter and OpenTelemetry fallback `ActivitySource` are replaced together, so a log call never mixes old and new settings; the previous `ActivitySource` is disposed.
- `LogMetrics` now logs at `LogLevel.Information` with `MetricsEventId` instead of the custom level `(LogLevel)10`. Tjidde.Logging still shows these entries as `[METRICS]` and its own minimum level never drops them; other providers see a normal information entry. `LogMetrics(EventId, ...)` keeps the caller's event ID and only adds the name `Metrics` when it has none.
- JSON output: object cycles in logged values are written as `null` instead of failing, and object graphs deeper than 32 levels fall back to `ToString()`.
- All loggers of a provider share one `SensitiveDataMasker` (and its regular expressions) per configuration instead of building one per category; it is replaced when the options change.
- `AddTjiddeLogger` registers `TjiddeLoggerProvider` through a factory, so the container never has to choose between its constructors.
- `IsEnabled` caches the resolved minimum level per logger until the options change, instead of matching `CategoryMinimumLevels` on every call.
### Performance
Output is unchanged; see `benchmarks/README.md` for the measurements (new BenchmarkDotNet project `benchmarks/Tjidde.Logging.Benchmarks`).
- `SensitiveDataMasker.MaskMessage` skips the regular expressions of a key that does not occur in the text (case-insensitive ordinal check, on .NET 9+ one `SearchValues<string>` scan for all keys). Text with non-ASCII characters and non-ASCII keys still always run the expressions, because those can match case-insensitively in ways an ordinal comparison does not.
- Fewer allocations per entry: the list of masked property names is only created when a property is masked, the property list is sized up front, the scopes are read in one pass (method name and scope texts together, without closures or LINQ), and the text line is written into one reused `StringBuilder` per thread instead of via intermediate strings.
- JSON output is written with a `Utf8JsonWriter` (one reused buffer per thread) instead of serializing two dictionaries, and string, `int`, `long` and `bool` property values are written directly instead of through a `JsonElement`. The JSON is byte for byte the same; when the writer fails, the previous serializer path is used.
### Fixed
- `LogMetrics` threw an `ArgumentOutOfRangeException` when another provider, such as the Microsoft console logger (`AddConsole()`), was also registered.
- Logging no longer throws. A property value that cannot be serialized (unsupported type, throwing getter) falls back to its `ToString()`, or to `[unserializable: TypeName]` when that throws too; a value whose `ToString()` or enumeration throws no longer breaks the message (the template is used instead); a failing custom `IExceptionFormatter` falls back to exception type and message; scopes that throw are skipped; OpenTelemetry export failures are ignored. Any other failure while building the entry writes a minimal line with level, category, message and a note that rendering failed.
- A custom `IMaskedKeysAccessor` that returns the same mutable collection on every call: added or removed keys were not picked up after the first log call. The masker now compares the keys by content (only the immutable snapshots of `MaskedKeysContext` and `MaskedKeysStore` are compared by reference), builds from a copy, and no longer lets a concurrent rebuild overwrite a newer key set.
### Deprecated
- `TjiddeLoggerOptions.MinimumLevel` and `TjiddeLoggerOptions.CategoryMinimumLevels` (`TjiddeLogger:MinimumLevel`, `TjiddeLogger:CategoryMinimumLevels`) are marked `[Obsolete]` and will be removed in a future major version. Use the standard filter `Logging:Tjidde:LogLevel` in `appsettings.json` (or `AddFilter<TjiddeLoggerProvider>(...)`) instead. They still work and are still bound from configuration: the `Logging` filters run first, then these, so the stricter level wins.
- `MetricsLoggerExtensions.Metrics` (`(LogLevel)10`): not a valid `LogLevel`, and other providers throw on it. Use `LogMetrics(...)`, or `System.Diagnostics.Metrics` for real application metrics. Tjidde.Logging still recognizes the value.
Full changelog: https://github.com/tjidde-nl/TjiddeLogger/blob/main/CHANGELOG.md