Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f4fb29d
Add v2.0 namespace clean-up proposal
tjidde-nl Oct 2, 2026
d60447d
Add package icon
tjidde-nl Oct 2, 2026
e41ad3a
Test all target frameworks (net7.0-net10.0) in CI
tjidde-nl Oct 2, 2026
8561aec
Add Dependabot and update test packages
tjidde-nl Oct 2, 2026
a12f3ba
Use only the current version's changelog section as release notes
tjidde-nl Oct 2, 2026
fc3cd23
Log metrics at Information with a Metrics event instead of (LogLevel)10
tjidde-nl Oct 2, 2026
f5b0ce8
Make logging never throw
tjidde-nl Oct 2, 2026
1b267fe
Merge branch 'worktree-agent-aea9aa4005b21b714' into improvements
tjidde-nl Oct 2, 2026
8448418
Merge branch 'worktree-agent-adb8dbbe24a87d6f6' into improvements
tjidde-nl Oct 2, 2026
1b259d1
Merge branch 'worktree-agent-a385c0521950cddcc' into improvements
tjidde-nl Oct 2, 2026
563a45f
Reference Microsoft.Extensions.Logging.Console for every test TFM
tjidde-nl Oct 2, 2026
a297593
Apply options changes to existing loggers immediately
tjidde-nl Oct 2, 2026
ede726b
Share one masker per configuration and cache the minimum level
tjidde-nl Oct 2, 2026
d6bc837
Take timestamps from TimeProvider and add UseUtcTimestamp
tjidde-nl Oct 2, 2026
d521a65
Deprecate MinimumLevel and CategoryMinimumLevels in favor of Logging:…
tjidde-nl Oct 2, 2026
dd7dcb0
Add MaskedKeysStore and UseIsolatedMaskedKeys for per-host masked keys
tjidde-nl Oct 2, 2026
aab9350
Add ILogSink abstraction, InMemoryLogSink and WriteToConsole
tjidde-nl Oct 3, 2026
325a711
Use the built-in InMemoryLogSink in the samples
tjidde-nl Oct 3, 2026
ad1ea75
Merge branch 'improvements' into worktree-agent-a2bad4555513a8611
tjidde-nl Oct 3, 2026
8d19311
Add BenchmarkDotNet benchmarks for the logging path
tjidde-nl Oct 3, 2026
83d380e
Skip masking expressions for keys that do not occur in the text
tjidde-nl Oct 3, 2026
5265a81
Add package validation and public API tracking
tjidde-nl Oct 3, 2026
8460951
Reduce allocations when building an entry
tjidde-nl Oct 3, 2026
a9fc37b
Merge branch 'improvements' into worktree-agent-a6daf4d1b1015fb6f
tjidde-nl Oct 3, 2026
e2f66ef
Merge branch 'improvements' into worktree-agent-a6daf4d1b1015fb6f
tjidde-nl Oct 3, 2026
750d03c
Bump FluentAssertions from 8.9.0 to 8.11.0
tjidde-nl Oct 3, 2026
b8e571d
Bump Avalonia from 11.3.12 to 12.1.3 in the desktop sample
tjidde-nl Oct 3, 2026
f16f4dd
Write JSON lines with Utf8JsonWriter
tjidde-nl Oct 3, 2026
a9a9eaf
Bump Microsoft.Extensions.* patch versions in tests, samples and benc…
tjidde-nl Oct 3, 2026
3c868f9
Split Dependabot NuGet config into library/tests and samples/benchmarks
tjidde-nl Oct 3, 2026
d38a6d9
Document benchmark results
tjidde-nl Oct 3, 2026
5de6161
Merge branch 'worktree-agent-a2bad4555513a8611' into improvements
tjidde-nl Oct 3, 2026
b2030e4
Merge dependency updates
tjidde-nl Oct 3, 2026
e1923ee
Enforce clean CI builds
tjidde-nl Oct 3, 2026
19e41a3
Align documentation with current output
tjidde-nl Oct 3, 2026
82d0109
Ignore local Claude Code worktrees
tjidde-nl Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
root = true

[src/Tjidde.Logging/Extensions/MetricsLoggerExtensions.cs]
# These public extension methods intentionally forward the caller's message template and arguments to ILogger.
# LoggerMessage delegates require fixed templates and would change the API's intended behavior.
dotnet_diagnostic.CA1848.severity = none
dotnet_diagnostic.CA2254.severity = none
31 changes: 23 additions & 8 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
version: 2

updates:
# Library and tests.
- package-ecosystem: "nuget"
directory: "/"
directories:
- "/src/*"
- "/tests/*"
schedule:
interval: "weekly"
groups:
microsoft-extensions:
patterns:
- "Microsoft.Extensions.*"
test-packages:
patterns:
- "xunit*"
Expand All @@ -17,11 +17,11 @@ updates:
ignore:
# The library (and the test project) reference Microsoft.Extensions.* with one major per
# target framework on purpose: 7.0.x for net7.0, 8.0.x for net8.0, 9.0.x for net9.0 and
# 10.0.x for net10.0. A higher major does not support the older TFM (or would force
# consumers on that TFM onto a newer Microsoft.Extensions line), so only allow
# minor/patch updates within each major. Raise majors by hand when adding a new TFM.
# 10.0.x for net10.0, with deliberately low minimum versions in the library. Dependabot
# takes the highest version in a file as the current one and rewrites every occurrence,
# which collapses the per-TFM ItemGroups, so a semver-major ignore is not enough.
# The per-TFM versions are updated by hand.
- dependency-name: "Microsoft.Extensions.*"
update-types: ["version-update:semver-major"]
# net7.0 in the test project is pinned to the last releases that still support it:
# Microsoft.NET.Test.Sdk 17.13.0 (17.14+ requires net8.0) and xunit.runner.visualstudio
# 3.0.2 (3.1+ requires net8.0). The net8.0+ references are already on newer majors, so
Expand All @@ -35,6 +35,21 @@ updates:
- dependency-name: "xunit.runner.visualstudio"
update-types: ["version-update:semver-major"]

# Samples and benchmarks: single TFM (net10.0), so all packages can follow the latest release.
- package-ecosystem: "nuget"
directories:
- "/samples/*"
- "/benchmarks/*"
schedule:
interval: "weekly"
groups:
microsoft-extensions:
patterns:
- "Microsoft.Extensions.*"
avalonia:
patterns:
- "Avalonia*"

- package-ecosystem: "github-actions"
directory: "/"
schedule:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
run: dotnet restore TjiddeLogger.sln

- name: Build
run: dotnet build TjiddeLogger.sln --configuration Release --no-restore -p:Version=${{ steps.version.outputs.version }} -p:ContinuousIntegrationBuild=true
run: dotnet build TjiddeLogger.sln --configuration Release --no-restore -p:Version=${{ steps.version.outputs.version }}

# Runs once per target framework (net7.0, net8.0, net9.0, net10.0)
- name: Test
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ desktop.ini

# Logs
*.log

# BenchmarkDotNet results
BenchmarkDotNet.Artifacts/

# Claude Code worktrees and local state
.claude/
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and

- `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
- 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.
Expand All @@ -20,6 +26,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
- `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.
Expand Down
11 changes: 11 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
</PropertyGroup>

<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(ContinuousIntegrationBuild)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<Deterministic>true</Deterministic>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
56 changes: 43 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Tjidde.Logging

[![NuGet](https://img.shields.io/nuget/v/Tjidde.Logging.svg)](https://www.nuget.org/packages/Tjidde.Logging)
[![CI](https://github.com/tjidde-nl/TjiddeLogger/actions/workflows/ci.yml/badge.svg)](https://github.com/tjidde-nl/TjiddeLogger/actions/workflows/ci.yml)

A reusable NuGet package for standardized, structured logging in .NET applications.
Integrates naturally with the Microsoft.Extensions.Logging pipeline.

Expand Down Expand Up @@ -35,7 +38,7 @@ dotnet add package Tjidde.Logging
Or via PackageReference:

```xml
<PackageReference Include="Tjidde.Logging" Version="1.0.0" />
<PackageReference Include="Tjidde.Logging" Version="*" />
```

---
Expand Down Expand Up @@ -116,7 +119,7 @@ CustomerContext.Set("AcmeCorp");

// Later in any service in the same async flow:
_logger.LogInformation("Order placed successfully.");
// Output: 2024-03-17: 14:22:01: [INF] OrderService: AcmeCorp: 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();
Expand Down Expand Up @@ -161,7 +164,7 @@ public class OrderService

Output:
```
2024-03-17: 14:22:01: [INF] OrderService: AcmeCorp: Placing order 42
2024-03-17: 14:22:01: [INFORMATION] Class=>OrderService: Client=>AcmeCorp: Placing order 42
```

### Logging an exception
Expand All @@ -179,18 +182,40 @@ catch (Exception ex)

Output:
```
2024-03-17: 14:22:01: [ERR] OrderService: AcmeCorp: Failed to process order 42
Exception Type : System.InvalidOperationException
Message : Payment gateway timeout
Stack Trace :
at OrderService.ProcessPayment() in OrderService.cs:line 42
Inner Exception:
-> Exception Type : System.TimeoutException
-> Message : The operation timed out
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.

```csharp
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>();
```

```csharp
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 `Write` fast 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).
- `InMemoryLogSink` keeps the newest `Capacity` entries (default 1000), with `GetSnapshot()`, `Count`, `Clear()` and the events `EntryAdded` and `Cleared`. 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

```
Expand All @@ -210,6 +235,7 @@ YYYY-MM-DD: HH:mm:ss: [LEVEL] Class=>ClassName Method=>MethodName: Client=>Custo

| 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) |
Expand Down Expand Up @@ -260,8 +286,7 @@ Values that are only known at runtime (a token from a vault, a customer code) ca
The following capabilities are **intentionally not implemented** in v1.0 but the package is designed to support them:

- **Method argument logging** — The `ICustomerContextAccessor` and scope design allow future decorator/interceptor-based enrichment. Attribute-based exclusion (`[SensitiveArgument]`) can be layered on top.
- **Sink abstraction** — Currently writes to Console. A future `ITjiddeLogSink` abstraction can route output to files, databases, or external systems.
- **Structured output formats** — JSON or OTLP output can be added as alternative formatters.
- **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.

---
Expand All @@ -280,8 +305,13 @@ Tjidde.Logging/
│ ├── 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)
Expand Down
17 changes: 17 additions & 0 deletions TjiddeLogger.sln
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tjidde.Logging.DesktopApp",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tjidde.Logging.TuiApp", "samples\Tjidde.Logging.TuiApp\Tjidde.Logging.TuiApp.csproj", "{CA63A14A-CC92-4DD3-A55D-3644AA99D31D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{66320409-64EC-F7C5-3DEF-65E7510DAAD1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tjidde.Logging.Benchmarks", "benchmarks\Tjidde.Logging.Benchmarks\Tjidde.Logging.Benchmarks.csproj", "{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -86,6 +90,18 @@ Global
{CA63A14A-CC92-4DD3-A55D-3644AA99D31D}.Release|x64.Build.0 = Release|Any CPU
{CA63A14A-CC92-4DD3-A55D-3644AA99D31D}.Release|x86.ActiveCfg = Release|Any CPU
{CA63A14A-CC92-4DD3-A55D-3644AA99D31D}.Release|x86.Build.0 = Release|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Debug|x64.ActiveCfg = Debug|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Debug|x64.Build.0 = Debug|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Debug|x86.ActiveCfg = Debug|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Debug|x86.Build.0 = Debug|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Release|Any CPU.Build.0 = Release|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Release|x64.ActiveCfg = Release|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Release|x64.Build.0 = Release|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Release|x86.ActiveCfg = Release|Any CPU
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -94,5 +110,6 @@ Global
{C8E0BBB7-9CA6-4551-B342-B7620289575E} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
{E97826C8-FAF8-4145-9FF5-E9FD5929390B} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
{CA63A14A-CC92-4DD3-A55D-3644AA99D31D} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
{2807FF2D-F8C0-4B7F-B8ED-DD7455DE394E} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1}
EndGlobalSection
EndGlobal
Loading
Loading