From f4fb29d2dec1690b6eb6d95e140cc2ae7a676f14 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 17:56:36 +0200 Subject: [PATCH 01/28] Add v2.0 namespace clean-up proposal Inventory of the public surface per namespace, proposed 2.0 layout (registration extensions in Microsoft.Extensions.Logging, core types in Tjidde.Logging), migration path and user migration guide. Co-Authored-By: Claude Opus 5.5 --- docs/proposals/v2-namespaces.md | 306 ++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 docs/proposals/v2-namespaces.md diff --git a/docs/proposals/v2-namespaces.md b/docs/proposals/v2-namespaces.md new file mode 100644 index 0000000..8eecb47 --- /dev/null +++ b/docs/proposals/v2-namespaces.md @@ -0,0 +1,306 @@ +# Proposal: public namespace clean-up for Tjidde.Logging v2.0 + +| | | +|---|---| +| Status | Draft / for discussion | +| Applies to | `src/Tjidde.Logging` (current version 1.0.1) | +| Target | 2.0.0 (breaking), with preparatory non-breaking steps in 1.x | +| Code changes in this proposal | None — this document only describes the plan | + +## 1. Motivation + +The current public surface is spread over six namespaces that mirror the folder layout +(`Context`, `Extensions`, `Formatting`, `Logging`, `Masking`, `Options`). That causes three problems: + +1. **`Tjidde.Logging.Logging` is redundant.** It contains a single public type (`TjiddeLoggerProvider`) and + reads as a stutter in fully-qualified names and docs (`Tjidde.Logging.Logging.TjiddeLoggerProvider`). +2. **`Tjidde.Logging.Options` shadows `Microsoft.Extensions.Options.Options`.** Any code whose own + namespace is `Tjidde.*` (our own tests and samples, and any consumer whose root namespace starts + with `Tjidde`) resolves the simple name `Options` to *our namespace* before it considers types imported + via `using Microsoft.Extensions.Options;`. `Options.Create(new TjiddeLoggerOptions())` then fails with + CS0234 ("The type or namespace name 'Create' does not exist in the namespace 'Tjidde.Logging.Options'"), + and the user has to write `Microsoft.Extensions.Options.Options.Create(...)` or add an alias. The + .NET Framework Design Guidelines explicitly advise against namespace names that collide with + well-known type names for this reason. +3. **Too many `using` directives for basic use.** A typical app that registers the logger, sets options in + code, sets the customer context and adds a masked key needs four usings: + + ```csharp + using Tjidde.Logging.Extensions; // AddTjiddeLogger, UseTjiddeJsonFormat, BeginMethodScope, LogMetrics + using Tjidde.Logging.Options; // TjiddeLoggerOptions, TjiddeLogOutputFormat + using Tjidde.Logging.Context; // CustomerContext + using Tjidde.Logging.Masking; // MaskedKeysContext + ``` + + Compare with first-party providers: `builder.Logging.AddConsole()` needs no extra using at all, because + `ConsoleLoggerExtensions` lives in `Microsoft.Extensions.Logging`. + +## 2. Inventory of the current public surface (v1.0.1) + +Internal types are listed for completeness because they can be moved freely (tests use +`InternalsVisibleTo("Tjidde.Logging.Tests")`). + +| Namespace | Type | Kind | Visibility | Typical consumer | +|---|---|---|---|---| +| `Tjidde.Logging.Context` | `CustomerContext` | static class (`Current`, `Set`, `Clear`) | public | app code (middleware) | +| | `ICustomerContextAccessor` | interface | public | extensibility (DI replacement) | +| | `AsyncLocalCustomerContextAccessor` | sealed class | public | default implementation | +| `Tjidde.Logging.Extensions` | `TjiddeLoggingBuilderExtensions` | static class: `AddTjiddeLogger` (4 overloads), `UseTjiddeJsonFormat`, `UseTjiddeTextFormat` on `ILoggingBuilder` | public | `Program.cs` | +| | `MethodScopeLoggerExtensions` | static class: `BeginMethodScope(this ILogger, ...)` | public | app code | +| | `MetricsLoggerExtensions` | static class: `Metrics` (`LogLevel` field), `LogMetrics(this ILogger, ...)` (3 overloads) | public | app code | +| `Tjidde.Logging.Formatting` | `IExceptionFormatter` | interface | public | not pluggable today (see §3.3) | +| | `ExceptionFormatter` | sealed class | public | not pluggable today | +| `Tjidde.Logging.Logging` | `TjiddeLoggerProvider` | sealed class (`ILoggerProvider`, `ISupportExternalScope`, `[ProviderAlias("Tjidde")]`) | public | rarely referenced directly (DI, `AddFilter`) | +| | `TjiddeLogger` | sealed class | internal | — | +| | `ConsoleLogProcessor` | sealed class | internal | — | +| `Tjidde.Logging.Masking` | `MaskedKeysContext` | static class (`Add`, `Remove`, `Clear`, `GetKeys`) | public | app code | +| | `IMaskedKeysAccessor` | interface | public | extensibility (DI replacement) | +| | `GlobalMaskedKeysAccessor` | sealed class | public | default implementation | +| | `ISensitiveDataMasker` | interface | public | not pluggable today | +| | `SensitiveDataMasker` | sealed class | public | not pluggable today | +| | `MessageTemplateRenderer` | static class | internal | — | +| `Tjidde.Logging.Options` | `TjiddeLoggerOptions` | sealed class | public | `Program.cs`, `appsettings.json` binding | +| | `TjiddeLogOutputFormat` | enum (`Text`, `Json`) | public | `Program.cs` | + +Notes: + +- The root namespace `Tjidde.Logging` currently contains **no types**. +- The configuration section name (`"TjiddeLogger"`) and the provider alias (`"Tjidde"`) are not namespace + dependent and are **not** changed by this proposal; `appsettings.json` files keep working. + +## 3. Proposed layout for 2.0 + +### 3.1 Principles + +1. **Registration extensions on `ILoggingBuilder` live in `Microsoft.Extensions.Logging`.** This is the + established convention (`AddConsole`, `AddDebug`, `AddEventLog`, `AddApplicationInsights`, + `AddOpenTelemetry`, ...). `Program.cs` already has that namespace via implicit usings, so registration + needs zero extra usings. +2. **Everything an application touches in normal use lives in the root namespace `Tjidde.Logging`:** + options, the output-format enum, the provider, the two static "context" facades and the `ILogger` + convenience extensions. One `using Tjidde.Logging;` (or a single `global using`) covers all day-to-day use. +3. **Extensibility points and implementation details live in descriptive sub-namespaces** that most + users never import: `Tjidde.Logging.Context`, `Tjidde.Logging.Masking`, `Tjidde.Logging.Formatting`. +4. **No namespace may share its last segment with a well-known BCL / Microsoft.Extensions type or + namespace** (`Options`, `Logging`, `Configuration`, `Extensions`, ...). +5. **`ILogger` extension methods are *not* put in `Microsoft.Extensions.Logging`.** Unlike the + registration methods, `BeginMethodScope` and `LogMetrics` would then appear in IntelliSense on every + `ILogger` in every project that references the package (including transitively), which is intrusive and + increases the chance of name collisions with other libraries. They go into `Tjidde.Logging`, which apps + import anyway. + +### 3.2 Target mapping + +| Type (v1) | v1 namespace | **v2 namespace** | Remarks | +|---|---|---|---| +| `TjiddeLoggingBuilderExtensions` | `Tjidde.Logging.Extensions` | **`Microsoft.Extensions.Logging`** | Same class name and signatures. | +| `TjiddeLoggerOptions` | `Tjidde.Logging.Options` | **`Tjidde.Logging`** | | +| `TjiddeLogOutputFormat` | `Tjidde.Logging.Options` | **`Tjidde.Logging`** | | +| `TjiddeLoggerProvider` | `Tjidde.Logging.Logging` | **`Tjidde.Logging`** | | +| `CustomerContext` | `Tjidde.Logging.Context` | **`Tjidde.Logging`** | Static facade used from app code/middleware. | +| `MaskedKeysContext` | `Tjidde.Logging.Masking` | **`Tjidde.Logging`** | Static facade used from app code. | +| `MethodScopeLoggerExtensions` | `Tjidde.Logging.Extensions` | **`Tjidde.Logging`** | | +| `MetricsLoggerExtensions` | `Tjidde.Logging.Extensions` | **`Tjidde.Logging`** | Optionally also expose the level as `TjiddeLogLevel.Metrics` (const) — out of scope here. | +| `ICustomerContextAccessor` | `Tjidde.Logging.Context` | `Tjidde.Logging.Context` (unchanged) | Extensibility point. | +| `AsyncLocalCustomerContextAccessor` | `Tjidde.Logging.Context` | `Tjidde.Logging.Context` (unchanged) | | +| `IMaskedKeysAccessor` | `Tjidde.Logging.Masking` | `Tjidde.Logging.Masking` (unchanged) | | +| `GlobalMaskedKeysAccessor` | `Tjidde.Logging.Masking` | `Tjidde.Logging.Masking` (unchanged) | | +| `ISensitiveDataMasker` | `Tjidde.Logging.Masking` | `Tjidde.Logging.Masking` (unchanged) | See §3.3. | +| `SensitiveDataMasker` | `Tjidde.Logging.Masking` | `Tjidde.Logging.Masking` (unchanged) | See §3.3. | +| `IExceptionFormatter` | `Tjidde.Logging.Formatting` | `Tjidde.Logging.Formatting` (unchanged) | See §3.3. | +| `ExceptionFormatter` | `Tjidde.Logging.Formatting` | `Tjidde.Logging.Formatting` (unchanged) | See §3.3. | +| `TjiddeLogger` (internal) | `Tjidde.Logging.Logging` | `Tjidde.Logging.Internal` | Internal, no public impact. | +| `ConsoleLogProcessor` (internal) | `Tjidde.Logging.Logging` | `Tjidde.Logging.Internal` | Internal, no public impact. | +| `MessageTemplateRenderer` (internal) | `Tjidde.Logging.Masking` | `Tjidde.Logging.Masking` or `.Internal` | Internal, no public impact. | + +Resulting public namespaces in 2.0: + +``` +Microsoft.Extensions.Logging TjiddeLoggingBuilderExtensions +Tjidde.Logging TjiddeLoggerOptions, TjiddeLogOutputFormat, TjiddeLoggerProvider, + CustomerContext, MaskedKeysContext, + MethodScopeLoggerExtensions, MetricsLoggerExtensions +Tjidde.Logging.Context ICustomerContextAccessor, AsyncLocalCustomerContextAccessor +Tjidde.Logging.Masking IMaskedKeysAccessor, GlobalMaskedKeysAccessor, + ISensitiveDataMasker, SensitiveDataMasker +Tjidde.Logging.Formatting IExceptionFormatter, ExceptionFormatter +``` + +`Tjidde.Logging.Extensions`, `Tjidde.Logging.Logging` and `Tjidde.Logging.Options` disappear. + +Resulting `Program.cs` for the common case: + +```csharp +using Tjidde.Logging; // only needed for options/enums/facades + +builder.Logging.ClearProviders(); +builder.Logging.AddTjiddeLogger(o => o.OutputFormat = TjiddeLogOutputFormat.Json); +``` + +and for the minimal case (`builder.Logging.AddTjiddeLogger();` or binding from `appsettings.json`), +**no Tjidde using at all**. + +### 3.3 Side issue found during the inventory: non-pluggable public types + +`ISensitiveDataMasker`/`SensitiveDataMasker` and `IExceptionFormatter`/`ExceptionFormatter` are public, +but `TjiddeLoggerProvider` constructs them itself (`new SensitiveDataMasker(...)`, +`new ExceptionFormatter(...)`) instead of resolving them from DI. Registering a custom implementation +therefore has no effect. Because 2.0 is a breaking release anyway, decide one of: + +- **(a) make them pluggable** (resolve from DI with `TryAdd` defaults, as already done for + `ICustomerContextAccessor`/`IMaskedKeysAccessor`), or +- **(b) make them internal** (and move to `Tjidde.Logging.Internal`), shrinking the public surface. + +Recommendation: (a) for `IExceptionFormatter` (a realistic customisation), (b) or (a) for the masker +depending on whether masking is meant to be replaceable. This decision is independent of the namespace +move but should land in the same major version. + +### 3.4 Alternatives considered + +| Alternative | Why not chosen | +|---|---| +| Rename `Tjidde.Logging.Options` → `Tjidde.Logging.Configuration` and keep the rest | Solves the clash but still requires 3–4 usings; `Configuration` collides with `Microsoft.Extensions.Configuration` in the same way. | +| Put *everything* (including accessors, masker, formatter) in `Tjidde.Logging` | Simplest, but mixes rarely-used extensibility types into the namespace everyone imports. Acceptable fallback if the sub-namespaces feel like overkill for ~6 types. | +| Put the `ILogger` extensions (`BeginMethodScope`, `LogMetrics`) in `Microsoft.Extensions.Logging` too | Pollutes IntelliSense on every `ILogger` for every consumer; see principle 5. | +| Put registration extensions in `Microsoft.Extensions.DependencyInjection` | They extend `ILoggingBuilder`, not `IServiceCollection`; the logging-provider convention is `Microsoft.Extensions.Logging`. | + +## 4. Migration path + +### 4.1 Why the type moves cannot be done additively in 1.x + +- **Moving a type to another namespace is both source- and binary-breaking.** The CLR identifies a type by + namespace + name; `[TypeForwardedTo]` only forwards to *another assembly* with the *same* full name, so it + cannot express a namespace rename inside one assembly. +- **We cannot keep a copy of a class in both namespaces.** Two distinct `TjiddeLoggerOptions` types would + break `IOptions` (DI keys on the exact type) and confuse every user who imports both. +- **Duplicating the extension methods causes ambiguity (CS0121).** If 1.x shipped + `Microsoft.Extensions.Logging.TjiddeLoggingBuilderExtensions` *next to* the existing + `Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions`, every existing user has both + `using Microsoft.Extensions.Logging;` (implicit usings) and `using Tjidde.Logging.Extensions;` in scope. + Both classes are found at the same lookup level, so `builder.Logging.AddTjiddeLogger(...)` becomes + *"The call is ambiguous between ... and ..."* — a compile break in a minor version. Marking the old one + `[Obsolete]` does not help: obsolete members still participate in overload resolution. + The same applies to duplicating `BeginMethodScope`/`LogMetrics` into `Tjidde.Logging` for users who + import both `Tjidde.Logging` and `Tjidde.Logging.Extensions`. + (Additive duplicates *with different method names* would avoid the ambiguity, but would introduce a + second, permanent name for the same API just to retire it again — not worth it.) + +### 4.2 What can be done in 1.x (non-breaking) + +| Step | Version | Change | +|---|---|---| +| 1 | 1.1 | Move the **internal** types (`TjiddeLogger`, `ConsoleLogProcessor`) to `Tjidde.Logging.Internal`. Public types stay put. This makes `Tjidde.Logging.Logging` contain only `TjiddeLoggerProvider` and shrinks the 2.0 diff. | +| 2 | 1.1 | Rename the samples' own namespaces away from `Tjidde.Logging.*` (e.g. `TjiddeSamples.DemoApp`) so they model what consumers see and stop being affected by the `Options` shadowing. | +| 3 | 1.1 | Docs (`wiki-en.md`, `wiki-nl.md`, `AI-INTEGRATION.md`, `README.md`): add an "Upcoming in 2.0" note with the mapping table, recommend a single `global using` file (see §5.3) so the eventual migration touches one file, and document the workaround for the `Options` clash: `using MsOptions = Microsoft.Extensions.Options.Options;`. | +| 4 | 1.1 | Decide §3.3 (pluggable vs. internal masker/formatter). If "pluggable" is chosen, resolving them from DI with `TryAdd` defaults is additive and can ship in 1.x already. | +| 5 | 1.x (last minor) | Optionally add `[Obsolete("Moves to namespace X in 2.0. See docs/proposals/v2-namespaces.md")]` to the types that will move. **Not recommended**: the warning cannot be fixed until 2.0 exists, and it breaks builds with `TreatWarningsAsErrors`. Prefer the release note + docs. | + +### 4.3 What has to wait for 2.0 (breaking) + +1. Move `TjiddeLoggingBuilderExtensions` to `Microsoft.Extensions.Logging`. +2. Move `TjiddeLoggerOptions`, `TjiddeLogOutputFormat`, `TjiddeLoggerProvider`, `CustomerContext`, + `MaskedKeysContext`, `MethodScopeLoggerExtensions`, `MetricsLoggerExtensions` to `Tjidde.Logging`. +3. Delete the namespaces `Tjidde.Logging.Extensions`, `Tjidde.Logging.Logging`, `Tjidde.Logging.Options`. +4. Apply the §3.3 decision (if it makes types internal, that is breaking too). +5. Update tests, samples, wiki (EN + NL), `AI-INTEGRATION.md`, `README.md`, `CHANGELOG.md` + (a "Breaking changes" section linking to the migration guide below). +6. Bump `` to `2.0.0`. Consider adding `Microsoft.CodeAnalysis.PublicApiAnalyzers` + (`PublicAPI.Shipped.txt`) at the same time so future public-surface changes are deliberate. + +A note on leftover `using` lines: after the upgrade, a remaining `using Tjidde.Logging.Options;` produces +CS0246 because the namespace no longer exists. That is intended — it points the user exactly at the line +to change — and the fix is mechanical (§5). + +## 5. Migration guide for users (1.x → 2.0) + +*This section is written so it can be copied into the 2.0 release notes / wiki.* + +### 5.1 TL;DR + +1. Remove `using Tjidde.Logging.Extensions;`, `using Tjidde.Logging.Options;` and `using Tjidde.Logging.Logging;`. +2. Add `using Tjidde.Logging;` where you use options, `TjiddeLogOutputFormat`, `CustomerContext`, + `MaskedKeysContext`, `BeginMethodScope` or `LogMetrics`. +3. `AddTjiddeLogger(...)`, `UseTjiddeJsonFormat()` and `UseTjiddeTextFormat()` now work with only + `using Microsoft.Extensions.Logging;` (included in the default implicit usings of ASP.NET Core and + Worker/Console templates). +4. Keep `using Tjidde.Logging.Context;` / `using Tjidde.Logging.Masking;` / `using Tjidde.Logging.Formatting;` + **only** where you implement or reference the accessor/masker/formatter interfaces or their default + implementations. + +No changes are needed in `appsettings.json` (section `TjiddeLogger`, provider alias `Tjidde`). +Method names, signatures and behaviour are unchanged. + +### 5.2 Lookup table + +| You used | Replace with | +|---|---| +| `using Tjidde.Logging.Extensions;` (for `AddTjiddeLogger`, `UseTjidde*Format`) | nothing (`Microsoft.Extensions.Logging`) | +| `using Tjidde.Logging.Extensions;` (for `BeginMethodScope`, `LogMetrics`, `MetricsLoggerExtensions.Metrics`) | `using Tjidde.Logging;` | +| `using Tjidde.Logging.Options;` | `using Tjidde.Logging;` | +| `using Tjidde.Logging.Logging;` | `using Tjidde.Logging;` | +| `using Tjidde.Logging.Context;` for `CustomerContext` | `using Tjidde.Logging;` | +| `using Tjidde.Logging.Context;` for `ICustomerContextAccessor` / `AsyncLocalCustomerContextAccessor` | unchanged | +| `using Tjidde.Logging.Masking;` for `MaskedKeysContext` | `using Tjidde.Logging;` | +| `using Tjidde.Logging.Masking;` for accessor/masker types | unchanged | +| `using Tjidde.Logging.Formatting;` | unchanged | +| Fully-qualified `Tjidde.Logging.Options.TjiddeLoggerOptions` | `Tjidde.Logging.TjiddeLoggerOptions` | +| Fully-qualified `Tjidde.Logging.Logging.TjiddeLoggerProvider` (e.g. in `AddFilter`) | `Tjidde.Logging.TjiddeLoggerProvider` | +| Workaround `Microsoft.Extensions.Options.Options.Create(...)` / alias for the `Options` clash | can be reverted to `Options.Create(...)` | + +### 5.3 Recommended: one global using + +Create (or edit) `GlobalUsings.cs`: + +```csharp +global using Tjidde.Logging; +``` + +or in the project file: + +```xml + + + +``` + +### 5.4 Before / after + +**1.x** + +```csharp +using Tjidde.Logging.Context; +using Tjidde.Logging.Extensions; +using Tjidde.Logging.Masking; +using Tjidde.Logging.Options; + +builder.Logging.ClearProviders(); +builder.Logging.AddTjiddeLogger(o => o.OutputFormat = TjiddeLogOutputFormat.Json); +CustomerContext.Set("customer-42"); +MaskedKeysContext.Add("iban"); +``` + +**2.0** + +```csharp +using Tjidde.Logging; + +builder.Logging.ClearProviders(); +builder.Logging.AddTjiddeLogger(o => o.OutputFormat = TjiddeLogOutputFormat.Json); +CustomerContext.Set("customer-42"); +MaskedKeysContext.Add("iban"); +``` + +### 5.5 Binary compatibility + +2.0 is **not binary compatible** with 1.x. Libraries compiled against 1.x that reference moved types +(for example a shared library that calls `AddTjiddeLogger` or references `TjiddeLoggerOptions`) must be +recompiled against 2.0; otherwise they fail at runtime with `TypeLoadException` / `MissingMethodException`. +If your solution has several projects referencing Tjidde.Logging, upgrade them together. + +## 6. Open questions + +1. Sub-namespaces for `Context`/`Masking`/`Formatting`, or everything in `Tjidde.Logging` (§3.4)? +2. Masker/formatter: pluggable or internal (§3.3)? +3. Ship a 1.x release that announces the change (docs + release note only), or go straight to 2.0? +4. Should `MetricsLoggerExtensions.Metrics` get a friendlier home (e.g. `TjiddeLogLevel.Metrics`) in the same + major version? From d60447d293c2087d0a05e0bcb95f6e0d71989a74 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 17:58:37 +0200 Subject: [PATCH 02/28] Add package icon Adds assets/icon.svg (source) and a 128x128 assets/icon.png, and packs icon.png as the NuGet PackageIcon. Co-Authored-By: Claude Opus 5.5 --- assets/icon.png | Bin 0 -> 2401 bytes assets/icon.svg | 12 ++++++++++++ src/Tjidde.Logging/Tjidde.Logging.csproj | 2 ++ 3 files changed, 14 insertions(+) create mode 100644 assets/icon.png create mode 100644 assets/icon.svg diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d9508a300ed065e19f1670d43fb8b03a8a9412cd GIT binary patch literal 2401 zcmZuzc{J4B8~@H2%NQ|J3YoG-BScvn%h(NNUnWZoDeG9Lu~ddIiI8N=)*@vsQz+D+ zNcJT}_O&cSV(fm?``7R7y!V`YpZlD9&-vWvIiLGHpI9>!g9DsLIROATfHl;$U|RB@ z$-%~48#M4OOalqj##(VO$90a&w*dgkkJZ((3eH*__VTuz6<}Z9UmX;wfcblKV6o&V zcMi57vkJE5BXFHlf<<{fW}4nb&wT9eNkqq67#f_qc>iQXY|>+*V?Hrc>4If{$&=T& zZJtbK9#pa;jGrMuISE;?S32q0^C1j;OKR0yl?o>zG<|hDYB4Z}Ay3!TXjW~Sg&)l8 zoPRUS!)|lbA{3(_Xfb1)N9NeHa0w7?>^zJ(Za5~3-M~!UN+ChaFQg6=YwG3~?^NF` zKJ|{|o#F9&%_oI6nRJrXIj&MbN!AfKCKl3yfr2n>k z;F-q$LXE}~#_6PVK2q9wRZ)j~;yH8Ai{Goyl)K7|6`yp{>E58Gd^W;Sqy+{c_AaXbBkfsc|V)hnI}*(!gEwK;uF^Q?ulqfs@gKc zS7_(^z7j}vWcF96&Tzl@m_j}}l@|gThwEwi65+QrL7@%9ht)O@DCv4nVG3VsqBF6 z%~~lKxrdPG@*-9wd-PQ9N|7ozc}4}uoCMqxSn^ceKyw@v>jx&PKXxuXU>ytfq84_e zB(q10$^k1O^y9*yFW6az=c^E-8Y#|Ecb~ctCo#@)$Mob3sD0zdtsD;nm8jMC`rhix z65LKN&Q@oqqtN_ME&LcQ@+E1tHv;R!u%sQ^{|EdR`hPp^p4V7j6uu6YRVv#$!$k8N zOx|wGO(>OHVq*#39AH=RpMTf=N(DejYv&aQDc-Dl%&RLuY9%9U^6*%@8v0V$&!G*s zHvgTSDf8cgG0Crj55dP_RL+(7rkZA<_}k0z1-yxEvKC5g?85bov~c;HA(TXxa0Fdu z^?6ZQaX@Bf?!!>qR`YL%*fD`fGKcNM-7A&LD5e>rq0vTkB0Blu@m zhwD~~w3-4+zS4Tf)|AKY9DVX`V#h>HnY@VNXYOcTiju7a9n}%YKb-w<)+8_>33t3eZPNEiK2(P1ejeoQn)_tj^?fD=s@^l2tGqV;B(t&TH`0<75uJG*1H>G zCJAL@)_2hnS<%5pDz2#|le z8l;r;blyB&3RNR+ODsL5eS6G3f)Urs{&?u<}6+iZ*bvXA1&_P zO`UCZ2e0)8QOJV8ef{G7egFE6t8qf~KF5!5QhTd=7u-*V*WU(pz+yWQRGr#gh6UVc+l70pds~^ZQyO+>yh*T z$cnb})#;UpCLL~eKE96dXmV`0HRJ-qudph3?-Zirz=QeWbG7K+RUT-oktgXq3amIN zoshrhSZznq(Bzsm{UW57YYub%%`z@61Bb`F42^tXJuB)&<*L+XG!IHp3Z7bnolK1= z=TUKabcj?DMg4^5003ZY+cK!!rdTTE{h7f4>HIZ}v*pWxeY(D#b8Fd$s>-yIxil3H z+74`}{5j$g)^J{VkYRN<^yTS7x~7|L3SYh4NHQ%siugH2WGwQl)ce783t|thMW1Nn z_p + Tjidde.Logging + + + + + + + + + + diff --git a/src/Tjidde.Logging/Tjidde.Logging.csproj b/src/Tjidde.Logging/Tjidde.Logging.csproj index db3072c..ac76442 100644 --- a/src/Tjidde.Logging/Tjidde.Logging.csproj +++ b/src/Tjidde.Logging/Tjidde.Logging.csproj @@ -17,6 +17,7 @@ Standardized, structured logging for .NET applications. Integrates with Microsoft.Extensions.Logging and provides consistent output, customer context, sensitive data masking, and improved exception formatting. logging;tjidde;structured-logging;microsoft-extensions-logging README.md + icon.png Apache-2.0 https://github.com/tjidde-nl/TjiddeLogger git @@ -64,6 +65,7 @@ + From e41ad3a69cd4c0d55c12363fa7dfa5ab711755bd Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 17:58:57 +0200 Subject: [PATCH 03/28] Test all target frameworks (net7.0-net10.0) in CI The test project only targeted net10.0, so the library builds for net7.0, net8.0 and net9.0 (each with its own Microsoft.Extensions.* major) were never tested. - Multi-target the test project net7.0;net8.0;net9.0;net10.0 with per-TFM Microsoft.Extensions.* versions matching the library. - Install the 7.0, 8.0, 9.0 and 10.0 runtimes in ci.yml and publish.yml so dotnet test runs every TFM. - ci.yml: write a .trx per TFM and upload them as the test-results artifact (also when tests fail). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 +++++++++++-- .github/workflows/publish.yml | 8 ++++- .../Tjidde.Logging.Tests.csproj | 30 +++++++++++++++++-- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fdd588..cb791e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,12 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.0.x + # All runtimes: the tests run on every target framework of the library + dotnet-version: | + 7.0.x + 8.0.x + 9.0.x + 10.0.x - name: Restore run: dotnet restore TjiddeLogger.sln @@ -31,8 +36,19 @@ jobs: - name: Build run: dotnet build TjiddeLogger.sln --configuration Release --no-restore + # Runs once per target framework (net7.0, net8.0, net9.0, net10.0); one .trx file per framework - name: Test - run: dotnet test tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj --configuration Release --no-build + run: >- + dotnet test tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj --configuration Release --no-build + --logger "trx;LogFilePrefix=test-results" --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: TestResults/*.trx + if-no-files-found: warn - name: Pack run: dotnet pack src/Tjidde.Logging/Tjidde.Logging.csproj --configuration Release --no-build --output artifacts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8739ebe..695f346 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,7 +26,12 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.0.x + # All runtimes: the tests run on every target framework of the library + dotnet-version: | + 7.0.x + 8.0.x + 9.0.x + 10.0.x - name: Read version from tag id: version @@ -51,6 +56,7 @@ jobs: - name: Build run: dotnet build TjiddeLogger.sln --configuration Release --no-restore -p:Version=${{ steps.version.outputs.version }} -p:ContinuousIntegrationBuild=true + # Runs once per target framework (net7.0, net8.0, net9.0, net10.0) - name: Test run: dotnet test tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj --configuration Release --no-build diff --git a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj index 9d2b64a..332a6c9 100644 --- a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj +++ b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj @@ -1,7 +1,8 @@ - net10.0 + + net7.0;net8.0;net9.0;net10.0 enable latest enable @@ -15,11 +16,36 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + + + + + + + + + + + + + + + + + + + + + + + + - From 8561aec895a24b323667acc705eec73c1f79bc59 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 18:02:43 +0200 Subject: [PATCH 04/28] Add Dependabot and update test packages - .github/dependabot.yml: weekly NuGet updates (grouped: Microsoft.Extensions.* and test packages) and weekly GitHub Actions updates. Semver-major updates of Microsoft.Extensions.* are ignored because each TFM deliberately uses its own major. - Test packages: xunit 2.6.6 -> 2.9.3 (last v2). net8.0+: Microsoft.NET.Test.Sdk 17.8.0 -> 18.10.1 and xunit.runner.visualstudio 2.5.6 -> 4.0.0. net7.0 stays on the last versions that support it: Microsoft.NET.Test.Sdk 17.13.0 and xunit.runner.visualstudio 3.0.2; Dependabot ignores the ranges that would drop net7.0. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 41 +++++++++++++++++++ .../Tjidde.Logging.Tests.csproj | 25 +++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e41a488 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +version: 2 + +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + groups: + microsoft-extensions: + patterns: + - "Microsoft.Extensions.*" + test-packages: + patterns: + - "xunit*" + - "Microsoft.NET.Test.Sdk" + - "FluentAssertions" + 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. + - 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 + # block the ranges that would break net7.0 and bump majors manually. + - dependency-name: "Microsoft.NET.Test.Sdk" + versions: [">= 17.14.0, < 18.0.0"] + - dependency-name: "Microsoft.NET.Test.Sdk" + update-types: ["version-update:semver-major"] + - dependency-name: "xunit.runner.visualstudio" + versions: [">= 3.1.0, < 4.0.0"] + - dependency-name: "xunit.runner.visualstudio" + update-types: ["version-update:semver-major"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj index 332a6c9..392a87d 100644 --- a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj +++ b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj @@ -10,13 +10,30 @@ - - - + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - From a12f3bac100b03b61c769a7675835201590fafa2 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 18:02:50 +0200 Subject: [PATCH 05/28] Use only the current version's changelog section as release notes PackageReleaseNotes used to contain the whole CHANGELOG.md. A target that runs before GenerateNuspec now extracts only the "## []" section (up to the next "## [" heading) and appends a link to the full changelog. If the section is missing, only the link is used and the build does not fail. Works with -p:Version=... as passed by CI. Co-Authored-By: Claude Opus 5.5 --- src/Tjidde.Logging/Tjidde.Logging.csproj | 26 ++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Tjidde.Logging/Tjidde.Logging.csproj b/src/Tjidde.Logging/Tjidde.Logging.csproj index ac76442..173f791 100644 --- a/src/Tjidde.Logging/Tjidde.Logging.csproj +++ b/src/Tjidde.Logging/Tjidde.Logging.csproj @@ -24,10 +24,32 @@ https://github.com/tjidde-nl/TjiddeLogger Copyright © 2026 Tjidde Nieuwenhuizen false - - $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\..\CHANGELOG.md')) + + + + + <_ChangelogPath>$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '..', '..', 'CHANGELOG.md')) + <_ChangelogUrl>https://github.com/tjidde-nl/TjiddeLogger/blob/main/CHANGELOG.md + <_ChangelogText> + <_ChangelogText Condition="Exists('$(_ChangelogPath)')">$([System.IO.File]::ReadAllText('$(_ChangelogPath)')) + <_ChangelogText>$(_ChangelogText.Replace(' ', '')) + <_ChangelogVersionPattern>(?m)^## \[$([System.Text.RegularExpressions.Regex]::Escape('$(PackageVersion)'))\][^\n]*\n(?:(?!## \[).*\n?)* + <_ChangelogSection>$([System.Text.RegularExpressions.Regex]::Match('$(_ChangelogText)', '$(_ChangelogVersionPattern)').Value.Trim()) + Full changelog: $(_ChangelogUrl) + $(_ChangelogSection) + +Full changelog: $(_ChangelogUrl) + + + From fc3cd235cb8107eb2d8bd989dd6dc02156d70959 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 18:03:11 +0200 Subject: [PATCH 06/28] Log metrics at Information with a Metrics event instead of (LogLevel)10 LogMetrics used the custom level (LogLevel)10, which also reached other providers; the Microsoft console formatters throw ArgumentOutOfRangeException for it, so LogMetrics crashed as soon as AddConsole() was registered. LogMetrics now logs at LogLevel.Information with MetricsEventId (Id 10000, Name "Metrics"). TjiddeLogger shows entries with that event name, and the old (LogLevel)10, as METRICS in magenta and never drops them through its own minimum level. MetricsLoggerExtensions.Metrics is marked [Obsolete]. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +++ README.md | 1 + docs/AI-INTEGRATION.md | 4 +- docs/wiki-en.md | 31 +++--- docs/wiki-nl.md | 31 +++--- .../Extensions/MetricsLoggerExtensions.cs | 53 +++++++-- src/Tjidde.Logging/Logging/TjiddeLogger.cs | 27 +++-- .../LogOutputFormatTests.cs | 47 +++++++- .../MetricsLoggingTests.cs | 105 ++++++++++++++++++ .../Tjidde.Logging.Tests.csproj | 1 + 10 files changed, 262 insertions(+), 52 deletions(-) create mode 100644 tests/Tjidde.Logging.Tests/MetricsLoggingTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e379f89..b34282b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to Tjidde.Logging are listed here, newest first. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### 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. + +### Changed +- `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. + +### Fixed +- `LogMetrics` threw an `ArgumentOutOfRangeException` when another provider, such as the Microsoft console logger (`AddConsole()`), was also registered. + +### Deprecated +- `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. + ## [1.0.1] - 2026-10-02 ### Added diff --git a/README.md b/README.md index 6789fa2..8e78b81 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ YYYY-MM-DD: HH:mm:ss: [LEVEL] Class=>ClassName Method=>MethodName: Client=>Custo ``` - `LEVEL` is one of: `TRACE`, `DEBUG`, `INFORMATION`, `WARNING`, `ERROR`, `CRITICAL`, `METRICS` +- `METRICS` is shown for entries written with `_logger.LogMetrics(...)`. They are logged at `LogLevel.Information` with event `Metrics` (id 10000), so other providers such as `AddConsole()` see a normal information entry. For real application metrics, use `System.Diagnostics.Metrics`. - `Method=>` is included when set via `using (_logger.BeginMethodScope())` (or a scope with key `MethodName`) - `Client=>` is omitted when no customer context is active diff --git a/docs/AI-INTEGRATION.md b/docs/AI-INTEGRATION.md index 288539c..245e60d 100644 --- a/docs/AI-INTEGRATION.md +++ b/docs/AI-INTEGRATION.md @@ -184,7 +184,7 @@ builder.Services.AddSingletonClassName Method=>MethodName: Client=>Customer: Message [Masked: Key1, Key2] | Exception: … | Scopes: a > b ``` -Levels: `TRACE`, `DEBUG`, `INFORMATION`, `WARNING`, `ERROR`, `CRITICAL`, `METRICS`. Timestamps are local time. Each level gets its own color unless the output is redirected. +Levels: `TRACE`, `DEBUG`, `INFORMATION`, `WARNING`, `ERROR`, `CRITICAL`, `METRICS` (`METRICS` = an `Information` entry with event name `Metrics`). Timestamps are local time. Each level gets its own color unless the output is redirected. JSON (`OutputFormat: Json`), meant for log shippers such as Elasticsearch or Loki. Shown formatted here; the real output is one object per line, with standard JSON escaping (`+` can appear as `+`): diff --git a/docs/wiki-en.md b/docs/wiki-en.md index 74e6a8b..7b1f6c7 100644 --- a/docs/wiki-en.md +++ b/docs/wiki-en.md @@ -11,7 +11,7 @@ 7. [Sensitive Data Masking](#sensitive-data-masking) 8. [Exception Formatting](#exception-formatting) 9. [Scopes](#scopes) -10. [Metrics Log Level](#metrics-log-level) +10. [Metrics Entries](#metrics-entries) 11. [CI/CD (GitHub Actions)](#cicd-github-actions) 12. [Future Work](#future-work) @@ -26,7 +26,7 @@ - Compact single-line exception formatting including inner exceptions and stack traces. - Customer context propagation via `AsyncLocal` — safe for async/await and multi-tenant scenarios. - Scope support for structured logging. -- Custom `Metrics` log level for recording application metrics alongside regular log output. +- `LogMetrics` for metric-style entries, shown as `[METRICS]` alongside regular log output. - Targets .NET 7, 8, 9, and 10. --- @@ -131,7 +131,7 @@ YYYY-MM-DD: HH:mm:ss: [LEVEL] CS=>ClassName Method=>MethodName: Client=>Customer | Warning | `[WARNING]` | | Error | `[ERROR]` | | Critical | `[CRITICAL]` | -| Metrics *(custom)* | `[METRICS]` | +| Metrics *(Information + event `Metrics`)* | `[METRICS]` | ### Console colours @@ -145,7 +145,7 @@ Each log level is printed in a distinct console colour for quick visual scanning | Warning | Yellow | | Error | Red | | Critical | Dark Red | -| Metrics *(custom)* | Magenta | +| Metrics *(Information + event `Metrics`)* | Magenta | --- @@ -455,11 +455,13 @@ builder.Logging.AddTjiddeLogger(options => --- -## Metrics Log Level +## Metrics Entries -Tjidde.Logging provides a custom `Metrics` log level for recording application metrics (counters, timings, gauges) alongside regular log output. Because `Microsoft.Extensions.Logging.LogLevel` is a sealed enum, the Metrics level is implemented as a strongly-typed constant with value `10`. +Tjidde.Logging provides `LogMetrics` for writing metric-style entries (counters, timings, gauges) alongside regular log output. A metrics entry is a normal `LogLevel.Information` entry with the event ID `MetricsLoggerExtensions.MetricsEventId` (`Id = 10000`, `Name = "Metrics"`). Tjidde.Logging recognizes that event and shows the entry as `[METRICS]`; other providers (for example `AddConsole()`) see an ordinary information entry. -### Using the Metrics level +> For real application metrics (dashboards, alerting, aggregation), use [`System.Diagnostics.Metrics`](https://learn.microsoft.com/dotnet/core/diagnostics/metrics) with OpenTelemetry or `dotnet-counters`. `LogMetrics` is meant for metric values you also want to see in the log. + +### Using LogMetrics Add the using directive and call `LogMetrics`: @@ -475,8 +477,9 @@ _logger.LogMetrics("response_time_ms={ResponseTime}", elapsed.TotalMilliseconds) // With an exception _logger.LogMetrics(ex, "payment_failures_total={Count}", failureCount); -// With an event ID -_logger.LogMetrics(new EventId(200, "Throughput"), "throughput_rps={Rps}", rps); +// With an event ID: an unnamed event ID gets the name "Metrics" and is shown as [METRICS]; +// an event ID with another name is passed on unchanged and shown as [INFORMATION]. +_logger.LogMetrics(new EventId(200), "throughput_rps={Rps}", rps); ``` ### Example output @@ -487,13 +490,13 @@ _logger.LogMetrics(new EventId(200, "Throughput"), "throughput_rps={Rps}", rps); The `[METRICS]` label is printed in **Magenta** in the console for easy visual distinction. -### Direct log level access +### Filtering -If you need the raw `LogLevel` value (e.g. for filtering configuration): +Tjidde.Logging's own `MinimumLevel` and `CategoryMinimumLevels` never drop metrics entries. Filters of `Microsoft.Extensions.Logging` itself (for example `Logging:LogLevel:Default` in `appsettings.json`) treat them as `Information`, so a category filtered to `Warning` or higher also filters its metrics entries. -```csharp -var metricsLevel = MetricsLoggerExtensions.Metrics; // (LogLevel)10 -``` +### The obsolete `Metrics` log level + +Earlier versions logged metrics at the custom level `MetricsLoggerExtensions.Metrics` (`(LogLevel)10`). That value is not a valid `LogLevel`: other providers reject it, and the Microsoft console formatters throw an `ArgumentOutOfRangeException`, so `LogMetrics` crashed as soon as `AddConsole()` was also registered. The field is now marked `[Obsolete]`. Tjidde.Logging still shows `(LogLevel)10` as `[METRICS]`, but use `LogMetrics(...)` (or `MetricsEventId`) instead. --- diff --git a/docs/wiki-nl.md b/docs/wiki-nl.md index 25fc4b8..3dcd956 100644 --- a/docs/wiki-nl.md +++ b/docs/wiki-nl.md @@ -11,7 +11,7 @@ 7. [Maskering van gevoelige gegevens](#maskering-van-gevoelige-gegevens) 8. [Uitzonderingsopmaak](#uitzonderingsopmaak) 9. [Scopes](#scopes) -10. [Metrics logniveau](#metrics-logniveau) +10. [Metrics-regels](#metrics-regels) 11. [CI/CD (GitHub Actions)](#cicd-github-actions) 12. [Toekomstige ontwikkelingen](#toekomstige-ontwikkelingen) @@ -26,7 +26,7 @@ - Compacte éénregelige uitzonderingsopmaak inclusief inner exceptions en stack traces. - Klantcontextpropagatie via `AsyncLocal` — veilig voor async/await en multi-tenant scenario's. - Scope-ondersteuning voor gestructureerde logging. -- Aangepast `Metrics` logniveau voor het vastleggen van applicatiemetrics naast reguliere logberichten. +- `LogMetrics` voor metric-achtige regels, weergegeven als `[METRICS]` naast reguliere logberichten. - Ondersteunt .NET 7, 8, 9 en 10. --- @@ -131,7 +131,7 @@ YYYY-MM-DD: HH:mm:ss: [NIVEAU] CS=>KlasseNaam Method=>MethodeNaam: Client=>Klant | Warning | `[WARNING]` | | Error | `[ERROR]` | | Critical | `[CRITICAL]` | -| Metrics *(aangepast)* | `[METRICS]` | +| Metrics *(Information + event `Metrics`)* | `[METRICS]` | ### Consolekleuren @@ -145,7 +145,7 @@ Elk logniveau wordt in een aparte consolekleur weergegeven voor snelle visuele h | Warning | Geel | | Error | Rood | | Critical | Donkerrood | -| Metrics *(aangepast)* | Magenta | +| Metrics *(Information + event `Metrics`)* | Magenta | --- @@ -455,11 +455,13 @@ builder.Logging.AddTjiddeLogger(options => --- -## Metrics logniveau +## Metrics-regels -Tjidde.Logging biedt een aangepast `Metrics` logniveau voor het vastleggen van applicatiemetrics (tellers, tijdmetingen, meters) naast reguliere logberichten. Omdat `Microsoft.Extensions.Logging.LogLevel` een sealed enum is, is het Metrics-niveau geïmplementeerd als een sterk getypeerde constante met waarde `10`. +Tjidde.Logging biedt `LogMetrics` voor het schrijven van metric-achtige regels (tellers, tijdmetingen, meters) naast reguliere logberichten. Een metrics-regel is een gewone `LogLevel.Information`-regel met het event-ID `MetricsLoggerExtensions.MetricsEventId` (`Id = 10000`, `Name = "Metrics"`). Tjidde.Logging herkent dat event en toont de regel als `[METRICS]`; andere providers (bijvoorbeeld `AddConsole()`) zien een gewone information-regel. -### Het Metrics-niveau gebruiken +> Gebruik voor echte applicatiemetrics (dashboards, alerting, aggregatie) [`System.Diagnostics.Metrics`](https://learn.microsoft.com/dotnet/core/diagnostics/metrics) met OpenTelemetry of `dotnet-counters`. `LogMetrics` is bedoeld voor metricwaarden die u ook in de log wilt zien. + +### LogMetrics gebruiken Voeg de using-directive toe en roep `LogMetrics` aan: @@ -475,8 +477,9 @@ _logger.LogMetrics("response_time_ms={ResponseTime}", elapsed.TotalMilliseconds) // Met een uitzondering _logger.LogMetrics(ex, "payment_failures_total={Count}", failureCount); -// Met een event-ID -_logger.LogMetrics(new EventId(200, "Throughput"), "throughput_rps={Rps}", rps); +// Met een event-ID: een event-ID zonder naam krijgt de naam "Metrics" en wordt als [METRICS] getoond; +// een event-ID met een andere naam wordt ongewijzigd doorgegeven en als [INFORMATION] getoond. +_logger.LogMetrics(new EventId(200), "throughput_rps={Rps}", rps); ``` ### Voorbeelduitvoer @@ -487,13 +490,13 @@ _logger.LogMetrics(new EventId(200, "Throughput"), "throughput_rps={Rps}", rps); Het label `[METRICS]` wordt in **Magenta** weergegeven in de console voor eenvoudige visuele herkenning. -### Directe toegang tot het logniveau +### Filteren -Als u de ruwe `LogLevel`-waarde nodig hebt (bijv. voor filterconfiguratie): +Het eigen `MinimumLevel` en `CategoryMinimumLevels` van Tjidde.Logging laten metrics-regels altijd door. Filters van `Microsoft.Extensions.Logging` zelf (bijvoorbeeld `Logging:LogLevel:Default` in `appsettings.json`) behandelen ze als `Information`; een categorie die op `Warning` of hoger staat, filtert dus ook haar metrics-regels. -```csharp -var metricsLevel = MetricsLoggerExtensions.Metrics; // (LogLevel)10 -``` +### Het verouderde `Metrics`-logniveau + +Eerdere versies logden metrics op het aangepaste niveau `MetricsLoggerExtensions.Metrics` (`(LogLevel)10`). Die waarde is geen geldig `LogLevel`: andere providers weigeren haar en de console-formatters van Microsoft gooien een `ArgumentOutOfRangeException`, waardoor `LogMetrics` crashte zodra ook `AddConsole()` geregistreerd was. Het veld is nu gemarkeerd als `[Obsolete]`. Tjidde.Logging toont `(LogLevel)10` nog steeds als `[METRICS]`, maar gebruik in plaats daarvan `LogMetrics(...)` (of `MetricsEventId`). --- diff --git a/src/Tjidde.Logging/Extensions/MetricsLoggerExtensions.cs b/src/Tjidde.Logging/Extensions/MetricsLoggerExtensions.cs index 3bc7c5c..2ed8e84 100644 --- a/src/Tjidde.Logging/Extensions/MetricsLoggerExtensions.cs +++ b/src/Tjidde.Logging/Extensions/MetricsLoggerExtensions.cs @@ -3,18 +3,44 @@ namespace Tjidde.Logging.Extensions; /// -/// Provides the custom Metrics log level and extension methods for logging metrics entries. +/// Extension methods for writing metric-style log entries (for example requests_total=42). /// +/// +/// Metrics entries are written at with the event +/// . Tjidde.Logging recognizes that event and shows the entry as +/// [METRICS] in magenta; every other logging provider sees an ordinary information entry. +/// For real application metrics (counters, histograms, gauges) prefer System.Diagnostics.Metrics. +/// public static class MetricsLoggerExtensions { + /// The event name that marks a log entry as a metrics entry. + public const string MetricsEventName = "Metrics"; + + /// The fixed event id used for metrics entries. + public const int MetricsEventIdValue = 10_000; + + /// + /// The event ID that marks a log entry as a metrics entry (Id = 10000, Name = "Metrics"). + /// Any entry whose event name is "Metrics" is shown as [METRICS] by Tjidde.Logging. + /// + public static readonly EventId MetricsEventId = new(MetricsEventIdValue, MetricsEventName); + /// - /// The custom log level used for metrics entries. - /// Uses integer value 10 to avoid conflicts with all standard values. + /// The former custom log level for metrics entries (integer value 10). /// + /// + /// Other logging providers do not know this level; the Microsoft console formatters, for example, + /// throw an for it. Tjidde.Logging still recognizes it, + /// but use the LogMetrics methods (which log at with + /// ) instead, or System.Diagnostics.Metrics for real metrics. + /// + [Obsolete("(LogLevel)10 is not a valid LogLevel and makes other logging providers (such as the Microsoft console logger) throw. " + + "Use LogMetrics(...), which logs at LogLevel.Information with MetricsEventId, " + + "or System.Diagnostics.Metrics for real application metrics.")] public static readonly LogLevel Metrics = (LogLevel)10; /// - /// Logs a metrics entry. + /// Logs a metrics entry at with . /// /// The to write to. /// The log message. @@ -22,11 +48,11 @@ public static class MetricsLoggerExtensions public static void LogMetrics(this ILogger logger, string message, params object?[] args) { ArgumentNullException.ThrowIfNull(logger); - logger.Log(Metrics, message, args); + logger.Log(LogLevel.Information, MetricsEventId, message, args); } /// - /// Logs a metrics entry with an associated exception. + /// Logs a metrics entry with an associated exception at with . /// /// The to write to. /// The exception to log. @@ -35,12 +61,16 @@ public static void LogMetrics(this ILogger logger, string message, params object public static void LogMetrics(this ILogger logger, Exception? exception, string message, params object?[] args) { ArgumentNullException.ThrowIfNull(logger); - logger.Log(Metrics, exception, message, args); + logger.Log(LogLevel.Information, MetricsEventId, exception, message, args); } /// - /// Logs a metrics entry with an event ID. + /// Logs a metrics entry at with a caller-supplied event ID. /// + /// + /// The event ID is passed on unchanged. When it has no name, the name "Metrics" is used so the + /// entry is still shown as [METRICS]; an event ID with a different name is shown as [INFORMATION]. + /// /// The to write to. /// The event ID. /// The log message. @@ -48,6 +78,11 @@ public static void LogMetrics(this ILogger logger, Exception? exception, string public static void LogMetrics(this ILogger logger, EventId eventId, string message, params object?[] args) { ArgumentNullException.ThrowIfNull(logger); - logger.Log(Metrics, eventId, message, args); + var id = string.IsNullOrEmpty(eventId.Name) ? new EventId(eventId.Id, MetricsEventName) : eventId; + logger.Log(LogLevel.Information, id, message, args); } + + /// Returns true when the entry is a metrics entry (event name "Metrics" or the obsolete level 10). + internal static bool IsMetricsEntry(LogLevel logLevel, EventId eventId) + => (int)logLevel == 10 || string.Equals(eventId.Name, MetricsEventName, StringComparison.Ordinal); } diff --git a/src/Tjidde.Logging/Logging/TjiddeLogger.cs b/src/Tjidde.Logging/Logging/TjiddeLogger.cs index 0783505..709e1cb 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLogger.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLogger.cs @@ -1,4 +1,5 @@ using Tjidde.Logging.Context; +using Tjidde.Logging.Extensions; using Tjidde.Logging.Formatting; using Tjidde.Logging.Masking; using Tjidde.Logging.Options; @@ -76,7 +77,9 @@ public void Log( Exception? exception, Func formatter) { - if (!IsEnabled(logLevel)) + // Metrics entries (LogMetrics) are never dropped by Tjidde's own minimum level, as before. + var isMetrics = MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId); + if (logLevel == LogLevel.None || (!isMetrics && !IsEnabled(logLevel))) return; var now = DateTime.Now; @@ -112,11 +115,11 @@ public void Log( var rendered = _options.OutputFormat == TjiddeLogOutputFormat.Json ? BuildJsonLogLine(now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties) - : BuildTextLogLine(now, logLevel, methodName, customer, message, exception, scopes, maskedKeyNames); + : BuildTextLogLine(now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames); ExportToOpenTelemetry(now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties); - _processor.Enqueue(rendered, _options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel) : null); + _processor.Enqueue(rendered, _options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); } private string RenderMessage( @@ -208,7 +211,7 @@ private void ExportToOpenTelemetry( { yield return new KeyValuePair("log.category", _categoryName); yield return new KeyValuePair("log.class", _className); - yield return new KeyValuePair("log.level", FormatLogLevel(logLevel)); + yield return new KeyValuePair("log.level", FormatLogLevel(logLevel, eventId)); yield return new KeyValuePair("log.message", message); if (eventId.Id != 0) @@ -246,6 +249,7 @@ private void ExportToOpenTelemetry( private string BuildTextLogLine( DateTime timestamp, LogLevel logLevel, + EventId eventId, string? methodName, string? customer, string message, @@ -254,7 +258,7 @@ private string BuildTextLogLine( List maskedKeyNames) { var sb = new System.Text.StringBuilder(); - sb.Append(BuildLogLine(timestamp, logLevel, _className, methodName, customer, message)); + sb.Append(BuildLogLine(timestamp, FormatLogLevel(logLevel, eventId), _className, methodName, customer, message)); if (maskedKeyNames.Count > 0) sb.Append($" [Masked: {string.Join(", ", maskedKeyNames)}]"); @@ -284,7 +288,7 @@ private string BuildJsonLogLine( { ["@timestamp"] = timestamp.ToString("O"), ["message"] = message, - ["level"] = FormatLogLevel(logLevel), + ["level"] = FormatLogLevel(logLevel, eventId), ["category"] = _categoryName, ["class"] = _className, ["eventId"] = eventId.Id, @@ -336,7 +340,7 @@ private LogLevel ResolveMinimumLevelForCategory() private static string BuildLogLine( DateTime timestamp, - LogLevel logLevel, + string level, string className, string? methodName, string? customer, @@ -344,22 +348,21 @@ private static string BuildLogLine( { var date = timestamp.ToString("yyyy-MM-dd"); var time = timestamp.ToString("HH:mm:ss"); - var level = FormatLogLevel(logLevel); var method = string.IsNullOrWhiteSpace(methodName) ? string.Empty : $" Method=>{methodName}"; var customerPart = string.IsNullOrWhiteSpace(customer) ? string.Empty : $"Client=>{customer}: "; return $"{date}: {time}: [{level}] Class=>{className}{method}: {customerPart}{message}"; } - private static string FormatLogLevel(LogLevel logLevel) => logLevel switch + private static string FormatLogLevel(LogLevel logLevel, EventId eventId) => logLevel switch { + _ when MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId) => "METRICS", LogLevel.Trace => "TRACE", LogLevel.Debug => "DEBUG", LogLevel.Information => "INFORMATION", LogLevel.Warning => "WARNING", LogLevel.Error => "ERROR", LogLevel.Critical => "CRITICAL", - _ when (int)logLevel == 10 => "METRICS", _ => "???" }; @@ -449,15 +452,15 @@ private List CollectScopes() return scopes; } - private static ConsoleColor GetColor(LogLevel logLevel) => logLevel switch + private static ConsoleColor GetColor(LogLevel logLevel, EventId eventId) => logLevel switch { + _ when MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId) => ConsoleColor.Magenta, LogLevel.Trace => ConsoleColor.Gray, LogLevel.Debug => ConsoleColor.Cyan, LogLevel.Information => ConsoleColor.Green, LogLevel.Warning => ConsoleColor.Yellow, LogLevel.Error => ConsoleColor.Red, LogLevel.Critical => ConsoleColor.DarkRed, - _ when (int)logLevel == 10 => ConsoleColor.Magenta, _ => ConsoleColor.White }; diff --git a/tests/Tjidde.Logging.Tests/LogOutputFormatTests.cs b/tests/Tjidde.Logging.Tests/LogOutputFormatTests.cs index 474a79a..d71ed3c 100644 --- a/tests/Tjidde.Logging.Tests/LogOutputFormatTests.cs +++ b/tests/Tjidde.Logging.Tests/LogOutputFormatTests.cs @@ -228,20 +228,65 @@ public void Log_MaskingDisabled_DoesNotMaskMessage() } [Fact] - public void Log_MetricsLevel_OutputContainsMetricsLabel() + public void Log_ObsoleteMetricsLevel_OutputContainsMetricsLabel() { CustomerContext.Clear(); var logger = BuildLogger(); var output = CaptureConsoleOutput(() => +#pragma warning disable CS0618 // (LogLevel)10 must keep working for existing callers logger.Log(MetricsLoggerExtensions.Metrics, new EventId(0), "requests_total=42", null, (s, _) => s)); +#pragma warning restore CS0618 output.Should().Contain("[METRICS]"); output.Should().Contain("requests_total=42"); output.Trim().Should().MatchRegex(LogLinePattern); } + [Fact] + public void LogMetrics_OutputContainsMetricsLabel() + { + CustomerContext.Clear(); + var logger = BuildLogger(); + + var output = CaptureConsoleOutput(() => logger.LogMetrics("requests_total={Count}", 42)); + + output.Should().Contain("[METRICS]"); + output.Should().Contain("requests_total=42"); + output.Trim().Should().MatchRegex(LogLinePattern); + } + + [Fact] + public void LogMetrics_IsNotDroppedByMinimumLevel() + { + CustomerContext.Clear(); + var logger = BuildLogger(options: new TjiddeLoggerOptions { MinimumLevel = LogLevel.Critical }); + + var output = CaptureConsoleOutput(() => + { + logger.LogInformation("plain information"); + logger.LogMetrics("requests_total={Count}", 42); + }); + + output.Should().NotContain("plain information"); + output.Should().Contain("[METRICS]"); + } + + [Fact] + public void LogMetrics_JsonOutput_UsesMetricsLevelAndEvent() + { + CustomerContext.Clear(); + var logger = BuildLogger(options: new TjiddeLoggerOptions { OutputFormat = TjiddeLogOutputFormat.Json }); + + var output = CaptureConsoleOutput(() => logger.LogMetrics("requests_total={Count}", 42)); + + using var doc = JsonDocument.Parse(output.Trim()); + doc.RootElement.GetProperty("level").GetString().Should().Be("METRICS"); + doc.RootElement.GetProperty("eventName").GetString().Should().Be("Metrics"); + doc.RootElement.GetProperty("eventId").GetInt32().Should().Be(MetricsLoggerExtensions.MetricsEventIdValue); + } + [Fact] public void Log_JsonOutput_ContainsExpectedFields() { diff --git a/tests/Tjidde.Logging.Tests/MetricsLoggingTests.cs b/tests/Tjidde.Logging.Tests/MetricsLoggingTests.cs new file mode 100644 index 0000000..415463c --- /dev/null +++ b/tests/Tjidde.Logging.Tests/MetricsLoggingTests.cs @@ -0,0 +1,105 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Extensions; +using Xunit; + +namespace Tjidde.Logging.Tests; + +/// +/// LogMetrics must work next to other logging providers (e.g. the Microsoft console logger), +/// which reject unknown values. +/// +[Collection("ConsoleOutput")] +public sealed class MetricsLoggingTests +{ + private static string CaptureConsoleOutput(Action action) + { + var original = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + try + { + action(); + } + finally + { + Console.SetOut(original); + } + return writer.ToString(); + } + + [Theory] + [InlineData("simple")] + [InlineData("json")] + [InlineData("systemd")] + public void LogMetrics_WithMicrosoftConsoleLoggerRegistered_DoesNotThrow(string formatterName) + { + var output = CaptureConsoleOutput(() => + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder + .SetMinimumLevel(LogLevel.Trace) + .AddTjiddeLogger() + .AddConsole(o => o.FormatterName = formatterName)); + + // Disposing the provider flushes both the Tjidde and the Microsoft console queues. + using var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + + var act = () => + { + logger.LogMetrics("requests_total={Count}", 42); + logger.LogMetrics(new InvalidOperationException("boom"), "failures_total={Count}", 1); + logger.LogMetrics(new EventId(200, "Throughput"), "throughput_rps={Rps}", 12); + }; + + act.Should().NotThrow(); + }); + + output.Should().Contain("[METRICS]"); + output.Should().Contain("requests_total=42"); + } + + [Fact] + public void LogMetrics_UsesInformationLevelWithMetricsEventId() + { + var logger = new RecordingLogger(); + + logger.LogMetrics("requests_total={Count}", 42); + logger.LogMetrics(new InvalidOperationException("boom"), "failures_total={Count}", 1); + + logger.Entries.Should().HaveCount(2); + logger.Entries.Should().OnlyContain(e => + e.Level == LogLevel.Information + && e.EventId.Id == MetricsLoggerExtensions.MetricsEventId.Id + && e.EventId.Name == "Metrics"); + logger.Entries[0].Message.Should().Be("requests_total=42"); + } + + [Fact] + public void LogMetrics_WithCustomEventId_KeepsCallerEventId() + { + var logger = new RecordingLogger(); + + logger.LogMetrics(new EventId(200, "Throughput"), "throughput_rps={Rps}", 12); + + var entry = logger.Entries.Should().ContainSingle().Subject; + entry.Level.Should().Be(LogLevel.Information); + entry.EventId.Id.Should().Be(200); + entry.EventId.Name.Should().Be("Throughput"); + } + + private sealed class RecordingLogger : ILogger + { + public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + => Entries.Add((logLevel, eventId, formatter(state, exception))); + } +} diff --git a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj index 9d2b64a..a91c9a5 100644 --- a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj +++ b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj @@ -16,6 +16,7 @@ all + From f5b0ce878f695eb20066f41bdc312341ea7bb620 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 18:10:47 +0200 Subject: [PATCH 07/28] Make logging never throw TjiddeLogger.Log could throw from JSON serialization (cycles, unsupported types, throwing getters), from ToString() or enumeration of logged values, from a custom IExceptionFormatter, from the formatter delegate and from the OpenTelemetry export. - Serialize each JSON property value separately with IgnoreCycles and MaxDepth 32; on failure fall back to ToString() or "[unserializable: TypeName]". - Fall back to the message template when rendering the message throws, and make MessageTemplateRenderer.FormatValue safe. - Fall back to exception type and message when the exception formatter fails. - Skip scopes that throw and ignore OpenTelemetry export failures. - As a last resort, write a minimal line with level, category, message and a note that rendering failed (without unmasked values). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + src/Tjidde.Logging/Logging/TjiddeLogger.cs | 303 +++++++++++++++--- .../Masking/MessageTemplateRenderer.cs | 24 +- .../LoggingNeverThrowsTests.cs | 296 +++++++++++++++++ 4 files changed, 582 insertions(+), 43 deletions(-) create mode 100644 tests/Tjidde.Logging.Tests/LoggingNeverThrowsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b34282b..fc05ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Changed - `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()`. ### 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. ### Deprecated - `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. diff --git a/src/Tjidde.Logging/Logging/TjiddeLogger.cs b/src/Tjidde.Logging/Logging/TjiddeLogger.cs index 709e1cb..8d59cc6 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLogger.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLogger.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Text.Json; +using System.Text.Json.Serialization; namespace Tjidde.Logging.Logging; @@ -17,7 +18,11 @@ internal sealed class TjiddeLogger : ILogger { private static readonly JsonSerializerOptions JsonOptions = new() { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + // Logged objects can reference each other; write null for a cycle instead of throwing. + ReferenceHandler = ReferenceHandler.IgnoreCycles, + // Very deep object graphs fall back to ToString() instead of producing huge lines. + MaxDepth = 32 }; private readonly string _categoryName; @@ -82,7 +87,28 @@ public void Log( if (logLevel == LogLevel.None || (!isMetrics && !IsEnabled(logLevel))) return; + // Logging must never throw: any failure while building the entry produces a minimal line instead. var now = DateTime.Now; + string? message = null; + try + { + WriteEntry(now, logLevel, eventId, state, exception, formatter, ref message); + } + catch (Exception renderError) + { + WriteFallbackEntry(now, logLevel, eventId, state, exception, formatter, message, renderError); + } + } + + private void WriteEntry( + DateTime now, + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter, + ref string? message) + { var customer = _customerContextAccessor.GetCustomerContext(); var methodName = ResolveMethodName(); @@ -103,11 +129,11 @@ public void Log( continue; } - properties.Add(new KeyValuePair(kvp.Key, MaskPropertyValue(kvp.Key, kvp.Value, maskedKeyNames))); + properties.Add(new KeyValuePair(kvp.Key, SafeMaskPropertyValue(kvp.Key, kvp.Value, maskedKeyNames))); } } - var message = RenderMessage(state, exception, formatter, originalFormat, properties, maskedKeyNames.Count > 0); + message = RenderMessage(state, exception, formatter, originalFormat, properties, maskedKeyNames.Count > 0); List? scopes = null; if (_options.IncludeScopes && _scopeProvider is not null) @@ -122,6 +148,138 @@ public void Log( _processor.Enqueue(rendered, _options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); } + /// + /// Last-resort output when building the regular entry failed: level, category, message and a note + /// that rendering failed. Never throws. + /// + private void WriteFallbackEntry( + DateTime now, + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter, + string? message, + Exception renderError) + { + try + { + message ??= FallbackMessage(state, exception, formatter) ?? "[message unavailable]"; + var level = FormatLogLevel(logLevel, eventId); + var failure = TryMask($"{renderError.GetType().Name}: {SafeExceptionMessage(renderError)}") + ?? renderError.GetType().Name; + + string line; + if (_options.OutputFormat == TjiddeLogOutputFormat.Json) + { + line = JsonSerializer.Serialize(new Dictionary + { + ["@timestamp"] = now.ToString("O"), + ["message"] = message, + ["level"] = level, + ["category"] = _categoryName, + ["renderError"] = $"Log entry could not be rendered completely: {failure}" + }); + } + else + { + line = $"{now:yyyy-MM-dd}: {now:HH:mm:ss}: [{level}] Class=>{_className}: {message} " + + $"[Log entry could not be rendered completely: {failure}] | Category: {_categoryName}"; + } + + _processor.Enqueue(line, _options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); + } + catch (Exception) + { + // Logging must never crash the application. + } + } + + /// + /// The message for the last-resort entry when the regular message was never rendered. With masking enabled, + /// structured state yields its unrendered template, because the framework's formatter inserts raw + /// (unmasked) values of sensitive properties. + /// + private string? FallbackMessage(TState state, Exception? exception, Func formatter) + { + if (_options.EnableSensitiveDataMasking && state is IEnumerable> structuredState) + { + try + { + foreach (var kvp in structuredState) + { + if (kvp.Key.Equals("{OriginalFormat}", StringComparison.Ordinal)) + return TryMask(kvp.Value as string); + } + } + catch (Exception) + { + // Fall through. + } + + return null; + } + + return TryMask(TryFormat(state, exception, formatter)); + } + + private static string? TryFormat(TState state, Exception? exception, Func formatter) + { + try + { + return formatter(state, exception); + } + catch (Exception) + { + return null; + } + } + + private string? TryMask(string? text) + { + if (text is null || !_options.EnableSensitiveDataMasking) + return text; + + try + { + return _masker.MaskMessage(text); + } + catch (Exception) + { + return null; + } + } + + private static string SafeExceptionMessage(Exception exception) + { + try + { + return exception.Message; + } + catch (Exception) + { + return "[message unavailable]"; + } + } + + /// Returns value.ToString(), or [unserializable: TypeName] when ToString throws. + internal static string? SafeToString(object? value) + { + if (value is null) + return null; + + try + { + return value.ToString(); + } + catch (Exception) + { + return Unserializable(value); + } + } + + private static string Unserializable(object value) => $"[unserializable: {value.GetType().FullName}]"; + private string RenderMessage( TState state, Exception? exception, @@ -130,19 +288,41 @@ private string RenderMessage( List>? properties, bool hasMaskedProperties) { - if (!_options.EnableSensitiveDataMasking) - return formatter(state, exception); + string message; + try + { + if (!_options.EnableSensitiveDataMasking) + return formatter(state, exception); - // The framework's formatter inserts the raw values of sensitive properties into the message, - // so rebuild the message from its template using the masked values instead. - var message = hasMaskedProperties + // The framework's formatter inserts the raw values of sensitive properties into the message, + // so rebuild the message from its template using the masked values instead. + message = hasMaskedProperties && originalFormat is not null && properties is not null && MessageTemplateRenderer.TryRender(originalFormat, properties, out var rendered) - ? rendered - : formatter(state, exception); + ? rendered + : formatter(state, exception); + } + catch (Exception renderError) + { + // A value whose ToString() or enumeration throws must not lose the entry: fall back to the template. + message = $"{originalFormat ?? "[message unavailable]"} [message could not be rendered: {renderError.GetType().Name}]"; + } - return _masker.MaskMessage(message); + return _options.EnableSensitiveDataMasking ? _masker.MaskMessage(message) : message; + } + + private object? SafeMaskPropertyValue(string key, object? value, List maskedKeyNames) + { + try + { + return MaskPropertyValue(key, value, maskedKeyNames); + } + catch (Exception) + { + // Only reachable for sensitive keys (ToString threw); never fall back to the raw value there. + return value is null ? null : Unserializable(value); + } } private object? MaskPropertyValue(string key, object? value, List maskedKeyNames) @@ -162,7 +342,18 @@ private string RenderMessage( private string FormatException(Exception exception) { - var formatted = _exceptionFormatter.Format(exception); + string formatted; + try + { + formatted = _exceptionFormatter.Format(exception); + } + catch (Exception formatterError) + { + // A custom IExceptionFormatter must not make logging throw; fall back to type and message. + formatted = $"{exception.GetType().FullName}: {SafeExceptionMessage(exception)} " + + $"[exception formatter failed: {formatterError.GetType().Name}]"; + } + return _options.EnableSensitiveDataMasking ? _masker.MaskMessage(formatted) : formatted; } @@ -181,21 +372,28 @@ private void ExportToOpenTelemetry( if (!_options.EnableOpenTelemetryExport) return; - var tags = BuildOpenTelemetryTags(logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties); - var activityEvent = new ActivityEvent("log", timestamp, new ActivityTagsCollection(tags)); - - var currentActivity = Activity.Current; - if (currentActivity is not null) + try { - currentActivity.AddEvent(activityEvent); - return; - } + var tags = BuildOpenTelemetryTags(logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties); + var activityEvent = new ActivityEvent("log", timestamp, new ActivityTagsCollection(tags)); - if (!_options.OpenTelemetryCreateFallbackActivity || _fallbackActivitySource is null) - return; + var currentActivity = Activity.Current; + if (currentActivity is not null) + { + currentActivity.AddEvent(activityEvent); + return; + } + + if (!_options.OpenTelemetryCreateFallbackActivity || _fallbackActivitySource is null) + return; - using var fallbackActivity = _fallbackActivitySource.StartActivity("tjidde.log", ActivityKind.Internal); - fallbackActivity?.AddEvent(activityEvent); + using var fallbackActivity = _fallbackActivitySource.StartActivity("tjidde.log", ActivityKind.Internal); + fallbackActivity?.AddEvent(activityEvent); + } + catch (Exception) + { + // Exporting is best effort: a failing listener or tag value must not break logging. + } } private IEnumerable> BuildOpenTelemetryTags( @@ -229,7 +427,8 @@ private void ExportToOpenTelemetry( if (exception is not null) { yield return new KeyValuePair("exception.type", exception.GetType().FullName); - yield return new KeyValuePair("exception.message", _options.EnableSensitiveDataMasking ? _masker.MaskMessage(exception.Message) : exception.Message); + var exceptionMessage = SafeExceptionMessage(exception); + yield return new KeyValuePair("exception.message", _options.EnableSensitiveDataMasking ? _masker.MaskMessage(exceptionMessage) : exceptionMessage); yield return new KeyValuePair("exception.stacktrace", FormatException(exception)); } @@ -304,13 +503,34 @@ private string BuildJsonLogLine( { var propertyMap = new Dictionary(); foreach (var kvp in properties) - propertyMap[kvp.Key] = kvp.Value; + propertyMap[kvp.Key] = ToSerializableValue(kvp.Value); payload["properties"] = propertyMap; } return JsonSerializer.Serialize(payload, JsonOptions); } + /// + /// Serializes one property value up front so a value that cannot be serialized (unsupported type, + /// throwing getter, too deep) only affects itself: it falls back to ToString(), or to + /// [unserializable: TypeName] when that throws too. + /// + private object? ToSerializableValue(object? value) + { + if (value is null) + return null; + + try + { + return JsonSerializer.SerializeToElement(value, value.GetType(), JsonOptions); + } + catch (Exception) + { + var text = SafeToString(value); + return _options.EnableSensitiveDataMasking && text is not null ? _masker.MaskMessage(text) : text; + } + } + private LogLevel ResolveMinimumLevelForCategory() { var levels = _options.CategoryMinimumLevels; @@ -422,12 +642,13 @@ _ when MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId) => "METRICS", private string FormatScopeProperty(string key, object? value) { + var raw = SafeToString(value) ?? string.Empty; if (!_options.EnableSensitiveDataMasking) - return $"{key}={value}"; + return $"{key}={raw}"; var text = _masker.IsSensitiveKey(key) - ? _masker.MaskValue(key, value?.ToString() ?? string.Empty) - : _masker.MaskMessage(value?.ToString() ?? string.Empty); + ? _masker.MaskValue(key, raw) + : _masker.MaskMessage(raw); return $"{key}={text}"; } @@ -436,18 +657,26 @@ private List CollectScopes() var scopes = new List(); _scopeProvider?.ForEachScope((scope, list) => { - switch (scope) + try { - case string s when !string.IsNullOrWhiteSpace(s): - list.Add(_options.EnableSensitiveDataMasking ? _masker.MaskMessage(s) : s); - break; - case IEnumerable> kvps: + switch (scope) { - list.AddRange(from kvp in kvps where !kvp.Key.Equals("MethodName", StringComparison.OrdinalIgnoreCase) select FormatScopeProperty(kvp.Key, kvp.Value)); - - break; + case string s when !string.IsNullOrWhiteSpace(s): + list.Add(_options.EnableSensitiveDataMasking ? _masker.MaskMessage(s) : s); + break; + case IEnumerable> kvps: + { + list.AddRange(from kvp in kvps where !kvp.Key.Equals("MethodName", StringComparison.OrdinalIgnoreCase) select FormatScopeProperty(kvp.Key, kvp.Value)); + + break; + } } } + catch (Exception) + { + // A scope whose enumeration throws is skipped instead of failing the whole entry. + list.Add($"[unserializable scope: {scope?.GetType().FullName}]"); + } }, scopes); return scopes; } diff --git a/src/Tjidde.Logging/Masking/MessageTemplateRenderer.cs b/src/Tjidde.Logging/Masking/MessageTemplateRenderer.cs index 12bcf9f..79b8a39 100644 --- a/src/Tjidde.Logging/Masking/MessageTemplateRenderer.cs +++ b/src/Tjidde.Logging/Masking/MessageTemplateRenderer.cs @@ -85,11 +85,23 @@ private static int FindUnusedValue(IReadOnlyList> } // Mirrors Microsoft.Extensions.Logging: null becomes "(null)" and collections are joined with ", ". - private static object FormatValue(object? value) => value switch + // A value whose enumeration or ToString() throws becomes "[unserializable: TypeName]" instead of failing. + private static object FormatValue(object? value) { - null => NullValue, - string text => text, - IEnumerable items => string.Join(", ", items.Cast().Select(item => item ?? NullValue)), - _ => value - }; + try + { + return value switch + { + null => NullValue, + string text => text, + IEnumerable items => string.Join(", ", items.Cast().Select(item => item ?? NullValue)), + IFormattable => value, + _ => value.ToString() ?? string.Empty + }; + } + catch (Exception) + { + return $"[unserializable: {value!.GetType().FullName}]"; + } + } } diff --git a/tests/Tjidde.Logging.Tests/LoggingNeverThrowsTests.cs b/tests/Tjidde.Logging.Tests/LoggingNeverThrowsTests.cs new file mode 100644 index 0000000..e981702 --- /dev/null +++ b/tests/Tjidde.Logging.Tests/LoggingNeverThrowsTests.cs @@ -0,0 +1,296 @@ +using System.Diagnostics; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Context; +using Tjidde.Logging.Formatting; +using Tjidde.Logging.Logging; +using Tjidde.Logging.Masking; +using Tjidde.Logging.Options; +using Xunit; + +namespace Tjidde.Logging.Tests; + +/// +/// Logging must never throw: values that cannot be serialized or formatted only affect themselves, +/// and anything else produces a minimal fallback line. +/// +[Collection("ConsoleOutput")] +public sealed class LoggingNeverThrowsTests +{ + private static readonly ConsoleLogProcessor Processor = new(); + + private static TjiddeLogger BuildLogger( + TjiddeLoggerOptions? options = null, + IExceptionFormatter? exceptionFormatter = null, + ICustomerContextAccessor? accessor = null) + { + var opts = options ?? new TjiddeLoggerOptions { OutputFormat = TjiddeLogOutputFormat.Json }; + var masker = new SensitiveDataMasker(opts.MaskPlaceholder, opts.AdditionalSensitiveKeys); + return new TjiddeLogger( + "My.Namespace.MyService", + opts, + accessor ?? new AsyncLocalCustomerContextAccessor(), + masker, + exceptionFormatter ?? new ExceptionFormatter(opts.IncludeStackTrace, opts.IncludeInnerExceptions), + Processor, + scopeProvider: null); + } + + private static string Capture(Action log) + { + var original = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + try + { + log.Should().NotThrow(); + Processor.Flush(TimeSpan.FromSeconds(5)).Should().BeTrue(); + } + finally + { + Console.SetOut(original); + } + return writer.ToString().Trim(); + } + + private static JsonElement CaptureJson(Action log) + { + var output = Capture(log); + output.Split(Environment.NewLine).Should().ContainSingle("one log entry is one line"); + using var doc = JsonDocument.Parse(output); + return doc.RootElement.Clone(); + } + + private static IReadOnlyList> State(params (string Key, object? Value)[] pairs) + => pairs.Select(p => new KeyValuePair(p.Key, p.Value)).ToList(); + + [Fact] + public void Json_ObjectWithCycle_IsWrittenWithoutTheCycle() + { + var node = new Node { Name = "a" }; + node.Next = node; + var logger = BuildLogger(); + + var root = CaptureJson(() => logger.LogInformation("Node {Node}", node)); + + var written = root.GetProperty("properties").GetProperty("Node"); + written.GetProperty("name").GetString().Should().Be("a"); + written.GetProperty("next").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public void Json_NullValues_AreWrittenAsNull() + { + var logger = BuildLogger(); + + var root = CaptureJson(() => logger.LogInformation("User {User} with {Password}", null, null)); + + root.GetProperty("message").GetString().Should().Contain("User (null)"); + var properties = root.GetProperty("properties"); + properties.GetProperty("User").ValueKind.Should().Be(JsonValueKind.Null); + properties.GetProperty("Password").GetString().Should().Be("[REDACTED]"); + } + + [Fact] + public void Json_Collections_AreWrittenAsArraysAndObjects() + { + var logger = BuildLogger(); + var ids = new List { 1, 2, 3 }; + var totals = new Dictionary { ["eu"] = 5 }; + + var root = CaptureJson(() => logger.LogInformation("Ids {Ids} totals {Totals}", ids, totals)); + + root.GetProperty("message").GetString().Should().Be("Ids 1, 2, 3 totals [eu, 5]"); + var properties = root.GetProperty("properties"); + properties.GetProperty("Ids").EnumerateArray().Select(e => e.GetInt32()).Should().Equal(1, 2, 3); + properties.GetProperty("Totals").GetProperty("eu").GetInt32().Should().Be(5); + } + + [Fact] + public void Json_ValueWhoseToStringThrows_IsSerializedAndMessageFallsBackToTemplate() + { + var logger = BuildLogger(); + + var root = CaptureJson(() => logger.LogInformation("Order {Order}", new ThrowingToString { Id = 7 })); + + root.GetProperty("level").GetString().Should().Be("INFORMATION"); + root.GetProperty("message").GetString().Should().StartWith("Order {Order}"); + root.GetProperty("properties").GetProperty("Order").GetProperty("id").GetInt32().Should().Be(7); + } + + [Fact] + public void Json_SensitiveValueWhoseToStringThrows_IsMarkedUnserializable() + { + var logger = BuildLogger(); + + var root = CaptureJson(() => logger.Log(LogLevel.Information, new EventId(0), + State(("Password", new ThrowingToString { Id = 7 })), null, (_, _) => "Login")); + + root.GetProperty("properties").GetProperty("Password").GetString() + .Should().Be($"[unserializable: {typeof(ThrowingToString).FullName}]"); + } + + [Fact] + public void Json_ValueWithThrowingGetter_FallsBackToToString() + { + var logger = BuildLogger(); + + var root = CaptureJson(() => logger.LogInformation("Value {Value}", new ThrowingGetter())); + + root.GetProperty("message").GetString().Should().Be("Value throwing-getter"); + root.GetProperty("properties").GetProperty("Value").GetString().Should().Be("throwing-getter"); + } + + [Fact] + public void Json_ValueWithThrowingGetterAndToString_IsMarkedUnserializable() + { + var logger = BuildLogger(); + + var root = CaptureJson(() => logger.Log(LogLevel.Information, new EventId(0), + State(("Value", new ThrowingEverything())), null, (_, _) => "Something happened")); + + root.GetProperty("message").GetString().Should().Be("Something happened"); + root.GetProperty("properties").GetProperty("Value").GetString() + .Should().Be($"[unserializable: {typeof(ThrowingEverything).FullName}]"); + } + + [Fact] + public void Json_UnsupportedType_FallsBackToToString() + { + var logger = BuildLogger(); + + var root = CaptureJson(() => logger.LogInformation("Type {Type}", typeof(string))); + + root.GetProperty("properties").GetProperty("Type").GetString().Should().Be("System.String"); + } + + [Fact] + public void Json_EnumerableThatThrows_DoesNotThrow() + { + var logger = BuildLogger(); + + var root = CaptureJson(() => logger.LogInformation("Items {Items} for {Password}", ThrowingSequence(), "hunter2")); + + root.GetProperty("level").GetString().Should().Be("INFORMATION"); + root.GetRawText().Should().NotContain("hunter2"); + } + + [Fact] + public void Json_ThrowingExceptionFormatter_FallsBackToTypeAndMessage() + { + var logger = BuildLogger(exceptionFormatter: new ThrowingExceptionFormatter()); + + var root = CaptureJson(() => logger.LogError(new InvalidOperationException("Boom"), "Failed")); + + root.GetProperty("message").GetString().Should().Be("Failed"); + var exception = root.GetProperty("exception").GetString(); + exception.Should().Contain("System.InvalidOperationException: Boom"); + exception.Should().Contain("exception formatter failed"); + } + + [Fact] + public void Text_ThrowingExceptionFormatter_FallsBackToTypeAndMessage() + { + var logger = BuildLogger(new TjiddeLoggerOptions(), new ThrowingExceptionFormatter()); + + var output = Capture(() => logger.LogError(new InvalidOperationException("Boom"), "Failed")); + + output.Should().Contain("[ERROR]"); + output.Should().Contain("Failed | Exception: System.InvalidOperationException: Boom"); + } + + [Fact] + public void Text_ValueWhoseToStringThrows_DoesNotThrow() + { + var logger = BuildLogger(new TjiddeLoggerOptions()); + + var output = Capture(() => logger.LogInformation("Order {Order}", new ThrowingToString())); + + output.Should().Contain("[INFORMATION]"); + output.Should().Contain("Order {Order}"); + } + + [Fact] + public void ThrowingCustomerAccessor_WritesMinimalFallbackLine() + { + var logger = BuildLogger(accessor: new ThrowingAccessor()); + + var root = CaptureJson(() => logger.LogWarning("Payment {Amount} by {Password}", 12, "hunter2")); + + root.GetProperty("level").GetString().Should().Be("WARNING"); + root.GetProperty("category").GetString().Should().Be("My.Namespace.MyService"); + root.GetProperty("message").GetString().Should().Be("Payment {Amount} by {Password}"); + root.GetProperty("renderError").GetString().Should().Contain("InvalidOperationException"); + root.GetRawText().Should().NotContain("hunter2"); + } + + [Fact] + public void Text_ThrowingCustomerAccessor_WritesMinimalFallbackLine() + { + var logger = BuildLogger(new TjiddeLoggerOptions(), accessor: new ThrowingAccessor()); + + var output = Capture(() => logger.LogWarning("Disk almost full")); + + output.Should().Contain("[WARNING] Class=>MyService: Disk almost full"); + output.Should().Contain("could not be rendered"); + output.Should().Contain("My.Namespace.MyService"); + } + + [Fact] + public void OpenTelemetryExport_WithExceptionWhoseMessageThrows_DoesNotThrow() + { + var logger = BuildLogger(new TjiddeLoggerOptions { EnableOpenTelemetryExport = true }); + using var activity = new Activity("request").Start(); + + var output = Capture(() => logger.LogError(new ThrowingMessageException(), "Failed")); + + output.Should().Contain("Failed"); + activity.Events.Should().ContainSingle(); + } + + private sealed class Node + { + public string Name { get; set; } = string.Empty; + public Node? Next { get; set; } + } + + private sealed class ThrowingToString + { + public int Id { get; set; } + public override string ToString() => throw new InvalidOperationException("ToString failed"); + } + + private sealed class ThrowingGetter + { + public int Value => throw new InvalidOperationException("getter failed"); + public override string ToString() => "throwing-getter"; + } + + private sealed class ThrowingEverything + { + public int Value => throw new InvalidOperationException("getter failed"); + public override string ToString() => throw new InvalidOperationException("ToString failed"); + } + + private static IEnumerable ThrowingSequence() + { + yield return 1; + throw new InvalidOperationException("enumeration failed"); + } + + private sealed class ThrowingExceptionFormatter : IExceptionFormatter + { + public string Format(Exception exception) => throw new NotSupportedException("formatter failed"); + } + + private sealed class ThrowingAccessor : ICustomerContextAccessor + { + public string? GetCustomerContext() => throw new InvalidOperationException("no context"); + } + + private sealed class ThrowingMessageException : Exception + { + public override string Message => throw new InvalidOperationException("message failed"); + } +} From 563a45fe4b287eb905b45a0502871e8646c42f3e Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 18:15:52 +0200 Subject: [PATCH 08/28] Reference Microsoft.Extensions.Logging.Console for every test TFM Co-Authored-By: Claude Opus 5.5 --- tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj index 83b31e7..2d593d3 100644 --- a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj +++ b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj @@ -39,6 +39,7 @@ + @@ -46,6 +47,7 @@ + @@ -53,6 +55,7 @@ + From a2975930b69b1aba4378e9c41a062e7ec1ce1c0a Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 20:45:17 +0200 Subject: [PATCH 09/28] Apply options changes to existing loggers immediately The provider now subscribes to IOptionsMonitor.OnChange and keeps options, masker, exception formatter and fallback ActivitySource in one immutable TjiddeLoggerConfiguration that is swapped atomically. Loggers read the current configuration once per call, so they pick up changes right away and never see a mixed set. The old ActivitySource is disposed on change, and the change registration on provider dispose. AddTjiddeLogger(IConfiguration/IConfigurationSection) registers an IOptionsChangeTokenSource so a configuration reload rebinds the options. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../TjiddeLoggingBuilderExtensions.cs | 19 ++ src/Tjidde.Logging/Logging/TjiddeLogger.cs | 205 +++++++++--------- .../Logging/TjiddeLoggerConfiguration.cs | 105 +++++++++ .../Logging/TjiddeLoggerProvider.cs | 69 ++++-- .../OptionsReloadTests.cs | 180 +++++++++++++++ 6 files changed, 455 insertions(+), 124 deletions(-) create mode 100644 src/Tjidde.Logging/Logging/TjiddeLoggerConfiguration.cs create mode 100644 tests/Tjidde.Logging.Tests/OptionsReloadTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fc05ffd..0f88f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - `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. ### Changed +- Options changes now apply to existing loggers immediately, without a restart: the provider listens to `IOptionsMonitor.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()`. diff --git a/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs b/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs index a02e2f7..f56061c 100644 --- a/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs +++ b/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; namespace Tjidde.Logging.Extensions; @@ -37,6 +39,11 @@ public static ILoggingBuilder AddTjiddeLogger(this ILoggingBuilder builder, ICon ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(configurationSection); + // Rebind the options when the configuration reloads (for example appsettings.json with reloadOnChange), + // so the provider applies the new values to existing loggers. + builder.Services.AddSingleton>( + new ConfigurationSectionChangeTokenSource(configurationSection)); + return builder.AddTjiddeLogger(options => configurationSection.Bind(options)); } @@ -104,4 +111,16 @@ public static ILoggingBuilder UseTjiddeTextFormat(this ILoggingBuilder builder) builder.Services.Configure(options => options.OutputFormat = TjiddeLogOutputFormat.Text); return builder; } + + /// Signals an options change when the bound configuration section reloads. + private sealed class ConfigurationSectionChangeTokenSource : IOptionsChangeTokenSource + { + private readonly IConfiguration _configuration; + + public ConfigurationSectionChangeTokenSource(IConfiguration configuration) => _configuration = configuration; + + public string Name => Microsoft.Extensions.Options.Options.DefaultName; + + public IChangeToken GetChangeToken() => _configuration.GetReloadToken(); + } } diff --git a/src/Tjidde.Logging/Logging/TjiddeLogger.cs b/src/Tjidde.Logging/Logging/TjiddeLogger.cs index 8d59cc6..42459e5 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLogger.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLogger.cs @@ -26,37 +26,49 @@ internal sealed class TjiddeLogger : ILogger }; private readonly string _categoryName; - private readonly ActivitySource? _fallbackActivitySource; - private readonly TjiddeLoggerOptions _options; + private readonly TjiddeLoggerConfigurationHolder _configuration; private readonly ICustomerContextAccessor _customerContextAccessor; - private readonly ISensitiveDataMasker _masker; - private readonly IExceptionFormatter _exceptionFormatter; private readonly ConsoleLogProcessor _processor; private IExternalScopeProvider? _scopeProvider; // Extracted from category name for readable output private readonly string _className; + /// + /// Creates a logger that reads its settings from on every call, + /// so a configuration change applies to this logger immediately. + /// public TjiddeLogger( string categoryName, - TjiddeLoggerOptions options, + TjiddeLoggerConfigurationHolder configuration, ICustomerContextAccessor customerContextAccessor, - ISensitiveDataMasker masker, - IExceptionFormatter exceptionFormatter, ConsoleLogProcessor processor, IExternalScopeProvider? scopeProvider) { _categoryName = categoryName; - _options = options; + _configuration = configuration; _customerContextAccessor = customerContextAccessor; - _masker = masker; - _exceptionFormatter = exceptionFormatter; _processor = processor; _scopeProvider = scopeProvider; _className = ExtractClassName(categoryName); - _fallbackActivitySource = string.IsNullOrWhiteSpace(_options.OpenTelemetryActivitySourceName) - ? null - : new ActivitySource(_options.OpenTelemetryActivitySourceName); + } + + /// Creates a logger with a fixed configuration. + public TjiddeLogger( + string categoryName, + TjiddeLoggerOptions options, + ICustomerContextAccessor customerContextAccessor, + ISensitiveDataMasker masker, + IExceptionFormatter exceptionFormatter, + ConsoleLogProcessor processor, + IExternalScopeProvider? scopeProvider) + : this( + categoryName, + new TjiddeLoggerConfigurationHolder(new TjiddeLoggerConfiguration(options, masker, exceptionFormatter)), + customerContextAccessor, + processor, + scopeProvider) + { } /// Updates the scope provider — called by the provider when the external scope provider is set. @@ -66,15 +78,19 @@ internal void SetScopeProvider(IExternalScopeProvider scopeProvider) public IDisposable? BeginScope(TState state) where TState : notnull => _scopeProvider?.Push(state); - public bool IsEnabled(LogLevel logLevel) + public bool IsEnabled(LogLevel logLevel) => IsEnabled(_configuration.Current, logLevel); + + private bool IsEnabled(TjiddeLoggerConfiguration config, LogLevel logLevel) { if (logLevel == LogLevel.None) return false; - var minimumLevel = ResolveMinimumLevelForCategory(); - return logLevel >= minimumLevel; + return logLevel >= GetMinimumLevel(config); } + private LogLevel GetMinimumLevel(TjiddeLoggerConfiguration config) + => config.ResolveMinimumLevel(_categoryName); + public void Log( LogLevel logLevel, EventId eventId, @@ -82,9 +98,12 @@ public void Log( Exception? exception, Func formatter) { + // One configuration for the whole call, so a concurrent options change never mixes old and new settings. + var config = _configuration.Current; + // Metrics entries (LogMetrics) are never dropped by Tjidde's own minimum level, as before. var isMetrics = MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId); - if (logLevel == LogLevel.None || (!isMetrics && !IsEnabled(logLevel))) + if (logLevel == LogLevel.None || (!isMetrics && !IsEnabled(config, logLevel))) return; // Logging must never throw: any failure while building the entry produces a minimal line instead. @@ -92,15 +111,16 @@ public void Log( string? message = null; try { - WriteEntry(now, logLevel, eventId, state, exception, formatter, ref message); + WriteEntry(config, now, logLevel, eventId, state, exception, formatter, ref message); } catch (Exception renderError) { - WriteFallbackEntry(now, logLevel, eventId, state, exception, formatter, message, renderError); + WriteFallbackEntry(config, now, logLevel, eventId, state, exception, formatter, message, renderError); } } private void WriteEntry( + TjiddeLoggerConfiguration config, DateTime now, LogLevel logLevel, EventId eventId, @@ -110,7 +130,7 @@ private void WriteEntry( ref string? message) { var customer = _customerContextAccessor.GetCustomerContext(); - var methodName = ResolveMethodName(); + var methodName = ResolveMethodName(config); var maskedKeyNames = new List(); List>? properties = null; @@ -129,23 +149,23 @@ private void WriteEntry( continue; } - properties.Add(new KeyValuePair(kvp.Key, SafeMaskPropertyValue(kvp.Key, kvp.Value, maskedKeyNames))); + properties.Add(new KeyValuePair(kvp.Key, SafeMaskPropertyValue(config, kvp.Key, kvp.Value, maskedKeyNames))); } } - message = RenderMessage(state, exception, formatter, originalFormat, properties, maskedKeyNames.Count > 0); + message = RenderMessage(config, state, exception, formatter, originalFormat, properties, maskedKeyNames.Count > 0); List? scopes = null; - if (_options.IncludeScopes && _scopeProvider is not null) - scopes = CollectScopes(); + if (config.Options.IncludeScopes && _scopeProvider is not null) + scopes = CollectScopes(config); - var rendered = _options.OutputFormat == TjiddeLogOutputFormat.Json - ? BuildJsonLogLine(now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties) - : BuildTextLogLine(now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames); + var rendered = config.Options.OutputFormat == TjiddeLogOutputFormat.Json + ? BuildJsonLogLine(config, now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties) + : BuildTextLogLine(config, now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames); - ExportToOpenTelemetry(now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties); + ExportToOpenTelemetry(config, now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties); - _processor.Enqueue(rendered, _options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); + _processor.Enqueue(rendered, config.Options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); } /// @@ -153,6 +173,7 @@ private void WriteEntry( /// that rendering failed. Never throws. /// private void WriteFallbackEntry( + TjiddeLoggerConfiguration config, DateTime now, LogLevel logLevel, EventId eventId, @@ -164,13 +185,13 @@ private void WriteFallbackEntry( { try { - message ??= FallbackMessage(state, exception, formatter) ?? "[message unavailable]"; + message ??= FallbackMessage(config, state, exception, formatter) ?? "[message unavailable]"; var level = FormatLogLevel(logLevel, eventId); - var failure = TryMask($"{renderError.GetType().Name}: {SafeExceptionMessage(renderError)}") + var failure = TryMask(config, $"{renderError.GetType().Name}: {SafeExceptionMessage(renderError)}") ?? renderError.GetType().Name; string line; - if (_options.OutputFormat == TjiddeLogOutputFormat.Json) + if (config.Options.OutputFormat == TjiddeLogOutputFormat.Json) { line = JsonSerializer.Serialize(new Dictionary { @@ -187,7 +208,7 @@ private void WriteFallbackEntry( $"[Log entry could not be rendered completely: {failure}] | Category: {_categoryName}"; } - _processor.Enqueue(line, _options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); + _processor.Enqueue(line, config.Options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); } catch (Exception) { @@ -200,16 +221,16 @@ private void WriteFallbackEntry( /// structured state yields its unrendered template, because the framework's formatter inserts raw /// (unmasked) values of sensitive properties. /// - private string? FallbackMessage(TState state, Exception? exception, Func formatter) + private string? FallbackMessage(TjiddeLoggerConfiguration config, TState state, Exception? exception, Func formatter) { - if (_options.EnableSensitiveDataMasking && state is IEnumerable> structuredState) + if (config.Options.EnableSensitiveDataMasking && state is IEnumerable> structuredState) { try { foreach (var kvp in structuredState) { if (kvp.Key.Equals("{OriginalFormat}", StringComparison.Ordinal)) - return TryMask(kvp.Value as string); + return TryMask(config, kvp.Value as string); } } catch (Exception) @@ -220,7 +241,7 @@ private void WriteFallbackEntry( return null; } - return TryMask(TryFormat(state, exception, formatter)); + return TryMask(config, TryFormat(state, exception, formatter)); } private static string? TryFormat(TState state, Exception? exception, Func formatter) @@ -235,14 +256,14 @@ private void WriteFallbackEntry( } } - private string? TryMask(string? text) + private string? TryMask(TjiddeLoggerConfiguration config, string? text) { - if (text is null || !_options.EnableSensitiveDataMasking) + if (text is null || !config.Options.EnableSensitiveDataMasking) return text; try { - return _masker.MaskMessage(text); + return config.Masker.MaskMessage(text); } catch (Exception) { @@ -281,6 +302,7 @@ private static string SafeExceptionMessage(Exception exception) private static string Unserializable(object value) => $"[unserializable: {value.GetType().FullName}]"; private string RenderMessage( + TjiddeLoggerConfiguration config, TState state, Exception? exception, Func formatter, @@ -291,7 +313,7 @@ private string RenderMessage( string message; try { - if (!_options.EnableSensitiveDataMasking) + if (!config.Options.EnableSensitiveDataMasking) return formatter(state, exception); // The framework's formatter inserts the raw values of sensitive properties into the message, @@ -309,14 +331,14 @@ private string RenderMessage( message = $"{originalFormat ?? "[message unavailable]"} [message could not be rendered: {renderError.GetType().Name}]"; } - return _options.EnableSensitiveDataMasking ? _masker.MaskMessage(message) : message; + return config.Options.EnableSensitiveDataMasking ? config.Masker.MaskMessage(message) : message; } - private object? SafeMaskPropertyValue(string key, object? value, List maskedKeyNames) + private object? SafeMaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, List maskedKeyNames) { try { - return MaskPropertyValue(key, value, maskedKeyNames); + return MaskPropertyValue(config, key, value, maskedKeyNames); } catch (Exception) { @@ -325,27 +347,27 @@ private string RenderMessage( } } - private object? MaskPropertyValue(string key, object? value, List maskedKeyNames) + private object? MaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, List maskedKeyNames) { - if (!_options.EnableSensitiveDataMasking) + if (!config.Options.EnableSensitiveDataMasking) return value; - if (_masker.IsSensitiveKey(key)) + if (config.Masker.IsSensitiveKey(key)) { maskedKeyNames.Add(key); - return _masker.MaskValue(key, value?.ToString() ?? string.Empty); + return config.Masker.MaskValue(key, value?.ToString() ?? string.Empty); } // Non-sensitive keys can still carry sensitive literals (e.g. "password=..." or dynamic keys). - return value is string text ? _masker.MaskMessage(text) : value; + return value is string text ? config.Masker.MaskMessage(text) : value; } - private string FormatException(Exception exception) + private string FormatException(TjiddeLoggerConfiguration config, Exception exception) { string formatted; try { - formatted = _exceptionFormatter.Format(exception); + formatted = config.ExceptionFormatter.Format(exception); } catch (Exception formatterError) { @@ -354,10 +376,11 @@ private string FormatException(Exception exception) $"[exception formatter failed: {formatterError.GetType().Name}]"; } - return _options.EnableSensitiveDataMasking ? _masker.MaskMessage(formatted) : formatted; + return config.Options.EnableSensitiveDataMasking ? config.Masker.MaskMessage(formatted) : formatted; } private void ExportToOpenTelemetry( + TjiddeLoggerConfiguration config, DateTime timestamp, LogLevel logLevel, EventId eventId, @@ -369,12 +392,12 @@ private void ExportToOpenTelemetry( List maskedKeyNames, List>? properties) { - if (!_options.EnableOpenTelemetryExport) + if (!config.Options.EnableOpenTelemetryExport) return; try { - var tags = BuildOpenTelemetryTags(logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties); + var tags = BuildOpenTelemetryTags(config, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties); var activityEvent = new ActivityEvent("log", timestamp, new ActivityTagsCollection(tags)); var currentActivity = Activity.Current; @@ -384,10 +407,10 @@ private void ExportToOpenTelemetry( return; } - if (!_options.OpenTelemetryCreateFallbackActivity || _fallbackActivitySource is null) + if (!config.Options.OpenTelemetryCreateFallbackActivity || config.FallbackActivitySource is null) return; - using var fallbackActivity = _fallbackActivitySource.StartActivity("tjidde.log", ActivityKind.Internal); + using var fallbackActivity = config.FallbackActivitySource.StartActivity("tjidde.log", ActivityKind.Internal); fallbackActivity?.AddEvent(activityEvent); } catch (Exception) @@ -397,6 +420,7 @@ private void ExportToOpenTelemetry( } private IEnumerable> BuildOpenTelemetryTags( + TjiddeLoggerConfiguration config, LogLevel logLevel, EventId eventId, string? methodName, @@ -428,8 +452,8 @@ private void ExportToOpenTelemetry( { yield return new KeyValuePair("exception.type", exception.GetType().FullName); var exceptionMessage = SafeExceptionMessage(exception); - yield return new KeyValuePair("exception.message", _options.EnableSensitiveDataMasking ? _masker.MaskMessage(exceptionMessage) : exceptionMessage); - yield return new KeyValuePair("exception.stacktrace", FormatException(exception)); + yield return new KeyValuePair("exception.message", config.Options.EnableSensitiveDataMasking ? config.Masker.MaskMessage(exceptionMessage) : exceptionMessage); + yield return new KeyValuePair("exception.stacktrace", FormatException(config, exception)); } if (scopes is { Count: > 0 }) @@ -446,6 +470,7 @@ private void ExportToOpenTelemetry( } private string BuildTextLogLine( + TjiddeLoggerConfiguration config, DateTime timestamp, LogLevel logLevel, EventId eventId, @@ -463,7 +488,7 @@ private string BuildTextLogLine( sb.Append($" [Masked: {string.Join(", ", maskedKeyNames)}]"); if (exception is not null) - sb.Append($" | Exception: {FormatException(exception)}"); + sb.Append($" | Exception: {FormatException(config, exception)}"); if (scopes is { Count: > 0 }) sb.Append($" | Scopes: {string.Join(" > ", scopes)}"); @@ -472,6 +497,7 @@ private string BuildTextLogLine( } private string BuildJsonLogLine( + TjiddeLoggerConfiguration config, DateTime timestamp, LogLevel logLevel, EventId eventId, @@ -496,14 +522,14 @@ private string BuildJsonLogLine( ["method"] = methodName, ["scopes"] = scopes is { Count: > 0 } ? scopes : null, ["maskedFields"] = maskedKeyNames.Count > 0 ? maskedKeyNames : null, - ["exception"] = exception is null ? null : FormatException(exception) + ["exception"] = exception is null ? null : FormatException(config, exception) }; if (properties is { Count: > 0 }) { var propertyMap = new Dictionary(); foreach (var kvp in properties) - propertyMap[kvp.Key] = ToSerializableValue(kvp.Value); + propertyMap[kvp.Key] = ToSerializableValue(config, kvp.Value); payload["properties"] = propertyMap; } @@ -515,7 +541,7 @@ private string BuildJsonLogLine( /// throwing getter, too deep) only affects itself: it falls back to ToString(), or to /// [unserializable: TypeName] when that throws too. /// - private object? ToSerializableValue(object? value) + private object? ToSerializableValue(TjiddeLoggerConfiguration config, object? value) { if (value is null) return null; @@ -527,35 +553,8 @@ private string BuildJsonLogLine( catch (Exception) { var text = SafeToString(value); - return _options.EnableSensitiveDataMasking && text is not null ? _masker.MaskMessage(text) : text; - } - } - - private LogLevel ResolveMinimumLevelForCategory() - { - var levels = _options.CategoryMinimumLevels; - if (levels.Count == 0) - return _options.MinimumLevel; - - if (levels.TryGetValue(_categoryName, out var exact)) - return exact; - - var current = _categoryName; - while (true) - { - var lastDot = current.LastIndexOf('.'); - if (lastDot <= 0) - break; - - current = current[..lastDot]; - if (levels.TryGetValue(current, out var matched)) - return matched; + return config.Options.EnableSensitiveDataMasking && text is not null ? config.Masker.MaskMessage(text) : text; } - - if (levels.TryGetValue("Default", out var fallback)) - return fallback; - - return _options.MinimumLevel; } private static string BuildLogLine( @@ -586,13 +585,13 @@ _ when MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId) => "METRICS", _ => "???" }; - private string? ResolveMethodName() - => ExtractMethodFromScope() - ?? (_options.ResolveMethodNameFromStackTrace ? ExtractMethodFromStack() : null); + private string? ResolveMethodName(TjiddeLoggerConfiguration config) + => ExtractMethodFromScope(config) + ?? (config.Options.ResolveMethodNameFromStackTrace ? ExtractMethodFromStack() : null); - private string? ExtractMethodFromScope() + private string? ExtractMethodFromScope(TjiddeLoggerConfiguration config) { - if (!_options.IncludeScopes || _scopeProvider is null) + if (!config.Options.IncludeScopes || _scopeProvider is null) return null; string? methodName = null; @@ -640,19 +639,19 @@ _ when MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId) => "METRICS", return null; } - private string FormatScopeProperty(string key, object? value) + private string FormatScopeProperty(TjiddeLoggerConfiguration config, string key, object? value) { var raw = SafeToString(value) ?? string.Empty; - if (!_options.EnableSensitiveDataMasking) + if (!config.Options.EnableSensitiveDataMasking) return $"{key}={raw}"; - var text = _masker.IsSensitiveKey(key) - ? _masker.MaskValue(key, raw) - : _masker.MaskMessage(raw); + var text = config.Masker.IsSensitiveKey(key) + ? config.Masker.MaskValue(key, raw) + : config.Masker.MaskMessage(raw); return $"{key}={text}"; } - private List CollectScopes() + private List CollectScopes(TjiddeLoggerConfiguration config) { var scopes = new List(); _scopeProvider?.ForEachScope((scope, list) => @@ -662,11 +661,11 @@ private List CollectScopes() switch (scope) { case string s when !string.IsNullOrWhiteSpace(s): - list.Add(_options.EnableSensitiveDataMasking ? _masker.MaskMessage(s) : s); + list.Add(config.Options.EnableSensitiveDataMasking ? config.Masker.MaskMessage(s) : s); break; case IEnumerable> kvps: { - list.AddRange(from kvp in kvps where !kvp.Key.Equals("MethodName", StringComparison.OrdinalIgnoreCase) select FormatScopeProperty(kvp.Key, kvp.Value)); + list.AddRange(from kvp in kvps where !kvp.Key.Equals("MethodName", StringComparison.OrdinalIgnoreCase) select FormatScopeProperty(config, kvp.Key, kvp.Value)); break; } diff --git a/src/Tjidde.Logging/Logging/TjiddeLoggerConfiguration.cs b/src/Tjidde.Logging/Logging/TjiddeLoggerConfiguration.cs new file mode 100644 index 0000000..c1f5d25 --- /dev/null +++ b/src/Tjidde.Logging/Logging/TjiddeLoggerConfiguration.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Formatting; +using Tjidde.Logging.Masking; +using Tjidde.Logging.Options; + +namespace Tjidde.Logging.Logging; + +/// +/// Everything a derives from one set of : the options, +/// the masker, the exception formatter and the fallback . Immutable and shared by all +/// loggers of a provider; an options change replaces the whole object, so a log call always sees a consistent set. +/// +internal sealed class TjiddeLoggerConfiguration : IDisposable +{ + public TjiddeLoggerConfiguration( + TjiddeLoggerOptions options, + ISensitiveDataMasker masker, + IExceptionFormatter exceptionFormatter) + { + Options = options; + Masker = masker; + ExceptionFormatter = exceptionFormatter; + FallbackActivitySource = string.IsNullOrWhiteSpace(options.OpenTelemetryActivitySourceName) + ? null + : new ActivitySource(options.OpenTelemetryActivitySourceName); + } + + /// + /// Builds the configuration for , with a masker that also applies the runtime keys + /// from . + /// + public static TjiddeLoggerConfiguration Create(TjiddeLoggerOptions options, IMaskedKeysAccessor maskedKeysAccessor) + { + // The accessor rather than its current keys, so keys added later apply too + var masker = new SensitiveDataMasker(options.MaskPlaceholder, options.AdditionalSensitiveKeys, maskedKeysAccessor); + var exceptionFormatter = new ExceptionFormatter(options.IncludeStackTrace, options.IncludeInnerExceptions); + return new TjiddeLoggerConfiguration(options, masker, exceptionFormatter); + } + + public TjiddeLoggerOptions Options { get; } + + /// One masker for all loggers; it is thread-safe. + public ISensitiveDataMasker Masker { get; } + + public IExceptionFormatter ExceptionFormatter { get; } + + /// Source for the short-lived activity used for OpenTelemetry export when no activity is current. + public ActivitySource? FallbackActivitySource { get; } + + /// + /// The minimum level for : an exact match in + /// , else the longest matching namespace prefix, + /// else Default, else . + /// + public LogLevel ResolveMinimumLevel(string categoryName) + { + var levels = Options.CategoryMinimumLevels; + if (levels is null || levels.Count == 0) + return Options.MinimumLevel; + + if (levels.TryGetValue(categoryName, out var exact)) + return exact; + + var current = categoryName; + while (true) + { + var lastDot = current.LastIndexOf('.'); + if (lastDot <= 0) + break; + + current = current[..lastDot]; + if (levels.TryGetValue(current, out var matched)) + return matched; + } + + if (levels.TryGetValue("Default", out var fallback)) + return fallback; + + return Options.MinimumLevel; + } + + /// + /// Disposes the fallback activity source. A log call that still uses this configuration stays safe: + /// a disposed source has no listeners and starts no activities. + /// + public void Dispose() => FallbackActivitySource?.Dispose(); +} + +/// +/// Holds the current of a provider. Loggers read it on every call, +/// so a replacement applies to existing loggers immediately. +/// +internal sealed class TjiddeLoggerConfigurationHolder +{ + private TjiddeLoggerConfiguration _current; + + public TjiddeLoggerConfigurationHolder(TjiddeLoggerConfiguration initial) => _current = initial; + + public TjiddeLoggerConfiguration Current => Volatile.Read(ref _current); + + /// Atomically replaces the configuration and returns the previous one. + public TjiddeLoggerConfiguration Exchange(TjiddeLoggerConfiguration replacement) + => Interlocked.Exchange(ref _current, replacement); +} diff --git a/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs b/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs index 2e38151..b034e6b 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs @@ -15,9 +15,11 @@ namespace Tjidde.Logging.Logging; [ProviderAlias("Tjidde")] public sealed class TjiddeLoggerProvider : ILoggerProvider, ISupportExternalScope { - private readonly IOptionsMonitor _optionsMonitor; private readonly ICustomerContextAccessor _customerContextAccessor; private readonly IMaskedKeysAccessor _maskedKeysAccessor; + private readonly TjiddeLoggerConfigurationHolder _configuration; + private readonly IDisposable? _optionsChangeRegistration; + private readonly object _sync = new(); private readonly ConcurrentDictionary _loggers = new(StringComparer.OrdinalIgnoreCase); private readonly ConsoleLogProcessor _processor = new(); private IExternalScopeProvider _scopeProvider = NoopExternalScopeProvider.Instance; @@ -31,9 +33,13 @@ public TjiddeLoggerProvider( ICustomerContextAccessor customerContextAccessor, IMaskedKeysAccessor maskedKeysAccessor) { - _optionsMonitor = optionsMonitor; _customerContextAccessor = customerContextAccessor; _maskedKeysAccessor = maskedKeysAccessor; + _configuration = new TjiddeLoggerConfigurationHolder( + TjiddeLoggerConfiguration.Create(optionsMonitor.CurrentValue, maskedKeysAccessor)); + + // Options changes (for example appsettings.json with reloadOnChange) apply to existing loggers immediately + _optionsChangeRegistration = optionsMonitor.OnChange(OnOptionsChanged); } /// @@ -55,12 +61,18 @@ public void SetScopeProvider(IExternalScopeProvider scopeProvider) /// public void Dispose() { - if (!_disposed) + lock (_sync) { - _loggers.Clear(); - _processor.Dispose(); + if (_disposed) + return; + _disposed = true; } + + _optionsChangeRegistration?.Dispose(); + _loggers.Clear(); + _processor.Dispose(); + _configuration.Current.Dispose(); } /// A no-op scope provider used before an external one is assigned. @@ -78,22 +90,37 @@ public void Dispose() { } } private TjiddeLogger CreateLoggerInstance(string categoryName) + => new(categoryName, _configuration, _customerContextAccessor, _processor, _scopeProvider); + + private void OnOptionsChanged(TjiddeLoggerOptions options, string? name) { - var options = _optionsMonitor.CurrentValue; - // Pass the accessor rather than its current keys, so keys added later reach this logger too - var masker = new SensitiveDataMasker( - options.MaskPlaceholder, - options.AdditionalSensitiveKeys, - _maskedKeysAccessor); - var exceptionFormatter = new ExceptionFormatter(options.IncludeStackTrace, options.IncludeInnerExceptions); - - return new TjiddeLogger( - categoryName, - options, - _customerContextAccessor, - masker, - exceptionFormatter, - _processor, - _scopeProvider); + // Only the default (unnamed) options configure this provider + if (!string.IsNullOrEmpty(name)) + return; + + TjiddeLoggerConfiguration updated; + try + { + updated = TjiddeLoggerConfiguration.Create(options, _maskedKeysAccessor); + } + catch (Exception) + { + // Keep logging with the previous configuration rather than fail the configuration reload. + return; + } + + TjiddeLoggerConfiguration previous; + lock (_sync) + { + if (_disposed) + { + updated.Dispose(); + return; + } + + previous = _configuration.Exchange(updated); + } + + previous.Dispose(); } } diff --git a/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs b/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs new file mode 100644 index 0000000..718430b --- /dev/null +++ b/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tjidde.Logging.Context; +using Tjidde.Logging.Extensions; +using Tjidde.Logging.Logging; +using Tjidde.Logging.Masking; +using Tjidde.Logging.Options; +using Xunit; + +namespace Tjidde.Logging.Tests; + +/// +/// Options changes (for example appsettings.json with reloadOnChange) apply to loggers that already exist. +/// +[Collection("ConsoleOutput")] +public sealed class OptionsReloadTests +{ + private static string[] CaptureConsoleLines(Action action) + { + var original = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + try + { + action(); + } + finally + { + Console.SetOut(original); + } + + return writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + } + + [Fact] + public void ConfigurationReload_AppliesOutputFormatMinimumLevelAndPlaceholderToExistingLogger() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TjiddeLogger:OutputFormat"] = "Text", + ["TjiddeLogger:MinimumLevel"] = "Information", + ["TjiddeLogger:MaskPlaceholder"] = "[REDACTED]" + }) + .Build(); + + bool enabledBefore = true, enabledAfter = false; + var lines = CaptureConsoleLines(() => + { + var services = new ServiceCollection(); + // Trace, so only Tjidde's own minimum level filters (the framework default is Information) + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace).AddTjiddeLogger(configuration)); + using var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService().CreateLogger("My.App.ReloadService"); + + enabledBefore = logger.IsEnabled(LogLevel.Debug); + logger.LogDebug("dropped before reload"); + logger.LogInformation("before reload password=abc123"); + + configuration["TjiddeLogger:OutputFormat"] = "Json"; + configuration["TjiddeLogger:MinimumLevel"] = "Debug"; + configuration["TjiddeLogger:MaskPlaceholder"] = "***"; + configuration.Reload(); + + enabledAfter = logger.IsEnabled(LogLevel.Debug); + logger.LogDebug("after reload password=abc123"); + }); + + enabledBefore.Should().BeFalse(); + enabledAfter.Should().BeTrue(); + + lines.Should().HaveCount(2, "the debug entry before the reload is below the minimum level"); + lines[0].Should().StartWith("20").And.EndWith("before reload password=[REDACTED]"); + + using var json = JsonDocument.Parse(lines[1]); + json.RootElement.GetProperty("level").GetString().Should().Be("DEBUG"); + json.RootElement.GetProperty("message").GetString().Should().Be("after reload password=***"); + } + + [Fact] + public void OptionsMonitorChange_AppliesCategoryLevelsToExistingLogger() + { + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions { MinimumLevel = LogLevel.Information }); + using var provider = new TjiddeLoggerProvider(monitor, new AsyncLocalCustomerContextAccessor(), new GlobalMaskedKeysAccessor()); + var logger = provider.CreateLogger("My.App.Service"); + + logger.IsEnabled(LogLevel.Debug).Should().BeFalse(); + + var updated = new TjiddeLoggerOptions { MinimumLevel = LogLevel.Information }; + updated.CategoryMinimumLevels["My.App"] = LogLevel.Trace; + monitor.Set(updated); + + logger.IsEnabled(LogLevel.Trace).Should().BeTrue(); + + monitor.Set(new TjiddeLoggerOptions { MinimumLevel = LogLevel.Error }); + + logger.IsEnabled(LogLevel.Warning).Should().BeFalse(); + logger.IsEnabled(LogLevel.Error).Should().BeTrue(); + } + + [Fact] + public void OptionsMonitorChange_ForNamedOptions_IsIgnored() + { + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions { MinimumLevel = LogLevel.Information }); + using var provider = new TjiddeLoggerProvider(monitor, new AsyncLocalCustomerContextAccessor(), new GlobalMaskedKeysAccessor()); + var logger = provider.CreateLogger("My.App.Service"); + + monitor.Set(new TjiddeLoggerOptions { MinimumLevel = LogLevel.Trace }, name: "Other"); + + logger.IsEnabled(LogLevel.Debug).Should().BeFalse(); + } + + [Fact] + public void Dispose_StopsListeningForOptionsChanges() + { + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions()); + var provider = new TjiddeLoggerProvider(monitor, new AsyncLocalCustomerContextAccessor(), new GlobalMaskedKeysAccessor()); + monitor.ListenerCount.Should().Be(1); + + provider.Dispose(); + + monitor.ListenerCount.Should().Be(0); + monitor.Invoking(m => m.Set(new TjiddeLoggerOptions())).Should().NotThrow(); + } + + /// An whose value the test replaces explicitly. + private sealed class TestOptionsMonitor : IOptionsMonitor + { + private readonly List> _listeners = []; + + public TestOptionsMonitor(TjiddeLoggerOptions initial) => CurrentValue = initial; + + public TjiddeLoggerOptions CurrentValue { get; private set; } + + public int ListenerCount + { + get + { + lock (_listeners) + return _listeners.Count; + } + } + + public TjiddeLoggerOptions Get(string? name) => CurrentValue; + + public IDisposable OnChange(Action listener) + { + lock (_listeners) + _listeners.Add(listener); + + return new Registration(() => + { + lock (_listeners) + _listeners.Remove(listener); + }); + } + + public void Set(TjiddeLoggerOptions options, string name = "") + { + if (name.Length == 0) + CurrentValue = options; + + Action[] listeners; + lock (_listeners) + listeners = _listeners.ToArray(); + + foreach (var listener in listeners) + listener(options, name); + } + + private sealed class Registration(Action dispose) : IDisposable + { + public void Dispose() => dispose(); + } + } +} From ede726b7253a1553ecda363aa25f79b74c067288 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 20:54:20 +0200 Subject: [PATCH 10/28] Share one masker per configuration and cache the minimum level Loggers already get their masker from the shared configuration object, so all categories use one SensitiveDataMasker and one set of regexes. Make that masker safe for concurrent use while MaskedKeysContext changes: the runtime key set is built from a copy, compared by content unless the accessor hands out immutable snapshots, and published with CompareExchange so a concurrent rebuild never overwrites a newer set. This also fixes custom accessors that return one mutable collection. IsEnabled now caches the resolved minimum level per logger together with the configuration it came from, so it is recomputed only after an options change. Tests cover concurrent logging while keys are added and removed, the shared masker across loggers and options changes, and the mutable accessor case. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + src/Tjidde.Logging/Logging/TjiddeLogger.cs | 30 +++- .../Masking/SensitiveDataMasker.cs | 35 +++-- .../OptionsReloadTests.cs | 51 ------- .../Tjidde.Logging.Tests/SharedMaskerTests.cs | 140 ++++++++++++++++++ .../TestOptionsMonitor.cs | 55 +++++++ 6 files changed, 252 insertions(+), 62 deletions(-) create mode 100644 tests/Tjidde.Logging.Tests/SharedMaskerTests.cs create mode 100644 tests/Tjidde.Logging.Tests/TestOptionsMonitor.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f88f24..901c571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - Options changes now apply to existing loggers immediately, without a restart: the provider listens to `IOptionsMonitor.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. +- `IsEnabled` caches the resolved minimum level per logger until the options change, instead of matching `CategoryMinimumLevels` on every call. ### 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 `MaskedKeysContext` snapshots are compared by reference), builds from a copy, and no longer lets a concurrent rebuild overwrite a newer key set. ### Deprecated - `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. diff --git a/src/Tjidde.Logging/Logging/TjiddeLogger.cs b/src/Tjidde.Logging/Logging/TjiddeLogger.cs index 42459e5..cbb864f 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLogger.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLogger.cs @@ -31,6 +31,10 @@ internal sealed class TjiddeLogger : ILogger private readonly ConsoleLogProcessor _processor; private IExternalScopeProvider? _scopeProvider; + // Minimum level resolved for this category, together with the configuration it was resolved from. + // Replaced (not mutated) when the configuration changes, so readers always see a matching pair. + private MinimumLevelCache? _minimumLevel; + // Extracted from category name for readable output private readonly string _className; @@ -71,6 +75,9 @@ public TjiddeLogger( { } + /// The configuration this logger currently uses. + internal TjiddeLoggerConfiguration Configuration => _configuration.Current; + /// Updates the scope provider — called by the provider when the external scope provider is set. internal void SetScopeProvider(IExternalScopeProvider scopeProvider) => _scopeProvider = scopeProvider; @@ -89,7 +96,28 @@ private bool IsEnabled(TjiddeLoggerConfiguration config, LogLevel logLevel) } private LogLevel GetMinimumLevel(TjiddeLoggerConfiguration config) - => config.ResolveMinimumLevel(_categoryName); + { + var cached = Volatile.Read(ref _minimumLevel); + if (cached is not null && ReferenceEquals(cached.Configuration, config)) + return cached.Level; + + // First call, or the configuration changed: resolve once and reuse until the next change. + var level = config.ResolveMinimumLevel(_categoryName); + Volatile.Write(ref _minimumLevel, new MinimumLevelCache(config, level)); + return level; + } + + private sealed class MinimumLevelCache + { + public MinimumLevelCache(TjiddeLoggerConfiguration configuration, LogLevel level) + { + Configuration = configuration; + Level = level; + } + + public TjiddeLoggerConfiguration Configuration { get; } + public LogLevel Level { get; } + } public void Log( LogLevel logLevel, diff --git a/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs b/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs index eec487d..66e9334 100644 --- a/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs +++ b/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs @@ -36,6 +36,8 @@ public sealed class SensitiveDataMasker : ISensitiveDataMasker private readonly TimeSpan _matchTimeout; private readonly KeySet _keys; private readonly IMaskedKeysAccessor? _runtimeKeysAccessor; + // True when the accessor hands out immutable snapshots (MaskedKeysContext), so an unchanged reference means unchanged keys. + private readonly bool _runtimeKeysAreSnapshots; private KeySet? _runtimeKeys; /// @@ -71,6 +73,7 @@ internal SensitiveDataMasker( _placeholder = placeholder; _matchTimeout = matchTimeout; _runtimeKeysAccessor = runtimeKeysAccessor; + _runtimeKeysAreSnapshots = runtimeKeysAccessor is GlobalMaskedKeysAccessor; var literals = dynamicKeys?.ToArray() ?? []; _keys = new KeySet( @@ -140,12 +143,16 @@ public IReadOnlyDictionary MaskDictionary(IEnumerable _patterns = []; private readonly string[] _literals; private readonly string _placeholder; - private readonly IReadOnlyCollection? _source; + private readonly object? _sourceReference; + private readonly bool _hasSource; private readonly HashSet _sourceKeys; public KeySet( @@ -174,11 +182,13 @@ public KeySet( IEnumerable literals, string placeholder, TimeSpan matchTimeout, - IReadOnlyCollection? source = null) + IReadOnlyCollection? source = null, + object? sourceReference = null) { _placeholder = placeholder; _literals = literals.Where(literal => !string.IsNullOrWhiteSpace(literal)).ToArray(); - _source = source; + _sourceReference = sourceReference; + _hasSource = source is not null; _sourceKeys = new HashSet(source ?? [], StringComparer.OrdinalIgnoreCase); foreach (var key in keys) @@ -207,8 +217,13 @@ public KeySet( public bool Contains(string normalizedKey) => _normalizedKeys.Contains(normalizedKey); - public bool IsBuiltFrom(IReadOnlyCollection keys) - => ReferenceEquals(_source, keys) || (_source is not null && _sourceKeys.SetEquals(keys)); + /// + /// Whether this set was built from the same keys. The reference check is only trusted for immutable + /// snapshots; any other collection is compared by content. + /// + public bool IsBuiltFrom(IReadOnlyCollection keys, bool referenceMeansUnchanged) + => _hasSource + && ((referenceMeansUnchanged && ReferenceEquals(_sourceReference, keys)) || _sourceKeys.SetEquals(keys)); public string MaskPatterns(string message) { diff --git a/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs b/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs index 718430b..db17a3e 100644 --- a/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs +++ b/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs @@ -126,55 +126,4 @@ public void Dispose_StopsListeningForOptionsChanges() monitor.ListenerCount.Should().Be(0); monitor.Invoking(m => m.Set(new TjiddeLoggerOptions())).Should().NotThrow(); } - - /// An whose value the test replaces explicitly. - private sealed class TestOptionsMonitor : IOptionsMonitor - { - private readonly List> _listeners = []; - - public TestOptionsMonitor(TjiddeLoggerOptions initial) => CurrentValue = initial; - - public TjiddeLoggerOptions CurrentValue { get; private set; } - - public int ListenerCount - { - get - { - lock (_listeners) - return _listeners.Count; - } - } - - public TjiddeLoggerOptions Get(string? name) => CurrentValue; - - public IDisposable OnChange(Action listener) - { - lock (_listeners) - _listeners.Add(listener); - - return new Registration(() => - { - lock (_listeners) - _listeners.Remove(listener); - }); - } - - public void Set(TjiddeLoggerOptions options, string name = "") - { - if (name.Length == 0) - CurrentValue = options; - - Action[] listeners; - lock (_listeners) - listeners = _listeners.ToArray(); - - foreach (var listener in listeners) - listener(options, name); - } - - private sealed class Registration(Action dispose) : IDisposable - { - public void Dispose() => dispose(); - } - } } diff --git a/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs b/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs new file mode 100644 index 0000000..88c2494 --- /dev/null +++ b/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs @@ -0,0 +1,140 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Context; +using Tjidde.Logging.Logging; +using Tjidde.Logging.Masking; +using Tjidde.Logging.Options; +using Xunit; + +namespace Tjidde.Logging.Tests; + +/// +/// All loggers of a provider share one masker per configuration, and that masker stays correct when +/// MaskedKeysContext changes while other threads log. +/// +// MaskedKeysContext is global state and these tests capture Console output, so they share the sequential collection. +[Collection("ConsoleOutput")] +public sealed class SharedMaskerTests +{ + [Fact] + public void Loggers_ShareOneMaskerPerConfiguration_AndSwitchTogetherOnOptionsChange() + { + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions()); + using var provider = new TjiddeLoggerProvider(monitor, new AsyncLocalCustomerContextAccessor(), new GlobalMaskedKeysAccessor()); + var first = (TjiddeLogger)provider.CreateLogger("My.App.First"); + var second = (TjiddeLogger)provider.CreateLogger("My.App.Second"); + + var before = first.Configuration; + second.Configuration.Should().BeSameAs(before); + + monitor.Set(new TjiddeLoggerOptions { MaskPlaceholder = "***" }); + + first.Configuration.Should().NotBeSameAs(before); + first.Configuration.Masker.Should().NotBeSameAs(before.Masker); + second.Configuration.Masker.Should().BeSameAs(first.Configuration.Masker); + first.Configuration.Masker.MaskValue("password", "secret").Should().Be("***"); + } + + [Fact] + public void ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows() + { + var id = Guid.NewGuid().ToString("N"); + var permanentKey = $"perm-{id}"; + var churnKeys = Enumerable.Range(0, 8).Select(i => $"churn-{id}-{i}").ToArray(); + const int threads = 8; + const int entriesPerThread = 200; + + var original = Console.Out; + using var writer = new StringWriter(); + var errors = new List(); + MaskedKeysContext.Add(permanentKey); + try + { + Console.SetOut(writer); + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions()); + using (var provider = new TjiddeLoggerProvider(monitor, new AsyncLocalCustomerContextAccessor(), new GlobalMaskedKeysAccessor())) + { + using var stop = new CancellationTokenSource(); + var churn = Task.Run(() => + { + var i = 0; + while (!stop.IsCancellationRequested) + { + var key = churnKeys[i++ % churnKeys.Length]; + MaskedKeysContext.Add(key); + MaskedKeysContext.Remove(key); + } + }); + + var loggers = Enumerable.Range(0, threads) + .Select(t => Task.Run(() => + { + // Different categories, one shared masker + var logger = provider.CreateLogger($"My.App.Worker{t % 3}"); + for (var n = 0; n < entriesPerThread; n++) + { + logger.LogInformation("value {PermanentKey} password=hunter2 entry {N}", permanentKey, n); + logger.LogInformation("{" + permanentKey + "} is sensitive", "structured-secret"); + } + })) + .ToArray(); + + try + { + Task.WaitAll(loggers); + } + catch (AggregateException ex) + { + errors.AddRange(ex.InnerExceptions); + } + finally + { + stop.Cancel(); + churn.Wait(); + } + } + } + finally + { + Console.SetOut(original); + MaskedKeysContext.Remove(permanentKey); + MaskedKeysContext.Remove(churnKeys); + } + + errors.Should().BeEmpty(); + + var lines = writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + lines.Should().HaveCount(threads * entriesPerThread * 2); + // The [Masked: ...] suffix names the masked property on purpose; the key must not appear anywhere else + var bodies = lines.Select(line => line.Replace($"[Masked: {permanentKey}]", string.Empty)).ToArray(); + bodies.Should().NotContain(line => line.Contains(permanentKey, StringComparison.OrdinalIgnoreCase)); + lines.Count(line => line.EndsWith($"[REDACTED] is sensitive [Masked: {permanentKey}]")).Should().Be(threads * entriesPerThread); + lines.Should().NotContain(line => line.Contains("hunter2")); + lines.Should().NotContain(line => line.Contains("structured-secret")); + lines.Should().NotContain(line => line.Contains("could not be rendered")); + } + + [Fact] + public void Masker_WithAccessorThatReusesOneMutableCollection_PicksUpChanges() + { + var accessor = new MutableKeysAccessor(); + var masker = new SensitiveDataMasker("[REDACTED]", additionalKeys: null, accessor); + + masker.IsSensitiveKey("customerCode").Should().BeFalse(); + + accessor.Keys.Add("customerCode"); + masker.IsSensitiveKey("customerCode").Should().BeTrue(); + masker.MaskMessage("customerCode=12345").Should().NotContain("12345"); + + accessor.Keys.Remove("customerCode"); + masker.IsSensitiveKey("customerCode").Should().BeFalse(); + } + + /// Returns the same mutable list on every call, unlike 's snapshots. + private sealed class MutableKeysAccessor : IMaskedKeysAccessor + { + public List Keys { get; } = []; + + public IReadOnlyCollection GetKeys() => Keys; + } +} diff --git a/tests/Tjidde.Logging.Tests/TestOptionsMonitor.cs b/tests/Tjidde.Logging.Tests/TestOptionsMonitor.cs new file mode 100644 index 0000000..539d7cf --- /dev/null +++ b/tests/Tjidde.Logging.Tests/TestOptionsMonitor.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Options; +using Tjidde.Logging.Options; + +namespace Tjidde.Logging.Tests; + +/// An whose value the test replaces explicitly. +internal sealed class TestOptionsMonitor : IOptionsMonitor +{ + private readonly List> _listeners = []; + + public TestOptionsMonitor(TjiddeLoggerOptions initial) => CurrentValue = initial; + + public TjiddeLoggerOptions CurrentValue { get; private set; } + + public int ListenerCount + { + get + { + lock (_listeners) + return _listeners.Count; + } + } + + public TjiddeLoggerOptions Get(string? name) => CurrentValue; + + public IDisposable OnChange(Action listener) + { + lock (_listeners) + _listeners.Add(listener); + + return new Registration(() => + { + lock (_listeners) + _listeners.Remove(listener); + }); + } + + public void Set(TjiddeLoggerOptions options, string name = "") + { + if (name.Length == 0) + CurrentValue = options; + + Action[] listeners; + lock (_listeners) + listeners = _listeners.ToArray(); + + foreach (var listener in listeners) + listener(options, name); + } + + private sealed class Registration(Action dispose) : IDisposable + { + public void Dispose() => dispose(); + } +} From d6bc8371a7c0582831a5b8c55b23a297153678d9 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 21:00:40 +0200 Subject: [PATCH 11/28] Take timestamps from TimeProvider and add UseUtcTimestamp TjiddeLogger used DateTime.Now. Timestamps now come from System.TimeProvider: the one registered in DI, or TimeProvider.System. TjiddeLoggerProvider gets a constructor overload that takes a TimeProvider; the existing constructor keeps working. AddTjiddeLogger registers the provider through a factory, so the container never has to choose between the constructors. net7.0 references Microsoft.Bcl.TimeProvider. New option UseUtcTimestamp (default false) writes UTC instead of local time; JSON @timestamp is ISO-8601 with the offset. A throwing TimeProvider falls back to the system clock, so logging still never throws. Tests use FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing, 8.x for net7.0/net8.0). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + README.md | 3 + docs/AI-INTEGRATION.md | 7 +- docs/wiki-en.md | 27 ++ docs/wiki-nl.md | 27 ++ .../TjiddeLoggingBuilderExtensions.cs | 12 +- src/Tjidde.Logging/Logging/TjiddeLogger.cs | 44 +++- .../Logging/TjiddeLoggerProvider.cs | 23 +- .../Options/TjiddeLoggerOptions.cs | 9 + src/Tjidde.Logging/Tjidde.Logging.csproj | 2 + tests/Tjidde.Logging.Tests/TimestampTests.cs | 240 ++++++++++++++++++ .../Tjidde.Logging.Tests.csproj | 5 + 12 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 tests/Tjidde.Logging.Tests/TimestampTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 901c571..fe17308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### 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, ICustomerContextAccessor, IMaskedKeysAccessor, TimeProvider)`; the existing constructor still works and uses `TimeProvider.System`. On .NET 7 this adds a dependency on `Microsoft.Bcl.TimeProvider`. ### Changed - Options changes now apply to existing loggers immediately, without a restart: the provider listens to `IOptionsMonitor.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. ### Fixed diff --git a/README.md b/README.md index 8e78b81..15418f3 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ YYYY-MM-DD: HH:mm:ss: [LEVEL] Class=>ClassName Method=>MethodName: Client=>Custo - `METRICS` is shown for entries written with `_logger.LogMetrics(...)`. They are logged at `LogLevel.Information` with event `Metrics` (id 10000), so other providers such as `AddConsole()` see a normal information entry. For real application metrics, use `System.Diagnostics.Metrics`. - `Method=>` is included when set via `using (_logger.BeginMethodScope())` (or a scope with key `MethodName`) - `Client=>` is omitted when no customer context is active +- The timestamp is local time by default; set `UseUtcTimestamp = true` for UTC. JSON output writes `@timestamp` as ISO-8601 with the offset (for example `2026-10-02T10:15:00.0000000+00:00`) +- The clock is the `TimeProvider` registered in DI (for example a `FakeTimeProvider` in tests), or `TimeProvider.System` when none is registered --- @@ -165,6 +167,7 @@ YYYY-MM-DD: HH:mm:ss: [LEVEL] Class=>ClassName Method=>MethodName: Client=>Custo | Option | Type | Default | Description | |--------|------|---------|-------------| | `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 | diff --git a/docs/AI-INTEGRATION.md b/docs/AI-INTEGRATION.md index 245e60d..a194f11 100644 --- a/docs/AI-INTEGRATION.md +++ b/docs/AI-INTEGRATION.md @@ -233,9 +233,9 @@ Full shape, where segments without a value are left out: yyyy-MM-dd: HH:mm:ss: [LEVEL] Class=>ClassName Method=>MethodName: Client=>Customer: Message [Masked: Key1, Key2] | Exception: … | Scopes: a > b ``` -Levels: `TRACE`, `DEBUG`, `INFORMATION`, `WARNING`, `ERROR`, `CRITICAL`, `METRICS` (`METRICS` = an `Information` entry with event name `Metrics`). Timestamps are local time. Each level gets its own color unless the output is redirected. +Levels: `TRACE`, `DEBUG`, `INFORMATION`, `WARNING`, `ERROR`, `CRITICAL`, `METRICS` (`METRICS` = an `Information` entry with event name `Metrics`). Timestamps are local time unless `UseUtcTimestamp` is `true`; the text format has no offset. Each level gets its own color unless the output is redirected. -JSON (`OutputFormat: Json`), meant for log shippers such as Elasticsearch or Loki. Shown formatted here; the real output is one object per line, with standard JSON escaping (`+` can appear as `+`): +JSON (`OutputFormat: Json`), meant for log shippers such as Elasticsearch or Loki. `@timestamp` is ISO-8601 with the offset (`+00:00` with `UseUtcTimestamp`). Shown formatted here; the real output is one object per line, with standard JSON escaping (`+` can appear as `+`): ```json { @@ -272,6 +272,8 @@ A background thread writes the lines to the console, so log calls do not wait fo ## Testing code that uses it +The clock is the `TimeProvider` registered in DI, or `TimeProvider.System` when none is registered. To assert on timestamps, register a `FakeTimeProvider` (package `Microsoft.Extensions.TimeProvider.Testing`) with `services.AddSingleton(clock)`. + In unit tests, inject `NullLogger.Instance` (from `Microsoft.Extensions.Logging.Abstractions`) instead of the Tjidde logger. If a test must read the console output, dispose the `LoggerFactory` first so all lines have been written. ## Options reference @@ -284,6 +286,7 @@ Section name in `appsettings.json`: `TjiddeLogger`. | `MinimumLevel` | `LogLevel` | `Trace` | Minimum level when no category rule matches. | | `CategoryMinimumLevels` | `IDictionary` | empty | Minimum level per category or namespace prefix; the key `Default` is the fallback. | | `IncludeScopes` | `bool` | `true` | Append scope values to each entry. Also required for `BeginMethodScope`. | +| `UseUtcTimestamp` | `bool` | `false` | Write timestamps in UTC instead of local time. | | `ResolveMethodNameFromStackTrace` | `bool` | `false` | Find the method name from the stack trace when no method scope is active. Slow. | | `IncludeStackTrace` | `bool` | `true` | Include stack traces in exception output. | | `IncludeInnerExceptions` | `bool` | `true` | Include inner exceptions in exception output. | diff --git a/docs/wiki-en.md b/docs/wiki-en.md index 7b1f6c7..f8db0f4 100644 --- a/docs/wiki-en.md +++ b/docs/wiki-en.md @@ -210,6 +210,7 @@ Pass an `Action` delegate to `AddTjiddeLogger` to customise builder.Logging.AddTjiddeLogger(options => { options.IncludeScopes = true; // Include scope info in output + options.UseUtcTimestamp = false; // true: timestamps in UTC instead of local time options.IncludeStackTrace = true; // Include stack trace in exception output options.IncludeInnerExceptions = true; // Include inner exceptions options.EnableSensitiveDataMasking = true; // Mask sensitive values @@ -229,6 +230,7 @@ builder.Logging.AddTjiddeLogger(options => | `OpenTelemetryActivitySourceName` | `string` | `Tjidde.Logging` | Activity source name used for optional fallback activity creation. | | `OpenTelemetryCreateFallbackActivity` | `bool` | `false` | Creates a short-lived internal activity if no current `Activity` exists. | | `IncludeScopes` | `bool` | `true` | Appends active scope values to the log line. | +| `UseUtcTimestamp` | `bool` | `false` | Writes timestamps in UTC instead of local time. See [Timestamps and `TimeProvider`](#timestamps-and-timeprovider). | | `ResolveMethodNameFromStackTrace` | `bool` | `false` | Falls back to the stack trace for the method name when no `MethodName` scope is active. Walks the stack on every log call, so it is slow; prefer `BeginMethodScope()`. | | `IncludeStackTrace` | `bool` | `true` | Includes the stack trace when an exception is logged. | | `IncludeInnerExceptions` | `bool` | `true` | Includes inner exceptions in the formatted output. | @@ -263,6 +265,31 @@ Example shape: } ``` +### Timestamps and `TimeProvider` + +Timestamps are local time by default. Set `UseUtcTimestamp` to write UTC instead, which is usually what you want when logs from servers in different time zones end up in one place: + +```json +{ + "TjiddeLogger": { + "UseUtcTimestamp": true + } +} +``` + +- Text output keeps the `yyyy-MM-dd: HH:mm:ss` format without an offset, so check `UseUtcTimestamp` when you read it. +- JSON output writes `@timestamp` as ISO-8601 with the offset: `2026-10-02T10:15:00.0000000+00:00` with `UseUtcTimestamp`, the local offset (for example `+02:00`) without it. + +The clock is a `System.TimeProvider`. When one is registered in DI, Tjidde.Logging uses it; otherwise it uses `TimeProvider.System`. In tests you can register a `FakeTimeProvider` (package `Microsoft.Extensions.TimeProvider.Testing`) to get predictable timestamps: + +```csharp +var clock = new FakeTimeProvider(new DateTimeOffset(2026, 10, 2, 10, 15, 0, TimeSpan.Zero)); +services.AddSingleton(clock); +services.AddLogging(logging => logging.AddTjiddeLogger(options => options.UseUtcTimestamp = true)); +``` + +Without DI, pass it to the constructor: `new TjiddeLoggerProvider(optionsMonitor, customerContextAccessor, maskedKeysAccessor, clock)`. + ### OpenTelemetry integration Enable OpenTelemetry export to attach each Tjidde log entry as an event to the active trace `Activity`. diff --git a/docs/wiki-nl.md b/docs/wiki-nl.md index 3dcd956..d58bab3 100644 --- a/docs/wiki-nl.md +++ b/docs/wiki-nl.md @@ -210,6 +210,7 @@ Geef een `Action`-delegate mee aan `AddTjiddeLogger` om het builder.Logging.AddTjiddeLogger(options => { options.IncludeScopes = true; // Scope-informatie opnemen in uitvoer + options.UseUtcTimestamp = false; // true: tijdstempels in UTC in plaats van lokale tijd options.IncludeStackTrace = true; // Stack trace opnemen bij uitzonderingen options.IncludeInnerExceptions = true; // Inner exceptions opnemen options.EnableSensitiveDataMasking = true; // Gevoelige waarden maskeren @@ -229,6 +230,7 @@ builder.Logging.AddTjiddeLogger(options => | `OpenTelemetryActivitySourceName` | `string` | `Tjidde.Logging` | Activity source-naam voor optionele fallback-activity creatie. | | `OpenTelemetryCreateFallbackActivity` | `bool` | `false` | Maakt een korte interne activity als er geen huidige `Activity` beschikbaar is. | | `IncludeScopes` | `bool` | `true` | Voegt actieve scope-waarden toe aan de logregel. | +| `UseUtcTimestamp` | `bool` | `false` | Schrijft tijdstempels in UTC in plaats van lokale tijd. Zie [Tijdstempels en `TimeProvider`](#tijdstempels-en-timeprovider). | | `ResolveMethodNameFromStackTrace` | `bool` | `false` | Haalt de methodenaam uit de stack trace als er geen `MethodName`-scope actief is. Doorloopt bij elke logregel de stack en is dus traag; gebruik liever `BeginMethodScope()`. | | `IncludeStackTrace` | `bool` | `true` | Neemt de stack trace op bij het loggen van uitzonderingen. | | `IncludeInnerExceptions` | `bool` | `true` | Neemt inner exceptions op in de opgemaakte uitvoer. | @@ -263,6 +265,31 @@ Voorbeeldvorm: } ``` +### Tijdstempels en `TimeProvider` + +Tijdstempels zijn standaard lokale tijd. Zet `UseUtcTimestamp` aan om UTC te schrijven; meestal handig als logs van servers in verschillende tijdzones op één plek samenkomen: + +```json +{ + "TjiddeLogger": { + "UseUtcTimestamp": true + } +} +``` + +- Tekstuitvoer houdt het formaat `yyyy-MM-dd: HH:mm:ss` zonder offset; let bij het lezen dus op `UseUtcTimestamp`. +- JSON-uitvoer schrijft `@timestamp` als ISO-8601 met offset: `2026-10-02T10:15:00.0000000+00:00` met `UseUtcTimestamp`, zonder die optie de lokale offset (bijvoorbeeld `+02:00`). + +De klok is een `System.TimeProvider`. Is er een geregistreerd in DI, dan gebruikt Tjidde.Logging die; anders `TimeProvider.System`. In tests registreer je een `FakeTimeProvider` (package `Microsoft.Extensions.TimeProvider.Testing`) voor voorspelbare tijdstempels: + +```csharp +var clock = new FakeTimeProvider(new DateTimeOffset(2026, 10, 2, 10, 15, 0, TimeSpan.Zero)); +services.AddSingleton(clock); +services.AddLogging(logging => logging.AddTjiddeLogger(options => options.UseUtcTimestamp = true)); +``` + +Zonder DI geef je hem mee aan de constructor: `new TjiddeLoggerProvider(optionsMonitor, customerContextAccessor, maskedKeysAccessor, clock)`. + ### OpenTelemetry-integratie Schakel OpenTelemetry-export in om elke Tjidde-logregel als event toe te voegen aan de actieve trace-`Activity`. diff --git a/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs b/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs index f56061c..4671b48 100644 --- a/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs +++ b/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs @@ -79,9 +79,10 @@ public static ILoggingBuilder AddTjiddeLogger(this ILoggingBuilder builder, Acti // Register the default masked keys accessor builder.Services.TryAddSingleton(); - // Register the provider + // Register the provider through a factory, so the constructor choice never depends on the container: + // a TimeProvider registered in DI is used for timestamps, otherwise TimeProvider.System. builder.Services.TryAddEnumerable( - ServiceDescriptor.Singleton()); + ServiceDescriptor.Singleton(CreateProvider)); return builder; } @@ -112,6 +113,13 @@ public static ILoggingBuilder UseTjiddeTextFormat(this ILoggingBuilder builder) return builder; } + private static TjiddeLoggerProvider CreateProvider(IServiceProvider services) + => new( + services.GetRequiredService>(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetService() ?? TimeProvider.System); + /// Signals an options change when the bound configuration section reloads. private sealed class ConfigurationSectionChangeTokenSource : IOptionsChangeTokenSource { diff --git a/src/Tjidde.Logging/Logging/TjiddeLogger.cs b/src/Tjidde.Logging/Logging/TjiddeLogger.cs index cbb864f..3a51827 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLogger.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLogger.cs @@ -29,6 +29,7 @@ internal sealed class TjiddeLogger : ILogger private readonly TjiddeLoggerConfigurationHolder _configuration; private readonly ICustomerContextAccessor _customerContextAccessor; private readonly ConsoleLogProcessor _processor; + private readonly TimeProvider _timeProvider; private IExternalScopeProvider? _scopeProvider; // Minimum level resolved for this category, together with the configuration it was resolved from. @@ -41,19 +42,22 @@ internal sealed class TjiddeLogger : ILogger /// /// Creates a logger that reads its settings from on every call, /// so a configuration change applies to this logger immediately. + /// Timestamps come from (default: ). /// public TjiddeLogger( string categoryName, TjiddeLoggerConfigurationHolder configuration, ICustomerContextAccessor customerContextAccessor, ConsoleLogProcessor processor, - IExternalScopeProvider? scopeProvider) + IExternalScopeProvider? scopeProvider, + TimeProvider? timeProvider = null) { _categoryName = categoryName; _configuration = configuration; _customerContextAccessor = customerContextAccessor; _processor = processor; _scopeProvider = scopeProvider; + _timeProvider = timeProvider ?? TimeProvider.System; _className = ExtractClassName(categoryName); } @@ -65,13 +69,15 @@ public TjiddeLogger( ISensitiveDataMasker masker, IExceptionFormatter exceptionFormatter, ConsoleLogProcessor processor, - IExternalScopeProvider? scopeProvider) + IExternalScopeProvider? scopeProvider, + TimeProvider? timeProvider = null) : this( categoryName, new TjiddeLoggerConfigurationHolder(new TjiddeLoggerConfiguration(options, masker, exceptionFormatter)), customerContextAccessor, processor, - scopeProvider) + scopeProvider, + timeProvider) { } @@ -135,7 +141,7 @@ public void Log( return; // Logging must never throw: any failure while building the entry produces a minimal line instead. - var now = DateTime.Now; + var now = GetTimestamp(config); string? message = null; try { @@ -147,9 +153,27 @@ public void Log( } } + /// + /// The timestamp for an entry: UTC when is set, otherwise + /// local time (the time provider's local time zone). Falls back to the system clock if the provider throws. + /// + private DateTimeOffset GetTimestamp(TjiddeLoggerConfiguration config) + { + var utc = config.Options.UseUtcTimestamp; + try + { + return utc ? _timeProvider.GetUtcNow() : _timeProvider.GetLocalNow(); + } + catch (Exception) + { + // Logging must never throw, not even when a custom TimeProvider does. + return utc ? DateTimeOffset.UtcNow : DateTimeOffset.Now; + } + } + private void WriteEntry( TjiddeLoggerConfiguration config, - DateTime now, + DateTimeOffset now, LogLevel logLevel, EventId eventId, TState state, @@ -202,7 +226,7 @@ private void WriteEntry( /// private void WriteFallbackEntry( TjiddeLoggerConfiguration config, - DateTime now, + DateTimeOffset now, LogLevel logLevel, EventId eventId, TState state, @@ -409,7 +433,7 @@ private string FormatException(TjiddeLoggerConfiguration config, Exception excep private void ExportToOpenTelemetry( TjiddeLoggerConfiguration config, - DateTime timestamp, + DateTimeOffset timestamp, LogLevel logLevel, EventId eventId, string? methodName, @@ -499,7 +523,7 @@ private void ExportToOpenTelemetry( private string BuildTextLogLine( TjiddeLoggerConfiguration config, - DateTime timestamp, + DateTimeOffset timestamp, LogLevel logLevel, EventId eventId, string? methodName, @@ -526,7 +550,7 @@ private string BuildTextLogLine( private string BuildJsonLogLine( TjiddeLoggerConfiguration config, - DateTime timestamp, + DateTimeOffset timestamp, LogLevel logLevel, EventId eventId, string? methodName, @@ -586,7 +610,7 @@ private string BuildJsonLogLine( } private static string BuildLogLine( - DateTime timestamp, + DateTimeOffset timestamp, string level, string className, string? methodName, diff --git a/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs b/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs index b034e6b..d56b34b 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs @@ -18,6 +18,7 @@ public sealed class TjiddeLoggerProvider : ILoggerProvider, ISupportExternalScop private readonly ICustomerContextAccessor _customerContextAccessor; private readonly IMaskedKeysAccessor _maskedKeysAccessor; private readonly TjiddeLoggerConfigurationHolder _configuration; + private readonly TimeProvider _timeProvider; private readonly IDisposable? _optionsChangeRegistration; private readonly object _sync = new(); private readonly ConcurrentDictionary _loggers = new(StringComparer.OrdinalIgnoreCase); @@ -26,15 +27,33 @@ public sealed class TjiddeLoggerProvider : ILoggerProvider, ISupportExternalScop private bool _disposed; /// - /// Initializes a new instance of . + /// Initializes a new instance of that takes its timestamps from + /// . /// public TjiddeLoggerProvider( IOptionsMonitor optionsMonitor, ICustomerContextAccessor customerContextAccessor, IMaskedKeysAccessor maskedKeysAccessor) + : this(optionsMonitor, customerContextAccessor, maskedKeysAccessor, TimeProvider.System) { + } + + /// + /// Initializes a new instance of that takes its timestamps from + /// , for example a fake clock in tests. + /// + /// is . + public TjiddeLoggerProvider( + IOptionsMonitor optionsMonitor, + ICustomerContextAccessor customerContextAccessor, + IMaskedKeysAccessor maskedKeysAccessor, + TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _customerContextAccessor = customerContextAccessor; _maskedKeysAccessor = maskedKeysAccessor; + _timeProvider = timeProvider; _configuration = new TjiddeLoggerConfigurationHolder( TjiddeLoggerConfiguration.Create(optionsMonitor.CurrentValue, maskedKeysAccessor)); @@ -90,7 +109,7 @@ public void Dispose() { } } private TjiddeLogger CreateLoggerInstance(string categoryName) - => new(categoryName, _configuration, _customerContextAccessor, _processor, _scopeProvider); + => new(categoryName, _configuration, _customerContextAccessor, _processor, _scopeProvider, _timeProvider); private void OnOptionsChanged(TjiddeLoggerOptions options, string? name) { diff --git a/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs b/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs index 4fdeb8c..90df97e 100644 --- a/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs +++ b/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs @@ -27,6 +27,15 @@ public sealed class TjiddeLoggerOptions public IDictionary CategoryMinimumLevels { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// + /// Whether timestamps are written in UTC instead of local time. + /// Text output shows the time without an offset (yyyy-MM-dd: HH:mm:ss); JSON output writes + /// @timestamp as ISO-8601 with the offset (+00:00 in UTC, the local offset otherwise). + /// The clock is the registered in DI, or . + /// Default: false (local time). + /// + public bool UseUtcTimestamp { get; set; } + /// /// Whether to emit each log entry as an OpenTelemetry event on the current . /// Default: false. diff --git a/src/Tjidde.Logging/Tjidde.Logging.csproj b/src/Tjidde.Logging/Tjidde.Logging.csproj index 173f791..8ae6348 100644 --- a/src/Tjidde.Logging/Tjidde.Logging.csproj +++ b/src/Tjidde.Logging/Tjidde.Logging.csproj @@ -57,6 +57,8 @@ Full changelog: $(_ChangelogUrl) + + diff --git a/tests/Tjidde.Logging.Tests/TimestampTests.cs b/tests/Tjidde.Logging.Tests/TimestampTests.cs new file mode 100644 index 0000000..1de6239 --- /dev/null +++ b/tests/Tjidde.Logging.Tests/TimestampTests.cs @@ -0,0 +1,240 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Tjidde.Logging.Context; +using Tjidde.Logging.Extensions; +using Tjidde.Logging.Logging; +using Tjidde.Logging.Masking; +using Tjidde.Logging.Options; +using Xunit; + +namespace Tjidde.Logging.Tests; + +/// +/// Timestamps come from a (optionally from DI) and are local time by default, +/// or UTC with . +/// +[Collection("ConsoleOutput")] +public sealed class TimestampTests +{ + // 10:20:30 UTC = 12:20:30 in the fixed +02:00 test zone + private static readonly DateTimeOffset Start = new(2026, 3, 15, 10, 20, 30, TimeSpan.Zero); + + private static readonly TimeZoneInfo PlusTwo = + TimeZoneInfo.CreateCustomTimeZone("Tjidde.Test+02", TimeSpan.FromHours(2), "Test +02:00", "Test +02:00"); + + private static FakeTimeProvider CreateClock() + { + var clock = new FakeTimeProvider(Start); + clock.SetLocalTimeZone(PlusTwo); + return clock; + } + + private static string[] Capture(TjiddeLoggerOptions options, TimeProvider timeProvider, Action log) + { + var original = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + try + { + // Disposing the provider flushes its console writer + using var provider = new TjiddeLoggerProvider( + new TestOptionsMonitor(options), new AsyncLocalCustomerContextAccessor(), new GlobalMaskedKeysAccessor(), timeProvider); + log(provider.CreateLogger("My.App.ClockService")); + } + finally + { + Console.SetOut(original); + } + + return writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + } + + private static string JsonTimestamp(string line) + { + using var json = JsonDocument.Parse(line); + return json.RootElement.GetProperty("@timestamp").GetString()!; + } + + [Fact] + public void UseUtcTimestamp_DefaultsToFalse() + { + new TjiddeLoggerOptions().UseUtcTimestamp.Should().BeFalse(); + } + + [Fact] + public void Text_ByDefault_WritesLocalTimeOfTimeProvider() + { + var lines = Capture(new TjiddeLoggerOptions(), CreateClock(), logger => logger.LogInformation("hello")); + + lines.Should().ContainSingle().Which.Should().StartWith("2026-03-15: 12:20:30: [INFORMATION]"); + } + + [Fact] + public void Text_WithUseUtcTimestamp_WritesUtcTime() + { + var lines = Capture(new TjiddeLoggerOptions { UseUtcTimestamp = true }, CreateClock(), logger => logger.LogInformation("hello")); + + lines.Should().ContainSingle().Which.Should().StartWith("2026-03-15: 10:20:30: [INFORMATION]"); + } + + [Fact] + public void Json_ByDefault_WritesIso8601WithLocalOffset() + { + var lines = Capture( + new TjiddeLoggerOptions { OutputFormat = TjiddeLogOutputFormat.Json }, + CreateClock(), + logger => logger.LogInformation("hello")); + + var timestamp = JsonTimestamp(lines.Should().ContainSingle().Subject); + timestamp.Should().Be("2026-03-15T12:20:30.0000000+02:00"); + DateTimeOffset.Parse(timestamp).Should().Be(Start); + } + + [Fact] + public void Json_WithUseUtcTimestamp_WritesIso8601WithZeroOffset() + { + var lines = Capture( + new TjiddeLoggerOptions { OutputFormat = TjiddeLogOutputFormat.Json, UseUtcTimestamp = true }, + CreateClock(), + logger => logger.LogInformation("hello")); + + JsonTimestamp(lines.Should().ContainSingle().Subject).Should().Be("2026-03-15T10:20:30.0000000+00:00"); + } + + [Fact] + public void Timestamp_FollowsTheTimeProvider() + { + var clock = CreateClock(); + var lines = Capture( + new TjiddeLoggerOptions { OutputFormat = TjiddeLogOutputFormat.Json, UseUtcTimestamp = true }, + clock, + logger => + { + logger.LogInformation("first"); + clock.Advance(TimeSpan.FromMinutes(90.5)); + logger.LogInformation("second"); + }); + + lines.Should().HaveCount(2); + JsonTimestamp(lines[0]).Should().Be("2026-03-15T10:20:30.0000000+00:00"); + JsonTimestamp(lines[1]).Should().Be("2026-03-15T11:51:00.0000000+00:00"); + } + + [Fact] + public void ThrowingTimeProvider_DoesNotBreakLogging() + { + string[] lines = []; + var act = () => lines = Capture(new TjiddeLoggerOptions(), new ThrowingTimeProvider(), logger => logger.LogInformation("still logged")); + + act.Should().NotThrow(); + lines.Should().ContainSingle().Which.Should().EndWith("still logged"); + } + + [Fact] + public void Provider_WithNullTimeProvider_Throws() + { + var act = () => new TjiddeLoggerProvider( + new TestOptionsMonitor(new TjiddeLoggerOptions()), + new AsyncLocalCustomerContextAccessor(), + new GlobalMaskedKeysAccessor(), + null!); + + act.Should().Throw().WithParameterName("timeProvider"); + } + + [Fact] + public void AddTjiddeLogger_UsesTimeProviderFromDI() + { + var clock = CreateClock(); + var original = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + try + { + var services = new ServiceCollection(); + services.AddSingleton(clock); + services.AddLogging(builder => builder.AddTjiddeLogger(options => + { + options.OutputFormat = TjiddeLogOutputFormat.Json; + options.UseUtcTimestamp = true; + })); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().CreateLogger("My.App.ClockService").LogInformation("from DI"); + } + finally + { + Console.SetOut(original); + } + + var line = writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle().Subject; + JsonTimestamp(line).Should().Be("2026-03-15T10:20:30.0000000+00:00"); + } + + [Fact] + public void AddTjiddeLogger_WithoutTimeProviderInDI_UsesSystemClock() + { + var original = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + var before = DateTimeOffset.UtcNow.AddSeconds(-1); + try + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddTjiddeLogger(options => options.OutputFormat = TjiddeLogOutputFormat.Json)); + + using var provider = services.BuildServiceProvider(); + provider.GetServices().Should().ContainSingle(p => p is TjiddeLoggerProvider); + provider.GetRequiredService().CreateLogger("My.App.ClockService").LogInformation("system clock"); + } + finally + { + Console.SetOut(original); + } + + var after = DateTimeOffset.UtcNow.AddSeconds(1); + var line = writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle().Subject; + var timestamp = DateTimeOffset.Parse(JsonTimestamp(line)); + timestamp.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); + timestamp.Offset.Should().Be(TimeZoneInfo.Local.GetUtcOffset(timestamp)); + } + + [Fact] + public void AddTjiddeLogger_CalledTwice_RegistersOneProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(CreateClock()); + services.AddLogging(builder => builder.AddTjiddeLogger().AddTjiddeLogger(_ => { })); + + using var provider = services.BuildServiceProvider(); + + provider.GetServices().Should().ContainSingle(p => p is TjiddeLoggerProvider); + } + + [Fact] + public void UseUtcTimestamp_BindsFromConfiguration() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["TjiddeLogger:UseUtcTimestamp"] = "true" }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddTjiddeLogger(configuration)); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService>().Value.UseUtcTimestamp.Should().BeTrue(); + } + + private sealed class ThrowingTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => throw new InvalidOperationException("clock failure"); + + public override TimeZoneInfo LocalTimeZone => throw new InvalidOperationException("clock failure"); + } +} diff --git a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj index 2d593d3..c45184c 100644 --- a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj +++ b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj @@ -37,12 +37,14 @@ + + @@ -51,6 +53,7 @@ + @@ -59,6 +62,7 @@ + @@ -67,6 +71,7 @@ + From d521a656073dd835affeb858fc9e816c18967936 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 21:05:34 +0200 Subject: [PATCH 12/28] Deprecate MinimumLevel and CategoryMinimumLevels in favor of Logging:Tjidde:LogLevel TjiddeLoggerOptions.MinimumLevel and CategoryMinimumLevels duplicate the standard Microsoft.Extensions.Logging filters (Logging:LogLevel and, through [ProviderAlias("Tjidde")], Logging:Tjidde:LogLevel). Mark them [Obsolete] with a pointer to Logging:Tjidde:LogLevel; they will be removed in 2.0. They keep working and keep binding from configuration; the library suppresses CS0618 only where it still applies them. New tests pin down how both filters combine: the framework filters first, then Tjidde's own filter, so the stricter level wins (also for metrics, which only the framework filter can drop). Existing tests of the legacy options suppress CS0618 locally. README and the wikis recommend Logging:Tjidde:LogLevel with an appsettings.json example. AI-INTEGRATION.md no longer claims that options changes need a restart. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 28 +++ docs/AI-INTEGRATION.md | 22 +- docs/wiki-en.md | 55 ++++- docs/wiki-nl.md | 55 ++++- .../Logging/TjiddeLoggerConfiguration.cs | 4 + .../Options/TjiddeLoggerOptions.cs | 16 ++ .../LogLevelFilteringTests.cs | 217 ++++++++++++++++++ .../LogOutputFormatTests.cs | 4 + .../OptionsReloadTests.cs | 4 + .../TjiddeLoggingRegistrationTests.cs | 2 + 11 files changed, 382 insertions(+), 26 deletions(-) create mode 100644 tests/Tjidde.Logging.Tests/LogLevelFilteringTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fe17308..5d34f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - 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 `MaskedKeysContext` snapshots 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 2.0. Use the standard filter `Logging:Tjidde:LogLevel` in `appsettings.json` (or `AddFilter(...)`) 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. ## [1.0.1] - 2026-10-02 diff --git a/README.md b/README.md index 15418f3..214be66 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,32 @@ services.AddLogging(builder => builder.AddTjiddeLogger(options => })); ``` +### 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: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Tjidde": { + "LogLevel": { + "Default": "Information", + "MyCompany.MyApp.Services.OrderService": "Debug", + "MyCompany.MyApp.Polling": "Warning" + } + } + } +} +``` + +In code, `builder.AddFilter("MyCompany.MyApp", LogLevel.Debug)` does the same. + +`TjiddeLoggerOptions.MinimumLevel` and `CategoryMinimumLevels` (`TjiddeLogger:MinimumLevel`, `TjiddeLogger:CategoryMinimumLevels`) are obsolete and will be removed in 2.0. 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`. + ### Setting customer context Use `CustomerContext.Set(...)` at the start of a request, job, or operation. It flows through the async call chain automatically. @@ -174,6 +200,8 @@ YYYY-MM-DD: HH:mm:ss: [LEVEL] Class=>ClassName Method=>MethodName: Client=>Custo | `EnableSensitiveDataMasking` | `bool` | `true` | Apply sensitive data masking | | `MaskPlaceholder` | `string` | `[REDACTED]` | Replacement text for masked values | | `AdditionalSensitiveKeys` | `IList` | `[]` | Extra keys to treat as sensitive | +| `MinimumLevel` *(obsolete)* | `LogLevel` | `Trace` | Use `Logging:Tjidde:LogLevel` instead; removed in 2.0 | +| `CategoryMinimumLevels` *(obsolete)* | `IDictionary` | `{}` | Use `Logging:Tjidde:LogLevel` instead; removed in 2.0 | --- diff --git a/docs/AI-INTEGRATION.md b/docs/AI-INTEGRATION.md index a194f11..b5b0129 100644 --- a/docs/AI-INTEGRATION.md +++ b/docs/AI-INTEGRATION.md @@ -79,10 +79,7 @@ builder.Logging.AddTjiddeLogger(options => ### 3. Configure log levels -Two filters apply, and an entry must pass both: - -1. The standard `Logging` section: `Logging:LogLevel`, or `Logging:Tjidde:LogLevel` for this provider only. Without configuration the minimum is `Information`, so `Debug` and `Trace` entries are dropped here unless you lower it. -2. `TjiddeLogger:MinimumLevel` and `TjiddeLogger:CategoryMinimumLevels`. The default is `Trace`, so this filter passes everything unless you set it. +Use the standard `Logging` section. `Logging:Tjidde:LogLevel` applies to this provider only (alias `Tjidde`) and takes precedence over `Logging:LogLevel`, which applies to all providers. Without any rule the minimum is `Information`, so `Debug` and `Trace` entries are dropped unless you lower it. In code: `builder.Logging.AddFilter("Shop.Orders", LogLevel.Debug)`. ```json { @@ -90,6 +87,12 @@ Two filters apply, and an entry must pass both: "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" + }, + "Tjidde": { + "LogLevel": { + "Default": "Information", + "Shop.Orders": "Debug" + } } }, "TjiddeLogger": { @@ -101,7 +104,9 @@ Two filters apply, and an entry must pass both: } ``` -Options are read once per log category, when the first logger for that category is created. Changing configuration while the app runs has no effect; restart the app. +Do not use `TjiddeLogger:MinimumLevel` or `TjiddeLogger:CategoryMinimumLevels` in new code: they are obsolete (compiler warning CS0618) and will be removed in 2.0. They still work as a second filter after the `Logging` section, so an entry must pass both and the stricter level wins. When you find them, move the values to `Logging:Tjidde:LogLevel` (same keys, same `Default` fallback). + +With `AddTjiddeLogger(builder.Configuration)` and `reloadOnChange` (the default for `appsettings.json`), changes to the `TjiddeLogger` and `Logging` sections apply to existing loggers without a restart. ### 4. Set the customer context (optional) @@ -283,8 +288,8 @@ Section name in `appsettings.json`: `TjiddeLogger`. | Option | Type | Default | Meaning | |---|---|---|---| | `OutputFormat` | `TjiddeLogOutputFormat` | `Text` | `Text` or `Json`. | -| `MinimumLevel` | `LogLevel` | `Trace` | Minimum level when no category rule matches. | -| `CategoryMinimumLevels` | `IDictionary` | empty | Minimum level per category or namespace prefix; the key `Default` is the fallback. | +| `MinimumLevel` | `LogLevel` | `Trace` | Obsolete, removed in 2.0: use `Logging:Tjidde:LogLevel`. Minimum level when no category rule matches. | +| `CategoryMinimumLevels` | `IDictionary` | empty | Obsolete, removed in 2.0: use `Logging:Tjidde:LogLevel`. Minimum level per category or namespace prefix; the key `Default` is the fallback. | | `IncludeScopes` | `bool` | `true` | Append scope values to each entry. Also required for `BeginMethodScope`. | | `UseUtcTimestamp` | `bool` | `false` | Write timestamps in UTC instead of local time. | | `ResolveMethodNameFromStackTrace` | `bool` | `false` | Find the method name from the stack trace when no method scope is active. Slow. | @@ -300,7 +305,8 @@ Section name in `appsettings.json`: `TjiddeLogger`. ## Checklist - [ ] `ClearProviders()` is called before `AddTjiddeLogger(...)`. -- [ ] `Logging:LogLevel` is low enough for the levels the app needs. +- [ ] `Logging:LogLevel` / `Logging:Tjidde:LogLevel` is low enough for the levels the app needs. +- [ ] No `TjiddeLogger:MinimumLevel` or `TjiddeLogger:CategoryMinimumLevels` (obsolete); levels live in `Logging:Tjidde:LogLevel`. - [ ] The customer context is set per request or job, if used. - [ ] Log calls use message templates with named placeholders, never `$"..."`. - [ ] Placeholders that can hold secrets are named after a sensitive key, or the name is added to `AdditionalSensitiveKeys`. diff --git a/docs/wiki-en.md b/docs/wiki-en.md index f8db0f4..e9456fc 100644 --- a/docs/wiki-en.md +++ b/docs/wiki-en.md @@ -159,12 +159,6 @@ You can configure Tjidde logger either in code or directly from `appsettings.jso { "TjiddeLogger": { "OutputFormat": "Json", - "MinimumLevel": "Information", - "CategoryMinimumLevels": { - "Default": "Warning", - "MyCompany.MyApp": "Information", - "MyCompany.MyApp.Services.OrderService": "Debug" - }, "EnableOpenTelemetryExport": true, "OpenTelemetryActivitySourceName": "MyCompany.MyApp", "OpenTelemetryCreateFallbackActivity": false, @@ -184,6 +178,49 @@ builder.Logging.AddTjiddeLogger(builder.Configuration); builder.Logging.AddTjiddeLogger(builder.Configuration.GetSection("TjiddeLogger")); ``` +Log levels are not part of the `TjiddeLogger` section: configure them under `Logging`, see [Log levels per category](#log-levels-per-category). + +### Log levels per category + +Use the standard `Logging` section of `appsettings.json`. Tjidde.Logging's provider alias is `Tjidde`, so `Logging:Tjidde:LogLevel` applies to Tjidde.Logging only, while `Logging:LogLevel` applies to every provider. For Tjidde.Logging, a rule under `Logging:Tjidde:LogLevel` takes precedence over one under `Logging:LogLevel`. Keys are categories or namespace prefixes (the longest match wins) and `Default` is the fallback: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Tjidde": { + "LogLevel": { + "Default": "Information", + "MyCompany.MyApp.Services.OrderService": "Debug", + "MyCompany.MyApp.Polling": "Warning" + } + } + } +} +``` + +The same in code: + +```csharp +builder.Logging.AddFilter("MyCompany.MyApp.Services.OrderService", LogLevel.Debug); +``` + +These filters are reloaded with the configuration, like the other options. + +**Obsolete: `MinimumLevel` and `CategoryMinimumLevels`.** `TjiddeLogger:MinimumLevel` and `TjiddeLogger:CategoryMinimumLevels` duplicate the filters above and will be removed in 2.0. Until then they still work and are still bound from configuration. They run *after* the `Logging` filters, so an entry must pass both and the stricter level wins: + +| `Logging:Tjidde:LogLevel:Default` | `TjiddeLogger:MinimumLevel` | Lowest level written | +|---|---|---| +| `Warning` | `Debug` | `Warning` | +| `Debug` | `Warning` | `Warning` | +| `Debug` | *(not set, `Trace`)* | `Debug` | +| *(not set, and no `Logging:LogLevel` rule: framework default `Information`)* | `Debug` | `Information` | + +The last row is a common surprise: without a `Logging` rule, the framework's default minimum is `Information`, so `MinimumLevel = Debug` alone never shows debug entries. Move the values to `Logging:Tjidde:LogLevel` (keys and `Default` work the same way). + ### Dynamic Censoring (at runtime) You can add extra sensitive words for censoring at runtime from within your application using `MaskedKeysContext`. This is useful for redacting data that is only known at execution time. @@ -224,8 +261,8 @@ builder.Logging.AddTjiddeLogger(options => | Option | Type | Default | Description | |---|---|---|---| | `OutputFormat` | `TjiddeLogOutputFormat` | `Text` | Log rendering mode: `Text` or `Json` (Elasticsearch-friendly JSON line). | -| `MinimumLevel` | `LogLevel` | `Trace` | Global minimum log level fallback when no category override matches. | -| `CategoryMinimumLevels` | `IDictionary` | `{}` | Category/namespace/class-specific minimum levels. Supports `Default` fallback and prefix matching like `Logging:LogLevel`. | +| `MinimumLevel` *(obsolete)* | `LogLevel` | `Trace` | Global minimum log level fallback when no category override matches. Use `Logging:Tjidde:LogLevel` instead; removed in 2.0. | +| `CategoryMinimumLevels` *(obsolete)* | `IDictionary` | `{}` | Category/namespace/class-specific minimum levels. Use `Logging:Tjidde:LogLevel` instead; removed in 2.0. | | `EnableOpenTelemetryExport` | `bool` | `false` | Emits each log as an OpenTelemetry event on the current `Activity` (for OTEL pipelines). | | `OpenTelemetryActivitySourceName` | `string` | `Tjidde.Logging` | Activity source name used for optional fallback activity creation. | | `OpenTelemetryCreateFallbackActivity` | `bool` | `false` | Creates a short-lived internal activity if no current `Activity` exists. | @@ -519,7 +556,7 @@ The `[METRICS]` label is printed in **Magenta** in the console for easy visual d ### Filtering -Tjidde.Logging's own `MinimumLevel` and `CategoryMinimumLevels` never drop metrics entries. Filters of `Microsoft.Extensions.Logging` itself (for example `Logging:LogLevel:Default` in `appsettings.json`) treat them as `Information`, so a category filtered to `Warning` or higher also filters its metrics entries. +Tjidde.Logging's own (obsolete) `MinimumLevel` and `CategoryMinimumLevels` never drop metrics entries. Filters of `Microsoft.Extensions.Logging` itself (for example `Logging:LogLevel:Default` or `Logging:Tjidde:LogLevel:Default` in `appsettings.json`) treat them as `Information`, so a category filtered to `Warning` or higher also filters its metrics entries. ### The obsolete `Metrics` log level diff --git a/docs/wiki-nl.md b/docs/wiki-nl.md index d58bab3..b17f768 100644 --- a/docs/wiki-nl.md +++ b/docs/wiki-nl.md @@ -159,12 +159,6 @@ Je kunt de Tjidde-logger in code configureren of rechtstreeks vanuit `appsetting { "TjiddeLogger": { "OutputFormat": "Json", - "MinimumLevel": "Information", - "CategoryMinimumLevels": { - "Default": "Warning", - "MyCompany.MyApp": "Information", - "MyCompany.MyApp.Services.OrderService": "Debug" - }, "EnableOpenTelemetryExport": true, "OpenTelemetryActivitySourceName": "MyCompany.MyApp", "OpenTelemetryCreateFallbackActivity": false, @@ -184,6 +178,49 @@ builder.Logging.AddTjiddeLogger(builder.Configuration); builder.Logging.AddTjiddeLogger(builder.Configuration.GetSection("TjiddeLogger")); ``` +Logniveaus horen niet in de sectie `TjiddeLogger`: stel ze in onder `Logging`, zie [Logniveaus per categorie](#logniveaus-per-categorie). + +### Logniveaus per categorie + +Gebruik de standaardsectie `Logging` van `appsettings.json`. De provider-alias van Tjidde.Logging is `Tjidde`, dus `Logging:Tjidde:LogLevel` geldt alleen voor Tjidde.Logging en `Logging:LogLevel` voor alle providers. Voor Tjidde.Logging gaat een regel onder `Logging:Tjidde:LogLevel` voor op een regel onder `Logging:LogLevel`. Sleutels zijn categorieën of namespace-prefixen (de langste match wint); `Default` is de terugvaloptie: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Tjidde": { + "LogLevel": { + "Default": "Information", + "MyCompany.MyApp.Services.OrderService": "Debug", + "MyCompany.MyApp.Polling": "Warning" + } + } + } +} +``` + +Hetzelfde in code: + +```csharp +builder.Logging.AddFilter("MyCompany.MyApp.Services.OrderService", LogLevel.Debug); +``` + +Deze filters worden net als de andere opties opnieuw ingelezen als de configuratie verandert. + +**Verouderd: `MinimumLevel` en `CategoryMinimumLevels`.** `TjiddeLogger:MinimumLevel` en `TjiddeLogger:CategoryMinimumLevels` doen hetzelfde als de filters hierboven en verdwijnen in 2.0. Tot die tijd werken ze nog en worden ze nog uit de configuratie gelezen. Ze filteren *na* de `Logging`-filters; een regel moet dus door beide heen en het strengste niveau wint: + +| `Logging:Tjidde:LogLevel:Default` | `TjiddeLogger:MinimumLevel` | Laagste niveau dat wordt geschreven | +|---|---|---| +| `Warning` | `Debug` | `Warning` | +| `Debug` | `Warning` | `Warning` | +| `Debug` | *(niet ingesteld, `Trace`)* | `Debug` | +| *(niet ingesteld en geen `Logging:LogLevel`-regel: frameworkstandaard `Information`)* | `Debug` | `Information` | + +De laatste rij verrast vaak: zonder `Logging`-regel is het standaardminimum van het framework `Information`, dus alleen `MinimumLevel = Debug` toont nooit debugregels. Zet de waarden over naar `Logging:Tjidde:LogLevel` (sleutels en `Default` werken hetzelfde). + ### Dynamisch censureren (tijdens runtime) Je kunt extra gevoelige woorden toevoegen om te censureren tijdens runtime vanuit je applicatie via `MaskedKeysContext`. Dit is handig voor het anonimiseren van gegevens die pas tijdens de uitvoering bekend zijn. @@ -224,8 +261,8 @@ builder.Logging.AddTjiddeLogger(options => | Optie | Type | Standaard | Beschrijving | |---|---|---|---| | `OutputFormat` | `TjiddeLogOutputFormat` | `Text` | Weergavemodus: `Text` of `Json` (Elasticsearch-vriendelijke JSON-regel). | -| `MinimumLevel` | `LogLevel` | `Trace` | Globaal minimum logniveau als er geen categorie-override matcht. | -| `CategoryMinimumLevels` | `IDictionary` | `{}` | Categorie-/namespace-/klasse-specifieke minimum niveaus. Ondersteunt `Default` en prefix-matching zoals `Logging:LogLevel`. | +| `MinimumLevel` *(verouderd)* | `LogLevel` | `Trace` | Globaal minimum logniveau als er geen categorie-override matcht. Gebruik `Logging:Tjidde:LogLevel`; verdwijnt in 2.0. | +| `CategoryMinimumLevels` *(verouderd)* | `IDictionary` | `{}` | Categorie-/namespace-/klasse-specifieke minimum niveaus. Gebruik `Logging:Tjidde:LogLevel`; verdwijnt in 2.0. | | `EnableOpenTelemetryExport` | `bool` | `false` | Stuurt elke logregel als OpenTelemetry-event op de huidige `Activity` (voor OTEL-pipelines). | | `OpenTelemetryActivitySourceName` | `string` | `Tjidde.Logging` | Activity source-naam voor optionele fallback-activity creatie. | | `OpenTelemetryCreateFallbackActivity` | `bool` | `false` | Maakt een korte interne activity als er geen huidige `Activity` beschikbaar is. | @@ -519,7 +556,7 @@ Het label `[METRICS]` wordt in **Magenta** weergegeven in de console voor eenvou ### Filteren -Het eigen `MinimumLevel` en `CategoryMinimumLevels` van Tjidde.Logging laten metrics-regels altijd door. Filters van `Microsoft.Extensions.Logging` zelf (bijvoorbeeld `Logging:LogLevel:Default` in `appsettings.json`) behandelen ze als `Information`; een categorie die op `Warning` of hoger staat, filtert dus ook haar metrics-regels. +Het eigen (verouderde) `MinimumLevel` en `CategoryMinimumLevels` van Tjidde.Logging laten metrics-regels altijd door. Filters van `Microsoft.Extensions.Logging` zelf (bijvoorbeeld `Logging:LogLevel:Default` of `Logging:Tjidde:LogLevel:Default` in `appsettings.json`) behandelen ze als `Information`; een categorie die op `Warning` of hoger staat, filtert dus ook haar metrics-regels. ### Het verouderde `Metrics`-logniveau diff --git a/src/Tjidde.Logging/Logging/TjiddeLoggerConfiguration.cs b/src/Tjidde.Logging/Logging/TjiddeLoggerConfiguration.cs index c1f5d25..0f942ba 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLoggerConfiguration.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLoggerConfiguration.cs @@ -52,7 +52,10 @@ public static TjiddeLoggerConfiguration Create(TjiddeLoggerOptions options, IMas /// The minimum level for : an exact match in /// , else the longest matching namespace prefix, /// else Default, else . + /// Both options are obsolete but still honored for backwards compatibility until 2.0; this filter runs after + /// the Microsoft.Extensions.Logging filters (Logging:Tjidde:LogLevel), so the stricter one wins. /// +#pragma warning disable CS0618 // MinimumLevel and CategoryMinimumLevels are obsolete; still applied for backwards compatibility public LogLevel ResolveMinimumLevel(string categoryName) { var levels = Options.CategoryMinimumLevels; @@ -79,6 +82,7 @@ public LogLevel ResolveMinimumLevel(string categoryName) return Options.MinimumLevel; } +#pragma warning restore CS0618 /// /// Disposes the fallback activity source. A log call that still uses this configuration stays safe: diff --git a/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs b/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs index 90df97e..a559af8 100644 --- a/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs +++ b/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs @@ -13,10 +13,20 @@ public sealed class TjiddeLoggerOptions /// public TjiddeLogOutputFormat OutputFormat { get; set; } = TjiddeLogOutputFormat.Text; + internal const string MinimumLevelObsoleteMessage = + "Use the standard Microsoft.Extensions.Logging filters instead: Logging:Tjidde:LogLevel in appsettings.json " + + "(or AddFilter(...) in code). This option will be removed in 2.0."; + /// /// Global minimum log level if no category-specific override is matched. /// Default: Trace. /// + /// + /// Obsolete: use Logging:Tjidde:LogLevel in appsettings.json (or + /// AddFilter<TjiddeLoggerProvider>) instead. This filter runs after the + /// Microsoft.Extensions.Logging filters, so the stricter of the two wins. It will be removed in 2.0. + /// + [Obsolete(MinimumLevelObsoleteMessage)] public LogLevel MinimumLevel { get; set; } = LogLevel.Trace; /// @@ -24,6 +34,12 @@ public sealed class TjiddeLoggerOptions /// Keys can be fully-qualified categories (for example My.App.Service), namespace prefixes /// (for example My.App), or Default for fallback behavior. /// + /// + /// Obsolete: use Logging:Tjidde:LogLevel in appsettings.json (or + /// AddFilter<TjiddeLoggerProvider>) instead. This filter runs after the + /// Microsoft.Extensions.Logging filters, so the stricter of the two wins. It will be removed in 2.0. + /// + [Obsolete(MinimumLevelObsoleteMessage)] public IDictionary CategoryMinimumLevels { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/tests/Tjidde.Logging.Tests/LogLevelFilteringTests.cs b/tests/Tjidde.Logging.Tests/LogLevelFilteringTests.cs new file mode 100644 index 0000000..086eaf8 --- /dev/null +++ b/tests/Tjidde.Logging.Tests/LogLevelFilteringTests.cs @@ -0,0 +1,217 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Extensions; +using Tjidde.Logging.Logging; +using Xunit; + +namespace Tjidde.Logging.Tests; + +/// +/// How the standard Microsoft.Extensions.Logging filters (Logging:LogLevel and, through +/// [ProviderAlias("Tjidde")], Logging:Tjidde:LogLevel) and Tjidde's own (obsolete) +/// TjiddeLogger:MinimumLevel / TjiddeLogger:CategoryMinimumLevels work together: +/// the framework filters first, then Tjidde's own filter, so the stricter of the two wins. +/// +[Collection("ConsoleOutput")] +public sealed class LogLevelFilteringTests +{ + private const string Category = "My.App.Service"; + + /// + /// Builds a host-like logging setup (the "Logging" section via AddConfiguration, the "TjiddeLogger" section + /// via AddTjiddeLogger), runs and returns the written lines. + /// + private static string[] Run( + Dictionary settings, + Action log, + Action? configureLogging = null) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var original = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + try + { + var services = new ServiceCollection(); + services.AddLogging(builder => + { + builder.AddConfiguration(configuration.GetSection("Logging")); + builder.AddTjiddeLogger(configuration); + configureLogging?.Invoke(builder); + }); + + // Disposing the container disposes the provider, which flushes the console writer + using var provider = services.BuildServiceProvider(); + log(provider.GetRequiredService()); + } + finally + { + Console.SetOut(original); + } + + return writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + } + + private static void LogAllLevels(ILogger logger) + { + logger.LogTrace("trace entry"); + logger.LogDebug("debug entry"); + logger.LogInformation("information entry"); + logger.LogWarning("warning entry"); + logger.LogError("error entry"); + } + + private static IEnumerable Messages(string[] lines) + => lines.Select(line => line[(line.LastIndexOf(": ", StringComparison.Ordinal) + 2)..]); + + [Fact] + public void ProviderLogLevel_Warning_WithTjiddeMinimumLevel_Debug_WarningWins() + { + bool informationEnabled = true; + var lines = Run( + new() + { + ["Logging:Tjidde:LogLevel:Default"] = "Warning", + ["TjiddeLogger:MinimumLevel"] = "Debug" + }, + factory => + { + var logger = factory.CreateLogger(Category); + informationEnabled = logger.IsEnabled(LogLevel.Information); + LogAllLevels(logger); + }); + + informationEnabled.Should().BeFalse("the framework filter drops Information before Tjidde's own filter is asked"); + Messages(lines).Should().Equal("warning entry", "error entry"); + } + + [Fact] + public void ProviderLogLevel_Debug_WithTjiddeMinimumLevel_Warning_WarningWins() + { + bool debugEnabled = true; + var lines = Run( + new() + { + ["Logging:Tjidde:LogLevel:Default"] = "Debug", + ["TjiddeLogger:MinimumLevel"] = "Warning" + }, + factory => + { + var logger = factory.CreateLogger(Category); + debugEnabled = logger.IsEnabled(LogLevel.Debug); + LogAllLevels(logger); + }); + + debugEnabled.Should().BeFalse("Tjidde's own minimum level still applies after the framework filter"); + Messages(lines).Should().Equal("warning entry", "error entry"); + } + + [Fact] + public void TjiddeMinimumLevel_Debug_WithoutLoggingRules_StillDropsDebug() + { + var lines = Run( + new() { ["TjiddeLogger:MinimumLevel"] = "Debug" }, + factory => LogAllLevels(factory.CreateLogger(Category))); + + Messages(lines).Should().Equal( + new[] { "information entry", "warning entry", "error entry" }, + "the framework's default minimum level is Information"); + } + + [Fact] + public void ProviderLogLevel_Alone_FiltersPerCategory() + { + var lines = Run( + new() + { + ["Logging:Tjidde:LogLevel:Default"] = "Information", + ["Logging:Tjidde:LogLevel:My.App.Noisy"] = "Error", + ["Logging:Tjidde:LogLevel:My.App.Orders.OrderService"] = "Debug" + }, + factory => + { + factory.CreateLogger(Category).LogDebug("service debug"); + factory.CreateLogger(Category).LogInformation("service information"); + factory.CreateLogger("My.App.Noisy.Poller").LogWarning("noisy warning"); + factory.CreateLogger("My.App.Noisy.Poller").LogError("noisy error"); + factory.CreateLogger("My.App.Orders.OrderService").LogDebug("orders debug"); + }); + + Messages(lines).Should().Equal("service information", "noisy error", "orders debug"); + } + + [Fact] + public void ProviderLogLevel_OverridesGlobalLogLevel_ForTjiddeOnly() + { + var lines = Run( + new() + { + ["Logging:LogLevel:Default"] = "Warning", + ["Logging:Tjidde:LogLevel:Default"] = "Debug" + }, + factory => LogAllLevels(factory.CreateLogger(Category))); + + Messages(lines).Should().Equal("debug entry", "information entry", "warning entry", "error entry"); + } + + [Fact] + public void GlobalLogLevel_WithoutProviderSection_AppliesToTjidde() + { + var lines = Run( + new() { ["Logging:LogLevel:Default"] = "Warning" }, + factory => LogAllLevels(factory.CreateLogger(Category))); + + Messages(lines).Should().Equal("warning entry", "error entry"); + } + + [Fact] + public void TjiddeCategoryMinimumLevels_StricterThanProviderLogLevel_Wins() + { + var lines = Run( + new() + { + ["Logging:Tjidde:LogLevel:Default"] = "Trace", + ["TjiddeLogger:CategoryMinimumLevels:My.App"] = "Error" + }, + factory => + { + LogAllLevels(factory.CreateLogger(Category)); + factory.CreateLogger("Other.Component").LogTrace("other trace"); + }); + + Messages(lines).Should().Equal("error entry", "other trace"); + } + + [Fact] + public void AddFilterForTjiddeProvider_InCode_WorksLikeProviderLogLevel() + { + var lines = Run( + new(), + factory => LogAllLevels(factory.CreateLogger(Category)), + builder => builder.AddFilter("My.App", LogLevel.Error)); + + Messages(lines).Should().Equal("error entry"); + } + + [Fact] + public void Metrics_AreFilteredByProviderLogLevel_ButNotByTjiddeMinimumLevel() + { + var droppedByFramework = Run( + new() { ["Logging:Tjidde:LogLevel:Default"] = "Warning" }, + factory => factory.CreateLogger(Category).LogMetrics("requests_total={Count}", 1)); + + var keptByTjidde = Run( + new() + { + ["Logging:Tjidde:LogLevel:Default"] = "Information", + ["TjiddeLogger:MinimumLevel"] = "Critical" + }, + factory => factory.CreateLogger(Category).LogMetrics("requests_total={Count}", 2)); + + droppedByFramework.Should().BeEmpty("metrics entries are Information entries for the framework filter"); + keptByTjidde.Should().ContainSingle().Which.Should().Contain("[METRICS]").And.EndWith("requests_total=2"); + } +} diff --git a/tests/Tjidde.Logging.Tests/LogOutputFormatTests.cs b/tests/Tjidde.Logging.Tests/LogOutputFormatTests.cs index d71ed3c..ce84363 100644 --- a/tests/Tjidde.Logging.Tests/LogOutputFormatTests.cs +++ b/tests/Tjidde.Logging.Tests/LogOutputFormatTests.cs @@ -257,6 +257,7 @@ public void LogMetrics_OutputContainsMetricsLabel() output.Trim().Should().MatchRegex(LogLinePattern); } +#pragma warning disable CS0618 // Obsolete MinimumLevel/CategoryMinimumLevels: verifies the legacy filter still works until 2.0 [Fact] public void LogMetrics_IsNotDroppedByMinimumLevel() { @@ -272,6 +273,7 @@ public void LogMetrics_IsNotDroppedByMinimumLevel() output.Should().NotContain("plain information"); output.Should().Contain("[METRICS]"); } +#pragma warning restore CS0618 [Fact] public void LogMetrics_JsonOutput_UsesMetricsLevelAndEvent() @@ -315,6 +317,7 @@ public void Log_JsonOutput_ContainsExpectedFields() root.TryGetProperty("@timestamp", out _).Should().BeTrue(); } +#pragma warning disable CS0618 // Obsolete MinimumLevel/CategoryMinimumLevels: verifies the legacy filter still works until 2.0 [Fact] public void IsEnabled_UsesCategoryMinimumLevels_WithNamespaceFallback() { @@ -333,6 +336,7 @@ public void IsEnabled_UsesCategoryMinimumLevels_WithNamespaceFallback() logger.IsEnabled(LogLevel.Warning).Should().BeFalse(); logger.IsEnabled(LogLevel.Error).Should().BeTrue(); } +#pragma warning restore CS0618 [Fact] public void Log_OpenTelemetryExportEnabled_AddsEventToCurrentActivity() diff --git a/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs b/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs index db17a3e..a17b7f6 100644 --- a/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs +++ b/tests/Tjidde.Logging.Tests/OptionsReloadTests.cs @@ -81,6 +81,7 @@ public void ConfigurationReload_AppliesOutputFormatMinimumLevelAndPlaceholderToE json.RootElement.GetProperty("message").GetString().Should().Be("after reload password=***"); } +#pragma warning disable CS0618 // Obsolete MinimumLevel/CategoryMinimumLevels: verifies the legacy filter still works until 2.0 [Fact] public void OptionsMonitorChange_AppliesCategoryLevelsToExistingLogger() { @@ -101,7 +102,9 @@ public void OptionsMonitorChange_AppliesCategoryLevelsToExistingLogger() logger.IsEnabled(LogLevel.Warning).Should().BeFalse(); logger.IsEnabled(LogLevel.Error).Should().BeTrue(); } +#pragma warning restore CS0618 +#pragma warning disable CS0618 // Obsolete MinimumLevel/CategoryMinimumLevels: verifies the legacy filter still works until 2.0 [Fact] public void OptionsMonitorChange_ForNamedOptions_IsIgnored() { @@ -113,6 +116,7 @@ public void OptionsMonitorChange_ForNamedOptions_IsIgnored() logger.IsEnabled(LogLevel.Debug).Should().BeFalse(); } +#pragma warning restore CS0618 [Fact] public void Dispose_StopsListeningForOptionsChanges() diff --git a/tests/Tjidde.Logging.Tests/TjiddeLoggingRegistrationTests.cs b/tests/Tjidde.Logging.Tests/TjiddeLoggingRegistrationTests.cs index 9527167..9d2557c 100644 --- a/tests/Tjidde.Logging.Tests/TjiddeLoggingRegistrationTests.cs +++ b/tests/Tjidde.Logging.Tests/TjiddeLoggingRegistrationTests.cs @@ -102,6 +102,7 @@ public void AddTjiddeLogger_CustomAccessor_IsNotOverridden() accessor.Should().BeOfType(); } +#pragma warning disable CS0618 // Obsolete MinimumLevel/CategoryMinimumLevels: verifies the legacy filter still works until 2.0 [Fact] public void AddTjiddeLogger_WithConfiguration_BindsOptionsFromSection() { @@ -136,6 +137,7 @@ public void AddTjiddeLogger_WithConfiguration_BindsOptionsFromSection() options.CategoryMinimumLevels["Default"].Should().Be(LogLevel.Error); options.CategoryMinimumLevels["My.Namespace"].Should().Be(LogLevel.Information); } +#pragma warning restore CS0618 private sealed class CustomTestAccessor : ICustomerContextAccessor { From dd7dcb029cdf5cc3aba634185f58e5f1cd3a21b8 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Fri, 2 Oct 2026 21:11:49 +0200 Subject: [PATCH 13/28] Add MaskedKeysStore and UseIsolatedMaskedKeys for per-host masked keys MaskedKeysStore is a non-static, thread-safe IMaskedKeysAccessor that hands out immutable snapshots, so the masker detects changes by reference. UseIsolatedMaskedKeys() registers one store per host as the accessor. MaskedKeysContext stays the static default and now wraps a MaskedKeysStore. Docs recommend accessors and DI for testable code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- README.md | 31 +++- docs/AI-INTEGRATION.md | 28 +++- docs/wiki-en.md | 21 ++- docs/wiki-nl.md | 21 ++- .../TjiddeLoggingBuilderExtensions.cs | 17 +++ .../Masking/MaskedKeysContext.cs | 68 ++------- src/Tjidde.Logging/Masking/MaskedKeysStore.cs | 85 +++++++++++ .../Masking/SensitiveDataMasker.cs | 5 +- .../MaskedKeysStoreTests.cs | 144 ++++++++++++++++++ .../Tjidde.Logging.Tests/SharedMaskerTests.cs | 23 ++- 11 files changed, 372 insertions(+), 75 deletions(-) create mode 100644 src/Tjidde.Logging/Masking/MaskedKeysStore.cs create mode 100644 tests/Tjidde.Logging.Tests/MaskedKeysStoreTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d34f9e..f23374c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - `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, ICustomerContextAccessor, IMaskedKeysAccessor, TimeProvider)`; the existing constructor still works and uses `TimeProvider.System`. On .NET 7 this adds a dependency on `Microsoft.Bcl.TimeProvider`. +- `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. + ### Changed - Options changes now apply to existing loggers immediately, without a restart: the provider listens to `IOptionsMonitor.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. @@ -21,7 +23,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### 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 `MaskedKeysContext` snapshots are compared by reference), builds from a copy, and no longer lets a concurrent rebuild overwrite a newer key set. +- 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 2.0. Use the standard filter `Logging:Tjidde:LogLevel` in `appsettings.json` (or `AddFilter(...)`) instead. They still work and are still bound from configuration: the `Logging` filters run first, then these, so the stricter level wins. diff --git a/README.md b/README.md index 214be66..fc690a5 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,27 @@ In code, `builder.AddFilter("MyCompany.MyApp", LogLevel.De `TjiddeLoggerOptions.MinimumLevel` and `CategoryMinimumLevels` (`TjiddeLogger:MinimumLevel`, `TjiddeLogger:CategoryMinimumLevels`) are obsolete and will be removed in 2.0. 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 inject `MaskedKeysStore`. Each host (and each test's `ServiceProvider`) gets its own keys; changes apply immediately to that host's existing loggers. + +```csharp +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. +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`. ```csharp // In middleware, a job handler, or service entry point: @@ -217,6 +235,14 @@ Masking is applied to: - 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 inject `MaskedKeysStore` (`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`. Keep `GetKeys()` 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: @@ -257,6 +283,9 @@ Tjidde.Logging/ │ ├── TjiddeLogger.cs # ILogger implementation │ └── TjiddeLoggerProvider.cs # ILoggerProvider implementation ├── 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/ diff --git a/docs/AI-INTEGRATION.md b/docs/AI-INTEGRATION.md index b5b0129..5b615a5 100644 --- a/docs/AI-INTEGRATION.md +++ b/docs/AI-INTEGRATION.md @@ -18,10 +18,12 @@ Target frameworks: `net7.0`, `net8.0`, `net9.0`, `net10.0`. | Namespace | Contains | |---|---| -| `Tjidde.Logging.Extensions` | `AddTjiddeLogger`, `UseTjiddeJsonFormat`, `UseTjiddeTextFormat`, `BeginMethodScope`, `LogMetrics` | +| `Tjidde.Logging.Extensions` | `AddTjiddeLogger`, `UseTjiddeJsonFormat`, `UseTjiddeTextFormat`, `UseIsolatedMaskedKeys`, `BeginMethodScope`, `LogMetrics` | | `Tjidde.Logging.Options` | `TjiddeLoggerOptions`, `TjiddeLogOutputFormat` | -| `Tjidde.Logging.Context` | `CustomerContext`, `ICustomerContextAccessor` | -| `Tjidde.Logging.Masking` | `MaskedKeysContext` | +| `Tjidde.Logging.Context` | `ICustomerContextAccessor`, `AsyncLocalCustomerContextAccessor`, `CustomerContext` | +| `Tjidde.Logging.Masking` | `IMaskedKeysAccessor`, `MaskedKeysStore`, `MaskedKeysContext` | + +Prefer the DI route for testable code: implement `ICustomerContextAccessor` and use `UseIsolatedMaskedKeys()` with an injected `MaskedKeysStore`. The static `CustomerContext` and `MaskedKeysContext` are the convenience defaults; `MaskedKeysContext` is shared by every host and test in the process. ## Integration steps @@ -110,7 +112,7 @@ With `AddTjiddeLogger(builder.Configuration)` and `reloadOnChange` (the default ### 4. Set the customer context (optional) -Each entry can carry a customer or tenant id: `Client=>…` in text output, `customer` in JSON. Set it at the start of every request: +Each entry can carry a customer or tenant id: `Client=>…` in text output, `customer` in JSON. The logger reads it through `ICustomerContextAccessor`; for testable code, implement that interface (see below). The quickest route is the static `CustomerContext`, read by the default accessor. Set it at the start of every request: ```csharp using Tjidde.Logging.Context; @@ -132,7 +134,7 @@ app.Use(async (context, next) => `CustomerContext` is stored in an `AsyncLocal`, so the value flows through `await` calls within that request only. In background jobs, call `CustomerContext.Set(...)` at the start of each unit of work. -Alternatively, supply the customer id yourself by implementing `ICustomerContextAccessor`. Register it as a **singleton** (the provider is a singleton and calls it for every entry) and keep it thread-safe: +Recommended for testable code: supply the customer id yourself by implementing `ICustomerContextAccessor` (in tests, register a fake that returns a fixed value). Register it as a **singleton** (the provider is a singleton and calls it for every entry) and keep it thread-safe: ```csharp using Tjidde.Logging.Context; @@ -214,11 +216,25 @@ Built-in sensitive keys: `password`, `wachtwoord`, `token`, `accesstoken`, `acce The pattern masking also masks the word after a sensitive key, so `Refreshing token cache` is written as `Refreshing token [REDACTED]`. Reword such messages if that matters. -To mask values that are only known at runtime, use `MaskedKeysContext.Add(...)`. It masks the literal value anywhere in the output and also treats it as a sensitive key name. The change applies immediately to all loggers, including existing ones; `Remove(...)` and `Clear()` undo it. +To mask values that are only known at runtime, add them as runtime keys. A runtime key masks the literal value anywhere in the output and is also treated as a sensitive key name. The change applies immediately to all loggers that use the keys, including existing ones; `Remove(...)` and `Clear()` undo it. + +Recommended: give the host its own `MaskedKeysStore` and inject it. Keys stay within that host, so tests and multiple hosts in one process do not affect each other: ```csharp +using Tjidde.Logging.Extensions; using Tjidde.Logging.Masking; +builder.Logging.AddTjiddeLogger().UseIsolatedMaskedKeys(); + +public sealed class VaultClient(MaskedKeysStore maskedKeys) +{ + public void OnSecretLoaded(string apiKeyFromVault) => maskedKeys.Add(apiKeyFromVault); +} +``` + +Convenience variant without DI (the default when `UseIsolatedMaskedKeys()` is not called): the static, process-wide `MaskedKeysContext`. + +```csharp MaskedKeysContext.Add(apiKeyFromVault); ``` diff --git a/docs/wiki-en.md b/docs/wiki-en.md index e9456fc..0601f95 100644 --- a/docs/wiki-en.md +++ b/docs/wiki-en.md @@ -239,6 +239,23 @@ _logger.LogInformation("Processing token SuperSecretToken for user PersonalID"); MaskedKeysContext.Remove("PersonalID"); ``` +`MaskedKeysContext` is the convenience variant: static and process-wide, so every host and every test in the process shares its keys. + +#### Isolated keys per host (recommended for testable code) + +Call `UseIsolatedMaskedKeys()` to give the host its own `MaskedKeysStore`, and inject that store where keys become known. Changes apply immediately to the existing loggers of that host only; two hosts (or two tests with their own `ServiceProvider`) never see each other's keys. + +```csharp +builder.Logging.AddTjiddeLogger().UseIsolatedMaskedKeys(); + +public sealed class TokenService(MaskedKeysStore maskedKeys) +{ + public void OnTokenIssued(string token) => maskedKeys.Add(token); // also Remove, Clear, GetKeys +} +``` + +You can also register your own `IMaskedKeysAccessor`. Keep `GetKeys()` cheap and thread-safe: the masker checks it on every log call. A collection that is replaced on every change (like `MaskedKeysStore` and `MaskedKeysContext` do) is the cheapest to check. + ### Option 2: code-based options Pass an `Action` delegate to `AddTjiddeLogger` to customise behaviour: @@ -346,7 +363,9 @@ Event tags include fields such as `log.level`, `log.message`, `log.category`, `e ## Customer Context -The customer context allows you to tag every log entry with a customer or tenant identifier without passing it through every method call. It uses `AsyncLocal` so it flows correctly through `async`/`await` chains. +The customer context allows you to tag every log entry with a customer or tenant identifier without passing it through every method call. The logger reads it through `ICustomerContextAccessor`. The default, `AsyncLocalCustomerContextAccessor`, reads the static `CustomerContext`, which uses `AsyncLocal` so it flows correctly through `async`/`await` chains. + +For testable code, register your own `ICustomerContextAccessor` as a singleton before `AddTjiddeLogger` (for example one that reads a claim from `IHttpContextAccessor`); in tests, register a fake that returns a fixed value. The static `CustomerContext` below is the convenience variant. ### Setting the context diff --git a/docs/wiki-nl.md b/docs/wiki-nl.md index b17f768..ba2f020 100644 --- a/docs/wiki-nl.md +++ b/docs/wiki-nl.md @@ -239,6 +239,23 @@ _logger.LogInformation("Verwerken van token SuperGeheimToken voor gebruiker Pers MaskedKeysContext.Remove("PersoonsID"); ``` +`MaskedKeysContext` is de gemaksvariant: statisch en procesbreed, dus elke host en elke test in het proces deelt dezelfde sleutels. + +#### Geisoleerde sleutels per host (aanbevolen voor testbare code) + +Roep `UseIsolatedMaskedKeys()` aan om de host een eigen `MaskedKeysStore` te geven, en injecteer die store waar sleutels bekend worden. Wijzigingen gelden direct voor de bestaande loggers van alleen die host; twee hosts (of twee tests met een eigen `ServiceProvider`) zien elkaars sleutels nooit. + +```csharp +builder.Logging.AddTjiddeLogger().UseIsolatedMaskedKeys(); + +public sealed class TokenService(MaskedKeysStore maskedKeys) +{ + public void OnTokenIssued(string token) => maskedKeys.Add(token); // ook Remove, Clear, GetKeys +} +``` + +Je kunt ook een eigen `IMaskedKeysAccessor` registreren. Houd `GetKeys()` goedkoop en thread-safe: de masker controleert het bij elke logaanroep. Een collectie die bij elke wijziging wordt vervangen (zoals `MaskedKeysStore` en `MaskedKeysContext` doen) is het goedkoopst te controleren. + ### Optie 2: code-gebaseerde opties Geef een `Action`-delegate mee aan `AddTjiddeLogger` om het gedrag aan te passen: @@ -346,7 +363,9 @@ Event-tags bevatten onder andere `log.level`, `log.message`, `log.category`, `ev ## Klantcontext -De klantcontext maakt het mogelijk om elke logmelding te voorzien van een klant- of tenant-identificatie, zonder deze door elke methodeaanroep te hoeven doorgeven. Het maakt gebruik van `AsyncLocal`, zodat het correct doorstroomt via `async`/`await`-ketens. +De klantcontext maakt het mogelijk om elke logmelding te voorzien van een klant- of tenant-identificatie, zonder deze door elke methodeaanroep te hoeven doorgeven. De logger leest hem via `ICustomerContextAccessor`. De standaard, `AsyncLocalCustomerContextAccessor`, leest de statische `CustomerContext`, die `AsyncLocal` gebruikt, zodat het correct doorstroomt via `async`/`await`-ketens. + +Voor testbare code registreer je een eigen `ICustomerContextAccessor` als singleton vóór `AddTjiddeLogger` (bijvoorbeeld een die een claim uit `IHttpContextAccessor` leest); in tests registreer je een fake die een vaste waarde teruggeeft. De statische `CustomerContext` hieronder is de gemaksvariant. ### Context instellen diff --git a/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs b/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs index 4671b48..782c9f7 100644 --- a/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs +++ b/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs @@ -113,6 +113,23 @@ public static ILoggingBuilder UseTjiddeTextFormat(this ILoggingBuilder builder) return builder; } + /// + /// Gives this host its own instead of the process-wide . + /// The store is registered as a singleton; inject to add or remove keys. Changes apply + /// immediately to the loggers of this host only, so hosts and tests in the same process do not share keys. + /// Replaces any registered earlier; can be called before or after AddTjiddeLogger. + /// + /// The . + /// The for chaining. + public static ILoggingBuilder UseIsolatedMaskedKeys(this ILoggingBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.TryAddSingleton(); + builder.Services.Replace(ServiceDescriptor.Singleton( + services => services.GetRequiredService())); + return builder; + } + private static TjiddeLoggerProvider CreateProvider(IServiceProvider services) => new( services.GetRequiredService>(), diff --git a/src/Tjidde.Logging/Masking/MaskedKeysContext.cs b/src/Tjidde.Logging/Masking/MaskedKeysContext.cs index 7e850a8..212e4ac 100644 --- a/src/Tjidde.Logging/Masking/MaskedKeysContext.cs +++ b/src/Tjidde.Logging/Masking/MaskedKeysContext.cs @@ -1,82 +1,40 @@ namespace Tjidde.Logging.Masking; /// -/// Provides global access to a collection of extra sensitive keys that should be masked. +/// Static, process-wide collection of extra sensitive keys that should be masked. /// These keys are added dynamically at runtime from within the application and apply -/// immediately to all loggers, including loggers that already exist. +/// immediately to all loggers that use the default , +/// including loggers that already exist. /// +/// +/// This is the convenience variant: every host and test in the process shares these keys. +/// For isolated, testable code, register a per host with +/// UseIsolatedMaskedKeys() and inject it instead. +/// public static class MaskedKeysContext { - private static readonly object SyncRoot = new(); - private static readonly HashSet Keys = new(StringComparer.OrdinalIgnoreCase); - - // Read-only copy of Keys, replaced on every change. Readers get the same instance until the - // keys change, so maskers can detect changes with a cheap reference comparison. - private static IReadOnlyCollection _snapshot = Array.Empty(); + private static readonly MaskedKeysStore Store = new(); /// /// Adds one or more keys to the collection of extra sensitive keywords for censoring. /// /// The keys to add. - public static void Add(params string[] keys) - { - if (keys is null) return; - - lock (SyncRoot) - { - var changed = false; - foreach (var key in keys) - { - if (!string.IsNullOrWhiteSpace(key)) - changed |= Keys.Add(key); - } - - if (changed) - PublishSnapshot(); - } - } + public static void Add(params string[] keys) => Store.Add(keys); /// /// Removes one or more keys from the collection of extra sensitive keywords. /// /// The keys to remove. - public static void Remove(params string[] keys) - { - if (keys is null) return; - - lock (SyncRoot) - { - var changed = false; - foreach (var key in keys) - { - if (key is not null) - changed |= Keys.Remove(key); - } - - if (changed) - PublishSnapshot(); - } - } + public static void Remove(params string[] keys) => Store.Remove(keys); /// /// Clears all extra sensitive keys added via this context. /// - public static void Clear() - { - lock (SyncRoot) - { - if (Keys.Count == 0) return; - - Keys.Clear(); - PublishSnapshot(); - } - } + public static void Clear() => Store.Clear(); /// /// Returns the current collection of extra sensitive keys. The collection is a read-only snapshot; /// the same instance is returned until the keys change. /// - public static IReadOnlyCollection GetKeys() => Volatile.Read(ref _snapshot); - - private static void PublishSnapshot() => Volatile.Write(ref _snapshot, Array.AsReadOnly(Keys.ToArray())); + public static IReadOnlyCollection GetKeys() => Store.GetKeys(); } diff --git a/src/Tjidde.Logging/Masking/MaskedKeysStore.cs b/src/Tjidde.Logging/Masking/MaskedKeysStore.cs new file mode 100644 index 0000000..54bcb21 --- /dev/null +++ b/src/Tjidde.Logging/Masking/MaskedKeysStore.cs @@ -0,0 +1,85 @@ +namespace Tjidde.Logging.Masking; + +/// +/// A thread-safe, non-static store of extra sensitive keys. Register one per host (see +/// UseIsolatedMaskedKeys()) and inject it where keys become known at runtime: the keys apply +/// immediately to every logger of that host, including loggers that already exist, and never leak +/// into other hosts or tests in the same process. +/// +/// +/// is the static, process-wide variant of this store. +/// +public sealed class MaskedKeysStore : IMaskedKeysAccessor +{ + private readonly object _syncRoot = new(); + private readonly HashSet _keys = new(StringComparer.OrdinalIgnoreCase); + + // Read-only copy of _keys, replaced on every change. Readers get the same instance until the + // keys change, so maskers can detect changes with a cheap reference comparison. + private IReadOnlyCollection _snapshot = Array.Empty(); + + /// + /// Adds one or more keys. Blank keys are ignored. + /// + /// The keys to add. + public void Add(params string[] keys) + { + if (keys is null) return; + + lock (_syncRoot) + { + var changed = false; + foreach (var key in keys) + { + if (!string.IsNullOrWhiteSpace(key)) + changed |= _keys.Add(key); + } + + if (changed) + PublishSnapshot(); + } + } + + /// + /// Removes one or more keys. + /// + /// The keys to remove. + public void Remove(params string[] keys) + { + if (keys is null) return; + + lock (_syncRoot) + { + var changed = false; + foreach (var key in keys) + { + if (key is not null) + changed |= _keys.Remove(key); + } + + if (changed) + PublishSnapshot(); + } + } + + /// + /// Removes all keys. + /// + public void Clear() + { + lock (_syncRoot) + { + if (_keys.Count == 0) return; + + _keys.Clear(); + PublishSnapshot(); + } + } + + /// + /// Returns the current keys as an immutable snapshot; the same instance is returned until the keys change. + /// + public IReadOnlyCollection GetKeys() => Volatile.Read(ref _snapshot); + + private void PublishSnapshot() => Volatile.Write(ref _snapshot, Array.AsReadOnly(_keys.ToArray())); +} diff --git a/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs b/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs index 66e9334..a9febd7 100644 --- a/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs +++ b/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs @@ -36,7 +36,8 @@ public sealed class SensitiveDataMasker : ISensitiveDataMasker private readonly TimeSpan _matchTimeout; private readonly KeySet _keys; private readonly IMaskedKeysAccessor? _runtimeKeysAccessor; - // True when the accessor hands out immutable snapshots (MaskedKeysContext), so an unchanged reference means unchanged keys. + // True when the accessor hands out immutable snapshots (MaskedKeysStore, MaskedKeysContext), so an unchanged reference + // means unchanged keys. Any other accessor may return a collection it changes later, so it is compared by content. private readonly bool _runtimeKeysAreSnapshots; private KeySet? _runtimeKeys; @@ -73,7 +74,7 @@ internal SensitiveDataMasker( _placeholder = placeholder; _matchTimeout = matchTimeout; _runtimeKeysAccessor = runtimeKeysAccessor; - _runtimeKeysAreSnapshots = runtimeKeysAccessor is GlobalMaskedKeysAccessor; + _runtimeKeysAreSnapshots = runtimeKeysAccessor is GlobalMaskedKeysAccessor or MaskedKeysStore; var literals = dynamicKeys?.ToArray() ?? []; _keys = new KeySet( diff --git a/tests/Tjidde.Logging.Tests/MaskedKeysStoreTests.cs b/tests/Tjidde.Logging.Tests/MaskedKeysStoreTests.cs new file mode 100644 index 0000000..f3644fd --- /dev/null +++ b/tests/Tjidde.Logging.Tests/MaskedKeysStoreTests.cs @@ -0,0 +1,144 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Extensions; +using Tjidde.Logging.Logging; +using Tjidde.Logging.Masking; +using Xunit; + +namespace Tjidde.Logging.Tests; + +// The stores are isolated, but the end-to-end tests capture Console output, so they share the sequential collection. +[Collection("ConsoleOutput")] +public sealed class MaskedKeysStoreTests +{ + private static string CaptureConsoleOutput(Action action) + { + var original = Console.Out; + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + action(); + } + finally + { + Console.SetOut(original); + } + return writer.ToString(); + } + + private static ServiceProvider BuildIsolatedHost() + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddTjiddeLogger().UseIsolatedMaskedKeys()); + return services.BuildServiceProvider(); + } + + [Fact] + public void GetKeys_ReturnsTheSameImmutableSnapshotUntilTheKeysChange() + { + var store = new MaskedKeysStore(); + var empty = store.GetKeys(); + store.GetKeys().Should().BeSameAs(empty); + + store.Add("customerCode", " ", null!); + var afterAdd = store.GetKeys(); + afterAdd.Should().NotBeSameAs(empty).And.BeEquivalentTo("customerCode"); + empty.Should().BeEmpty(); + + // No change, no new snapshot + store.Add("CUSTOMERCODE"); + store.Remove("unknown"); + store.GetKeys().Should().BeSameAs(afterAdd); + + store.Remove("customercode"); + store.GetKeys().Should().BeEmpty(); + afterAdd.Should().BeEquivalentTo("customerCode"); + + store.Add("a", "b"); + store.Clear(); + store.GetKeys().Should().BeEmpty(); + } + + [Fact] + public void UseIsolatedMaskedKeys_RegistersOneStoreAsTheAccessor_RegardlessOfOrder() + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.UseIsolatedMaskedKeys().AddTjiddeLogger()); + using var provider = services.BuildServiceProvider(); + + var store = provider.GetRequiredService(); + provider.GetRequiredService().Should().BeSameAs(store); + + using var defaultProvider = new ServiceCollection() + .AddLogging(builder => builder.AddTjiddeLogger()) + .BuildServiceProvider(); + defaultProvider.GetRequiredService().Should().BeOfType(); + } + + [Fact] + public void TwoHosts_WithTheirOwnStore_MaskIndependently() + { + var codeA = $"code-a-{Guid.NewGuid():N}"; + var codeB = $"code-b-{Guid.NewGuid():N}"; + + using var hostA = BuildIsolatedHost(); + using var hostB = BuildIsolatedHost(); + var storeA = hostA.GetRequiredService(); + var storeB = hostB.GetRequiredService(); + storeA.Should().NotBeSameAs(storeB); + + storeA.Add(codeA); + storeB.Add(codeB); + + var maskerA = MaskerOf(hostA); + var maskerB = MaskerOf(hostB); + + // " | " keeps the "key value" pattern of one code from masking the other + maskerA.MaskMessage($"{codeA} | {codeB}").Should().Be($"[REDACTED] | {codeB}"); + maskerB.MaskMessage($"{codeA} | {codeB}").Should().Be($"{codeA} | [REDACTED]"); + maskerA.IsSensitiveKey(codeB).Should().BeFalse(); + maskerB.IsSensitiveKey(codeB).Should().BeTrue(); + + // Nothing leaks into the process-wide context + MaskedKeysContext.GetKeys().Should().NotContain(codeA).And.NotContain(codeB); + } + + [Fact] + public void StoreChanges_ApplyToLoggersThatAlreadyExist() + { + var code = $"code-{Guid.NewGuid():N}"; + var otherCode = $"other-{Guid.NewGuid():N}"; + + var output = CaptureConsoleOutput(() => + { + using var host = BuildIsolatedHost(); + using var otherHost = BuildIsolatedHost(); + var store = host.GetRequiredService(); + var logger = host.GetRequiredService>(); + var otherLogger = otherHost.GetRequiredService>(); + otherHost.GetRequiredService().Add(otherCode); + + logger.LogInformation("before add: " + code); + store.Add(code); + logger.LogInformation("after add: " + code + " | " + otherCode); + otherLogger.LogInformation("other host: " + code + " | " + otherCode); + store.Remove(code); + logger.LogInformation("after remove: " + code); + }); + + var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + lines.Should().HaveCount(4); + lines.Should().Contain(line => line.EndsWith($"before add: {code}")); + lines.Should().Contain(line => line.EndsWith($"after add: [REDACTED] | {otherCode}")); + lines.Should().Contain(line => line.EndsWith($"other host: {code} | [REDACTED]")); + lines.Should().Contain(line => line.EndsWith($"after remove: {code}")); + } + + private static ISensitiveDataMasker MaskerOf(IServiceProvider services) + { + var provider = services.GetServices().OfType().Single(); + return ((TjiddeLogger)provider.CreateLogger("Isolation")).Configuration.Masker; + } +} diff --git a/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs b/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs index 88c2494..3da5aeb 100644 --- a/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs +++ b/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs @@ -35,8 +35,10 @@ public void Loggers_ShareOneMaskerPerConfiguration_AndSwitchTogetherOnOptionsCha first.Configuration.Masker.MaskValue("password", "secret").Should().Be("***"); } - [Fact] - public void ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows(bool useGlobalContext) { var id = Guid.NewGuid().ToString("N"); var permanentKey = $"perm-{id}"; @@ -47,12 +49,17 @@ public void ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows() var original = Console.Out; using var writer = new StringWriter(); var errors = new List(); - MaskedKeysContext.Add(permanentKey); + // Both snapshot accessors: the process-wide MaskedKeysContext and an isolated MaskedKeysStore + var store = new MaskedKeysStore(); + IMaskedKeysAccessor accessor = useGlobalContext ? new GlobalMaskedKeysAccessor() : store; + Action add = useGlobalContext ? MaskedKeysContext.Add : store.Add; + Action remove = useGlobalContext ? MaskedKeysContext.Remove : store.Remove; + add([permanentKey]); try { Console.SetOut(writer); var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions()); - using (var provider = new TjiddeLoggerProvider(monitor, new AsyncLocalCustomerContextAccessor(), new GlobalMaskedKeysAccessor())) + using (var provider = new TjiddeLoggerProvider(monitor, new AsyncLocalCustomerContextAccessor(), accessor)) { using var stop = new CancellationTokenSource(); var churn = Task.Run(() => @@ -61,8 +68,8 @@ public void ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows() while (!stop.IsCancellationRequested) { var key = churnKeys[i++ % churnKeys.Length]; - MaskedKeysContext.Add(key); - MaskedKeysContext.Remove(key); + add([key]); + remove([key]); } }); @@ -97,8 +104,8 @@ public void ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows() finally { Console.SetOut(original); - MaskedKeysContext.Remove(permanentKey); - MaskedKeysContext.Remove(churnKeys); + remove([permanentKey]); + remove(churnKeys); } errors.Should().BeEmpty(); From aab9350273731ac56e7e9cda386ea5f105c08e79 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 14:51:13 +0200 Subject: [PATCH 14/28] Add ILogSink abstraction, InMemoryLogSink and WriteToConsole Entries can now go to extra destinations next to the console. ILogSink receives an immutable, masked TjiddeLogEntry (timestamp, level, event id, category, class, method, customer, message, formatted exception, rendered line, output format, IsMetrics). Sinks are registered with AddTjiddeSink, AddTjiddeInMemorySink or plain DI, called synchronously, and isolated: a throwing sink never breaks logging, the console or other sinks, and entries logged from inside a sink are not dispatched again. TjiddeLoggerOptions.WriteToConsole (default true) turns the console off, also through an options reload. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 36 +- docs/AI-INTEGRATION.md | 24 +- docs/wiki-en.md | 65 +++- docs/wiki-nl.md | 65 +++- .../Logging/InMemoryLogSink.cs | 46 --- .../Logging/InMemoryLoggerProvider.cs | 78 ---- .../Logging/InMemoryLogSink.cs | 34 -- .../Logging/InMemoryLoggerProvider.cs | 50 --- samples/Tjidde.Logging.TuiApp/Logging.cs | 81 ---- .../TjiddeLoggingBuilderExtensions.cs | 63 ++- src/Tjidde.Logging/Logging/TjiddeLogger.cs | 73 +++- .../Logging/TjiddeLoggerProvider.cs | 29 +- .../Options/TjiddeLogOutputFormat.cs | 3 + .../Options/TjiddeLoggerOptions.cs | 8 + src/Tjidde.Logging/Sinks/ILogSink.cs | 30 ++ src/Tjidde.Logging/Sinks/InMemoryLogSink.cs | 86 +++++ src/Tjidde.Logging/Sinks/LogSinkDispatcher.cs | 61 +++ src/Tjidde.Logging/Sinks/TjiddeLogEntry.cs | 58 +++ tests/Tjidde.Logging.Tests/LogSinkTests.cs | 365 ++++++++++++++++++ 20 files changed, 948 insertions(+), 313 deletions(-) delete mode 100644 samples/Tjidde.Logging.DemoApp/Logging/InMemoryLogSink.cs delete mode 100644 samples/Tjidde.Logging.DemoApp/Logging/InMemoryLoggerProvider.cs delete mode 100644 samples/Tjidde.Logging.DesktopApp/Logging/InMemoryLogSink.cs delete mode 100644 samples/Tjidde.Logging.DesktopApp/Logging/InMemoryLoggerProvider.cs delete mode 100644 samples/Tjidde.Logging.TuiApp/Logging.cs create mode 100644 src/Tjidde.Logging/Sinks/ILogSink.cs create mode 100644 src/Tjidde.Logging/Sinks/InMemoryLogSink.cs create mode 100644 src/Tjidde.Logging/Sinks/LogSinkDispatcher.cs create mode 100644 src/Tjidde.Logging/Sinks/TjiddeLogEntry.cs create mode 100644 tests/Tjidde.Logging.Tests/LogSinkTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f23374c..d71f353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()`, `AddTjiddeSink(instance)` or `services.AddSingleton()`. 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?)`. +- `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.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. diff --git a/README.md b/README.md index fc690a5..e4df770 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,35 @@ Inner Exception: --- +## 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(); // your own sink, created by the container + +// or register directly: +builder.Services.AddSingleton(); +``` + +```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` 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 ``` @@ -210,6 +239,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) | @@ -260,7 +290,6 @@ 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. - **Automatic argument logging** — Explicitly deferred due to privacy, security, and performance risks. When added, it will require opt-in per method or argument. @@ -280,8 +309,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) diff --git a/docs/AI-INTEGRATION.md b/docs/AI-INTEGRATION.md index 5b615a5..9832c77 100644 --- a/docs/AI-INTEGRATION.md +++ b/docs/AI-INTEGRATION.md @@ -13,15 +13,17 @@ Tjidde.Logging is a logging provider for `Microsoft.Extensions.Logging`. Applica - masking of sensitive values (passwords, tokens, API keys, and so on) - compact single-line exception formatting - optional OpenTelemetry span events +- optional extra destinations (sinks) next to, or instead of, the console Target frameworks: `net7.0`, `net8.0`, `net9.0`, `net10.0`. | Namespace | Contains | |---|---| -| `Tjidde.Logging.Extensions` | `AddTjiddeLogger`, `UseTjiddeJsonFormat`, `UseTjiddeTextFormat`, `UseIsolatedMaskedKeys`, `BeginMethodScope`, `LogMetrics` | +| `Tjidde.Logging.Extensions` | `AddTjiddeLogger`, `UseTjiddeJsonFormat`, `UseTjiddeTextFormat`, `UseIsolatedMaskedKeys`, `AddTjiddeSink`, `AddTjiddeInMemorySink`, `BeginMethodScope`, `LogMetrics` | | `Tjidde.Logging.Options` | `TjiddeLoggerOptions`, `TjiddeLogOutputFormat` | | `Tjidde.Logging.Context` | `ICustomerContextAccessor`, `AsyncLocalCustomerContextAccessor`, `CustomerContext` | | `Tjidde.Logging.Masking` | `IMaskedKeysAccessor`, `MaskedKeysStore`, `MaskedKeysContext` | +| `Tjidde.Logging.Sinks` | `ILogSink`, `TjiddeLogEntry`, `InMemoryLogSink` | Prefer the DI route for testable code: implement `ICustomerContextAccessor` and use `UseIsolatedMaskedKeys()` with an injected `MaskedKeysStore`. The static `CustomerContext` and `MaskedKeysContext` are the convenience defaults; `MaskedKeysContext` is shared by every host and test in the process. @@ -287,6 +289,23 @@ builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing.AddSource("Tjidde.Logging")); ``` +## Sinks (other destinations) + +The console is the default destination. To also send entries elsewhere, register an `ILogSink`: + +```csharp +builder.Logging + .AddTjiddeLogger() + .AddTjiddeInMemorySink(capacity: 500) // built-in; inject InMemoryLogSink to read it + .AddTjiddeSink(); // or: builder.Services.AddSingleton() +``` + +- `ILogSink.Write(TjiddeLogEntry entry)` receives an immutable entry with `Timestamp`, `Level`, `EventId`, `Category`, `ClassName`, `MethodName`, `Customer`, `Message`, `FormattedException`, `RenderedLine`, `OutputFormat` and `IsMetrics`. All text is already masked. +- Sinks run synchronously on the logging thread: keep `Write` fast and thread-safe, and queue slow I/O yourself. A sink that throws is ignored; logging and the other sinks continue. +- Do not inject `ILogger` or `ILoggerFactory` into a sink's constructor (circular dependency). +- Recognize metrics entries with `entry.IsMetrics` (event `MetricsEventId`), not with the obsolete `MetricsLoggerExtensions.Metrics` level. +- Set `WriteToConsole = false` to write only to the sinks (for example in a desktop or terminal UI). + ## Shutdown and flushing A background thread writes the lines to the console, so log calls do not wait for console I/O. Pending lines are flushed when the host stops, when a `LoggerFactory` is disposed, and on normal process exit. Lines still queued when the process crashes can be lost. @@ -295,7 +314,7 @@ A background thread writes the lines to the console, so log calls do not wait fo The clock is the `TimeProvider` registered in DI, or `TimeProvider.System` when none is registered. To assert on timestamps, register a `FakeTimeProvider` (package `Microsoft.Extensions.TimeProvider.Testing`) with `services.AddSingleton(clock)`. -In unit tests, inject `NullLogger.Instance` (from `Microsoft.Extensions.Logging.Abstractions`) instead of the Tjidde logger. If a test must read the console output, dispose the `LoggerFactory` first so all lines have been written. +In unit tests, inject `NullLogger.Instance` (from `Microsoft.Extensions.Logging.Abstractions`) instead of the Tjidde logger. To assert on what the Tjidde logger writes, use `AddTjiddeInMemorySink()` (optionally with `WriteToConsole = false`) and read the injected `InMemoryLogSink.GetSnapshot()`: sinks are called synchronously, so no flushing is needed. If a test must read the console output instead, dispose the `LoggerFactory` first so all lines have been written. ## Options reference @@ -304,6 +323,7 @@ Section name in `appsettings.json`: `TjiddeLogger`. | Option | Type | Default | Meaning | |---|---|---|---| | `OutputFormat` | `TjiddeLogOutputFormat` | `Text` | `Text` or `Json`. | +| `WriteToConsole` | `bool` | `true` | Write entries to the console. `false` writes only to the registered sinks. | | `MinimumLevel` | `LogLevel` | `Trace` | Obsolete, removed in 2.0: use `Logging:Tjidde:LogLevel`. Minimum level when no category rule matches. | | `CategoryMinimumLevels` | `IDictionary` | empty | Obsolete, removed in 2.0: use `Logging:Tjidde:LogLevel`. Minimum level per category or namespace prefix; the key `Default` is the fallback. | | `IncludeScopes` | `bool` | `true` | Append scope values to each entry. Also required for `BeginMethodScope`. | diff --git a/docs/wiki-en.md b/docs/wiki-en.md index 0601f95..1bc8aa7 100644 --- a/docs/wiki-en.md +++ b/docs/wiki-en.md @@ -12,8 +12,9 @@ 8. [Exception Formatting](#exception-formatting) 9. [Scopes](#scopes) 10. [Metrics Entries](#metrics-entries) -11. [CI/CD (GitHub Actions)](#cicd-github-actions) -12. [Future Work](#future-work) +11. [Sinks](#sinks) +12. [CI/CD (GitHub Actions)](#cicd-github-actions) +13. [Future Work](#future-work) --- @@ -278,6 +279,7 @@ builder.Logging.AddTjiddeLogger(options => | Option | Type | Default | Description | |---|---|---|---| | `OutputFormat` | `TjiddeLogOutputFormat` | `Text` | Log rendering mode: `Text` or `Json` (Elasticsearch-friendly JSON line). | +| `WriteToConsole` | `bool` | `true` | Writes entries to the console. `false` writes only to the registered [sinks](#sinks). | | `MinimumLevel` *(obsolete)* | `LogLevel` | `Trace` | Global minimum log level fallback when no category override matches. Use `Logging:Tjidde:LogLevel` instead; removed in 2.0. | | `CategoryMinimumLevels` *(obsolete)* | `IDictionary` | `{}` | Category/namespace/class-specific minimum levels. Use `Logging:Tjidde:LogLevel` instead; removed in 2.0. | | `EnableOpenTelemetryExport` | `bool` | `false` | Emits each log as an OpenTelemetry event on the current `Activity` (for OTEL pipelines). | @@ -583,6 +585,63 @@ Earlier versions logged metrics at the custom level `MetricsLoggerExtensions.Met --- +## Sinks + +The console is the default destination. To send entries somewhere else as well (a UI, a file, a test assertion), implement `ILogSink` (namespace `Tjidde.Logging.Sinks`) or use the built-in `InMemoryLogSink`. + +### Registering sinks + +```csharp +builder.Logging + .AddTjiddeLogger() + .AddTjiddeInMemorySink(capacity: 500) // built-in, inject InMemoryLogSink to read it + .AddTjiddeSink(); // created by the container, also injectable as MyFileSink + +builder.Logging.AddTjiddeSink(new MySink()); // an existing instance +builder.Services.AddSingleton(); // plain DI works too +``` + +### Writing a sink + +```csharp +public sealed class MyFileSink : ILogSink +{ + public void Write(TjiddeLogEntry entry) + { + // entry.RenderedLine is exactly what the console gets (text or JSON, see entry.OutputFormat) + } +} +``` + +`TjiddeLogEntry` is immutable and contains only masked data: + +| Property | Description | +|---|---| +| `Timestamp` | `DateTimeOffset`; UTC or local, following `UseUtcTimestamp` | +| `Level` / `EventId` | Level and event ID; `IsMetrics` is `true` for `LogMetrics` entries (event `Metrics`) | +| `Category` / `ClassName` / `MethodName` | Logger category, its last segment, and the method from a `MethodName` scope | +| `Customer` | The customer context, or `null` | +| `Message` | The rendered, masked message | +| `FormattedException` | The formatted, masked exception, or `null` | +| `RenderedLine` / `OutputFormat` | The complete rendered line and its format | + +Rules: + +- Sinks are called **synchronously** on the thread that logs, in registration order. `Write` must be fast and thread-safe; queue slow I/O yourself. +- An exception thrown by a sink is ignored: the log call never throws, and the console and the other sinks still receive the entry. +- An entry logged from inside `Write` is not sent to the sinks again, so a sink cannot recurse. +- A sink must not take `ILogger` or `ILoggerFactory` in its constructor (circular dependency). + +### InMemoryLogSink + +Keeps the newest `Capacity` entries (default 1000) and drops the oldest. `GetSnapshot()` returns a copy, oldest first; `Count`, `Clear()`, and the events `EntryAdded` (on the logging thread) and `Cleared` complete it. All members are thread-safe. The samples in `samples/` use it to show the log in a Blazor, Avalonia and terminal UI. + +### Console off + +`WriteToConsole = false` writes only to the sinks, for example in a desktop or terminal UI where console output is not wanted. It can be set in `appsettings.json` (`"TjiddeLogger": { "WriteToConsole": false }`) and applies immediately when the options reload. + +--- + ## CI/CD (GitHub Actions) Two workflows live in `.github/workflows/`: @@ -624,7 +683,7 @@ The tag sets the package version: `v1.0.4` publishes `1.0.4`, and pre-releases s The following improvements and features are planned or considered for future releases: -- **File sink** — Write log output to rolling log files in addition to the console. +- **File sink** — A built-in `ILogSink` that writes rolling log files. - **Redaction audit log** — Optionally emit a separate audit entry listing which fields were redacted, for compliance scenarios. - **Custom sensitive key providers** — Allow injecting `ISensitiveKeyProvider` implementations so keys can be loaded from configuration or a secrets store at runtime. - **NuGet package signing** — Sign the NuGet package in the pipeline for supply-chain security. diff --git a/docs/wiki-nl.md b/docs/wiki-nl.md index ba2f020..88107fb 100644 --- a/docs/wiki-nl.md +++ b/docs/wiki-nl.md @@ -12,8 +12,9 @@ 8. [Uitzonderingsopmaak](#uitzonderingsopmaak) 9. [Scopes](#scopes) 10. [Metrics-regels](#metrics-regels) -11. [CI/CD (GitHub Actions)](#cicd-github-actions) -12. [Toekomstige ontwikkelingen](#toekomstige-ontwikkelingen) +11. [Sinks](#sinks) +12. [CI/CD (GitHub Actions)](#cicd-github-actions) +13. [Toekomstige ontwikkelingen](#toekomstige-ontwikkelingen) --- @@ -278,6 +279,7 @@ builder.Logging.AddTjiddeLogger(options => | Optie | Type | Standaard | Beschrijving | |---|---|---|---| | `OutputFormat` | `TjiddeLogOutputFormat` | `Text` | Weergavemodus: `Text` of `Json` (Elasticsearch-vriendelijke JSON-regel). | +| `WriteToConsole` | `bool` | `true` | Schrijft regels naar de console. `false` schrijft alleen naar de geregistreerde [sinks](#sinks). | | `MinimumLevel` *(verouderd)* | `LogLevel` | `Trace` | Globaal minimum logniveau als er geen categorie-override matcht. Gebruik `Logging:Tjidde:LogLevel`; verdwijnt in 2.0. | | `CategoryMinimumLevels` *(verouderd)* | `IDictionary` | `{}` | Categorie-/namespace-/klasse-specifieke minimum niveaus. Gebruik `Logging:Tjidde:LogLevel`; verdwijnt in 2.0. | | `EnableOpenTelemetryExport` | `bool` | `false` | Stuurt elke logregel als OpenTelemetry-event op de huidige `Activity` (voor OTEL-pipelines). | @@ -583,6 +585,63 @@ Eerdere versies logden metrics op het aangepaste niveau `MetricsLoggerExtensions --- +## Sinks + +De console is de standaardbestemming. Om regels ook ergens anders heen te sturen (een UI, een bestand, een test), implementeer je `ILogSink` (namespace `Tjidde.Logging.Sinks`) of gebruik je de ingebouwde `InMemoryLogSink`. + +### Sinks registreren + +```csharp +builder.Logging + .AddTjiddeLogger() + .AddTjiddeInMemorySink(capacity: 500) // ingebouwd, injecteer InMemoryLogSink om te lezen + .AddTjiddeSink(); // gemaakt door de container, ook injecteerbaar als MyFileSink + +builder.Logging.AddTjiddeSink(new MySink()); // een bestaande instantie +builder.Services.AddSingleton(); // gewone DI werkt ook +``` + +### Een sink schrijven + +```csharp +public sealed class MyFileSink : ILogSink +{ + public void Write(TjiddeLogEntry entry) + { + // entry.RenderedLine is precies wat de console krijgt (tekst of JSON, zie entry.OutputFormat) + } +} +``` + +`TjiddeLogEntry` is immutable en bevat alleen gemaskeerde gegevens: + +| Eigenschap | Beschrijving | +|---|---| +| `Timestamp` | `DateTimeOffset`; UTC of lokaal, volgens `UseUtcTimestamp` | +| `Level` / `EventId` | Niveau en event-ID; `IsMetrics` is `true` voor `LogMetrics`-regels (event `Metrics`) | +| `Category` / `ClassName` / `MethodName` | Loggercategorie, het laatste deel daarvan, en de methode uit een `MethodName`-scope | +| `Customer` | De klantcontext, of `null` | +| `Message` | Het gerenderde, gemaskeerde bericht | +| `FormattedException` | De opgemaakte, gemaskeerde exception, of `null` | +| `RenderedLine` / `OutputFormat` | De volledige gerenderde regel en het formaat daarvan | + +Regels: + +- Sinks worden **synchroon** aangeroepen op de thread die logt, in volgorde van registratie. `Write` moet snel en thread-safe zijn; zet trage I/O zelf in een wachtrij. +- Een exception uit een sink wordt genegeerd: de logaanroep gooit nooit, en de console en de andere sinks krijgen de regel nog steeds. +- Een regel die binnen `Write` gelogd wordt, gaat niet opnieuw naar de sinks, dus een sink kan niet recursief worden. +- Een sink mag geen `ILogger` of `ILoggerFactory` in zijn constructor vragen (circulaire afhankelijkheid). + +### InMemoryLogSink + +Bewaart de nieuwste `Capacity` regels (standaard 1000) en laat de oudste vallen. `GetSnapshot()` geeft een kopie, oudste eerst; daarnaast zijn er `Count`, `Clear()` en de events `EntryAdded` (op de logthread) en `Cleared`. Alle members zijn thread-safe. De voorbeelden in `samples/` gebruiken hem om de log te tonen in een Blazor-, Avalonia- en terminal-UI. + +### Console uitzetten + +`WriteToConsole = false` schrijft alleen naar de sinks, bijvoorbeeld in een desktop- of terminal-UI waar console-uitvoer ongewenst is. Het kan in `appsettings.json` (`"TjiddeLogger": { "WriteToConsole": false }`) en geldt direct wanneer de opties herladen. + +--- + ## CI/CD (GitHub Actions) In `.github/workflows/` staan twee workflows: @@ -624,7 +683,7 @@ De tag bepaalt de pakketversie: `v1.0.4` publiceert `1.0.4`, en pre-releases zoa De volgende verbeteringen en functies zijn gepland of worden overwogen voor toekomstige releases: -- **Bestandssink** — Loguitvoer wegschrijven naar roterende logbestanden naast de console. +- **Bestandssink** — Een ingebouwde `ILogSink` die naar roterende logbestanden schrijft. - **Redactie-auditlog** — Optioneel een aparte auditmelding uitsturen met de lijst van gemaskeerde velden, voor compliancescenario's. - **Aangepaste gevoelige-sleutelproviders** — Het injecteren van `ISensitiveKeyProvider`-implementaties toestaan, zodat sleutels tijdens runtime uit configuratie of een secrets store kunnen worden geladen. - **NuGet-pakketondertekening** — Het NuGet-pakket ondertekenen in de pipeline voor supply-chain-beveiliging. diff --git a/samples/Tjidde.Logging.DemoApp/Logging/InMemoryLogSink.cs b/samples/Tjidde.Logging.DemoApp/Logging/InMemoryLogSink.cs deleted file mode 100644 index f6db61f..0000000 --- a/samples/Tjidde.Logging.DemoApp/Logging/InMemoryLogSink.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace Tjidde.Logging.DemoApp.Logging; - -/// -/// A thread-safe in-memory sink that captures formatted log entries for display in the demo UI. -/// -public sealed class InMemoryLogSink -{ - private readonly List _entries = new(); - private readonly Lock _lock = new(); - - public event Action? OnChanged; - - public void Add(LogEntry entry) - { - lock (_lock) - { - _entries.Add(entry); - // Keep a reasonable cap so the UI stays responsive - if (_entries.Count > 200) - _entries.RemoveAt(0); - } - OnChanged?.Invoke(); - } - - public IReadOnlyList GetEntries() - { - lock (_lock) - return _entries.ToList(); - } - - public void Clear() - { - lock (_lock) - _entries.Clear(); - OnChanged?.Invoke(); - } -} - -public sealed record LogEntry( - DateTimeOffset Timestamp, - LogLevel Level, - string Category, - string FormattedMessage, - string? ExceptionDetails); diff --git a/samples/Tjidde.Logging.DemoApp/Logging/InMemoryLoggerProvider.cs b/samples/Tjidde.Logging.DemoApp/Logging/InMemoryLoggerProvider.cs deleted file mode 100644 index d60e9ee..0000000 --- a/samples/Tjidde.Logging.DemoApp/Logging/InMemoryLoggerProvider.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace Tjidde.Logging.DemoApp.Logging; - -/// -/// An that writes to so the demo UI -/// can display log entries alongside the Tjidde console output. -/// -[ProviderAlias("InMemory")] -public sealed class InMemoryLoggerProvider : ILoggerProvider -{ - private readonly InMemoryLogSink _sink; - - public InMemoryLoggerProvider(InMemoryLogSink sink) - { - _sink = sink; - } - - public ILogger CreateLogger(string categoryName) => - new InMemoryLogger(categoryName, _sink); - - public void Dispose() { } -} - -internal sealed class InMemoryLogger : ILogger -{ - private readonly string _category; - private readonly InMemoryLogSink _sink; - - public InMemoryLogger(string category, InMemoryLogSink sink) - { - _category = category; - _sink = sink; - } - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - if (!IsEnabled(logLevel)) return; - - var message = formatter(state, exception); - var exceptionDetails = exception is not null - ? FormatException(exception) - : null; - - _sink.Add(new LogEntry( - Timestamp: DateTimeOffset.Now, - Level: logLevel, - Category: _category, - FormattedMessage: message, - ExceptionDetails: exceptionDetails)); - } - - private static string FormatException(Exception ex) - { - var sb = new System.Text.StringBuilder(); - var current = ex; - var depth = 0; - while (current is not null) - { - if (depth > 0) sb.AppendLine(" ---> Inner exception:"); - sb.AppendLine($" {current.GetType().FullName}: {current.Message}"); - if (current.StackTrace is { } st) - sb.AppendLine(st); - current = current.InnerException; - depth++; - } - return sb.ToString().TrimEnd(); - } -} diff --git a/samples/Tjidde.Logging.DesktopApp/Logging/InMemoryLogSink.cs b/samples/Tjidde.Logging.DesktopApp/Logging/InMemoryLogSink.cs deleted file mode 100644 index e273544..0000000 --- a/samples/Tjidde.Logging.DesktopApp/Logging/InMemoryLogSink.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; - -namespace Tjidde.Logging.DesktopApp.Logging; - -public sealed class LogEntry -{ - public DateTimeOffset Timestamp { get; init; } - public LogLevel Level { get; init; } - public string Category { get; init; } = string.Empty; - public string FormattedMessage { get; init; } = string.Empty; - public string? ExceptionDetails { get; init; } -} - -public sealed class InMemoryLogSink -{ - private readonly ConcurrentQueue _entries = new(); - - public event Action? OnChanged; - - public void Add(LogEntry entry) - { - _entries.Enqueue(entry); - OnChanged?.Invoke(); - } - - public IReadOnlyList GetEntries() => _entries.ToArray(); - - public void Clear() - { - while (_entries.TryDequeue(out _)) { } - OnChanged?.Invoke(); - } -} diff --git a/samples/Tjidde.Logging.DesktopApp/Logging/InMemoryLoggerProvider.cs b/samples/Tjidde.Logging.DesktopApp/Logging/InMemoryLoggerProvider.cs deleted file mode 100644 index e219ac4..0000000 --- a/samples/Tjidde.Logging.DesktopApp/Logging/InMemoryLoggerProvider.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Tjidde.Logging.Formatting; -using Microsoft.Extensions.Logging; - -namespace Tjidde.Logging.DesktopApp.Logging; - -[ProviderAlias("InMemory")] -public sealed class InMemoryLoggerProvider : ILoggerProvider -{ - private readonly InMemoryLogSink _sink; - - public InMemoryLoggerProvider(InMemoryLogSink sink) => _sink = sink; - - public ILogger CreateLogger(string categoryName) => new InMemoryLogger(categoryName, _sink); - - public void Dispose() { } -} - -internal sealed class InMemoryLogger : ILogger -{ - private readonly string _category; - private readonly InMemoryLogSink _sink; - - public InMemoryLogger(string category, InMemoryLogSink sink) - { - _category = category; - _sink = sink; - } - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; - - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception? exception, Func formatter) - { - if (!IsEnabled(logLevel)) return; - - var message = formatter(state, exception); - var exDetails = exception is not null ? new ExceptionFormatter().Format(exception) : null; - - _sink.Add(new LogEntry - { - Timestamp = DateTimeOffset.Now, - Level = logLevel, - Category = _category, - FormattedMessage = message, - ExceptionDetails = exDetails - }); - } -} diff --git a/samples/Tjidde.Logging.TuiApp/Logging.cs b/samples/Tjidde.Logging.TuiApp/Logging.cs deleted file mode 100644 index 9e23b5a..0000000 --- a/samples/Tjidde.Logging.TuiApp/Logging.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Tjidde.Logging.Formatting; -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; - -namespace Tjidde.Logging.TuiApp.Logging; - -public sealed class LogEntry -{ - public DateTimeOffset Timestamp { get; init; } - public LogLevel Level { get; init; } - public string Category { get; init; } = string.Empty; - public string FormattedMessage { get; init; } = string.Empty; - public string? ExceptionDetails { get; init; } -} - -public sealed class InMemoryLogSink -{ - private readonly ConcurrentQueue _entries = new(); - - public event Action? OnChanged; - - public void Add(LogEntry entry) - { - _entries.Enqueue(entry); - OnChanged?.Invoke(); - } - - public IReadOnlyList GetEntries() => _entries.ToArray(); - - public void Clear() - { - while (_entries.TryDequeue(out _)) { } - OnChanged?.Invoke(); - } -} - -[ProviderAlias("InMemory")] -public sealed class InMemoryLoggerProvider : ILoggerProvider -{ - private readonly InMemoryLogSink _sink; - - public InMemoryLoggerProvider(InMemoryLogSink sink) => _sink = sink; - - public ILogger CreateLogger(string categoryName) => new InMemoryLogger(categoryName, _sink); - - public void Dispose() { } -} - -internal sealed class InMemoryLogger : ILogger -{ - private readonly string _category; - private readonly InMemoryLogSink _sink; - - public InMemoryLogger(string category, InMemoryLogSink sink) - { - _category = category; - _sink = sink; - } - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; - - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception? exception, Func formatter) - { - if (!IsEnabled(logLevel)) return; - - var message = formatter(state, exception); - var exDetails = exception is not null ? new ExceptionFormatter().Format(exception) : null; - - _sink.Add(new LogEntry - { - Timestamp = DateTimeOffset.Now, - Level = logLevel, - Category = _category, - FormattedMessage = message, - ExceptionDetails = exDetails - }); - } -} diff --git a/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs b/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs index 782c9f7..d40f840 100644 --- a/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs +++ b/src/Tjidde.Logging/Extensions/TjiddeLoggingBuilderExtensions.cs @@ -2,6 +2,7 @@ using Tjidde.Logging.Logging; using Tjidde.Logging.Masking; using Tjidde.Logging.Options; +using Tjidde.Logging.Sinks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -130,12 +131,72 @@ public static ILoggingBuilder UseIsolatedMaskedKeys(this ILoggingBuilder builder return builder; } + /// + /// Adds as an extra destination for Tjidde log entries. The sink is registered as a + /// singleton under its own type (so it can be injected) and as ; registering the same type + /// twice has no effect. Sinks are called synchronously on the logging thread and must be fast and thread-safe. + /// Sinks registered directly with services.AddSingleton<ILogSink, ...>() are used as well. + /// + /// The sink type, created by the container. + /// The . + /// The for chaining. + public static ILoggingBuilder AddTjiddeSink(this ILoggingBuilder builder) + where TSink : class, ILogSink + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.TryAddSingleton(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton(services => services.GetRequiredService())); + return builder; + } + + /// + /// Adds as an extra destination for Tjidde log entries. The instance is registered as + /// and, unless that type is already registered, under , so it + /// can be injected. Sinks are called synchronously on the logging thread and must be fast and thread-safe. + /// The container does not dispose an instance passed here. + /// + /// The sink type. + /// The . + /// The sink instance. + /// The for chaining. + public static ILoggingBuilder AddTjiddeSink(this ILoggingBuilder builder, TSink sink) + where TSink : class, ILogSink + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(sink); + builder.Services.TryAddSingleton(sink); + builder.Services.AddSingleton(sink); + return builder; + } + + /// + /// Adds an that keeps the last entries, for example to + /// show them in a UI. Inject to read them. Calling it again has no effect. + /// + /// The . + /// The maximum number of entries kept. Default: . + /// The for chaining. + /// is less than 1. + public static ILoggingBuilder AddTjiddeInMemorySink(this ILoggingBuilder builder, int capacity = InMemoryLogSink.DefaultCapacity) + { + ArgumentNullException.ThrowIfNull(builder); + if (capacity < 1) + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "The capacity must be at least 1."); + + builder.Services.TryAddSingleton(_ => new InMemoryLogSink(capacity)); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton(services => services.GetRequiredService())); + return builder; + } + private static TjiddeLoggerProvider CreateProvider(IServiceProvider services) => new( services.GetRequiredService>(), services.GetRequiredService(), services.GetRequiredService(), - services.GetService() ?? TimeProvider.System); + services.GetService() ?? TimeProvider.System, + services.GetServices()); /// Signals an options change when the bound configuration section reloads. private sealed class ConfigurationSectionChangeTokenSource : IOptionsChangeTokenSource diff --git a/src/Tjidde.Logging/Logging/TjiddeLogger.cs b/src/Tjidde.Logging/Logging/TjiddeLogger.cs index 3a51827..a65bd8d 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLogger.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLogger.cs @@ -3,6 +3,7 @@ using Tjidde.Logging.Formatting; using Tjidde.Logging.Masking; using Tjidde.Logging.Options; +using Tjidde.Logging.Sinks; using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Text.Json; @@ -29,6 +30,7 @@ internal sealed class TjiddeLogger : ILogger private readonly TjiddeLoggerConfigurationHolder _configuration; private readonly ICustomerContextAccessor _customerContextAccessor; private readonly ConsoleLogProcessor _processor; + private readonly LogSinkDispatcher _sinks; private readonly TimeProvider _timeProvider; private IExternalScopeProvider? _scopeProvider; @@ -43,6 +45,7 @@ internal sealed class TjiddeLogger : ILogger /// Creates a logger that reads its settings from on every call, /// so a configuration change applies to this logger immediately. /// Timestamps come from (default: ). + /// Entries go to the console (unless is off) and to . /// public TjiddeLogger( string categoryName, @@ -50,8 +53,10 @@ public TjiddeLogger( ICustomerContextAccessor customerContextAccessor, ConsoleLogProcessor processor, IExternalScopeProvider? scopeProvider, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + LogSinkDispatcher? sinks = null) { + _sinks = sinks ?? LogSinkDispatcher.Empty; _categoryName = categoryName; _configuration = configuration; _customerContextAccessor = customerContextAccessor; @@ -70,14 +75,16 @@ public TjiddeLogger( IExceptionFormatter exceptionFormatter, ConsoleLogProcessor processor, IExternalScopeProvider? scopeProvider, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + LogSinkDispatcher? sinks = null) : this( categoryName, new TjiddeLoggerConfigurationHolder(new TjiddeLoggerConfiguration(options, masker, exceptionFormatter)), customerContextAccessor, processor, scopeProvider, - timeProvider) + timeProvider, + sinks) { } @@ -211,13 +218,54 @@ private void WriteEntry( if (config.Options.IncludeScopes && _scopeProvider is not null) scopes = CollectScopes(config); + // Formatted (and masked) once, for the rendered line and the sinks. + var formattedException = exception is null ? null : FormatException(config, exception); + var rendered = config.Options.OutputFormat == TjiddeLogOutputFormat.Json - ? BuildJsonLogLine(config, now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties) - : BuildTextLogLine(config, now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames); + ? BuildJsonLogLine(config, now, logLevel, eventId, methodName, customer, message, formattedException, scopes, maskedKeyNames, properties) + : BuildTextLogLine(now, logLevel, eventId, methodName, customer, message, formattedException, scopes, maskedKeyNames); ExportToOpenTelemetry(config, now, logLevel, eventId, methodName, customer, message, exception, scopes, maskedKeyNames, properties); - _processor.Enqueue(rendered, config.Options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); + Emit(config, now, logLevel, eventId, methodName, customer, message, formattedException, rendered); + } + + /// + /// Writes the rendered entry to the console (when is on) + /// and passes it to the sinks. Only masked data reaches either. + /// + private void Emit( + TjiddeLoggerConfiguration config, + DateTimeOffset timestamp, + LogLevel logLevel, + EventId eventId, + string? methodName, + string? customer, + string message, + string? formattedException, + string rendered) + { + var format = config.Options.OutputFormat; + if (config.Options.WriteToConsole) + _processor.Enqueue(rendered, format == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); + + if (!_sinks.IsActive) + return; + + _sinks.Dispatch(new TjiddeLogEntry + { + Timestamp = timestamp, + Level = logLevel, + EventId = eventId, + Category = _categoryName, + ClassName = _className, + MethodName = methodName, + Customer = customer, + Message = message, + FormattedException = formattedException, + RenderedLine = rendered, + OutputFormat = format + }); } /// @@ -260,7 +308,7 @@ private void WriteFallbackEntry( $"[Log entry could not be rendered completely: {failure}] | Category: {_categoryName}"; } - _processor.Enqueue(line, config.Options.OutputFormat == TjiddeLogOutputFormat.Text ? GetColor(logLevel, eventId) : null); + Emit(config, now, logLevel, eventId, methodName: null, customer: null, message, formattedException: null, line); } catch (Exception) { @@ -522,14 +570,13 @@ private void ExportToOpenTelemetry( } private string BuildTextLogLine( - TjiddeLoggerConfiguration config, DateTimeOffset timestamp, LogLevel logLevel, EventId eventId, string? methodName, string? customer, string message, - Exception? exception, + string? formattedException, List? scopes, List maskedKeyNames) { @@ -539,8 +586,8 @@ private string BuildTextLogLine( if (maskedKeyNames.Count > 0) sb.Append($" [Masked: {string.Join(", ", maskedKeyNames)}]"); - if (exception is not null) - sb.Append($" | Exception: {FormatException(config, exception)}"); + if (formattedException is not null) + sb.Append($" | Exception: {formattedException}"); if (scopes is { Count: > 0 }) sb.Append($" | Scopes: {string.Join(" > ", scopes)}"); @@ -556,7 +603,7 @@ private string BuildJsonLogLine( string? methodName, string? customer, string message, - Exception? exception, + string? formattedException, List? scopes, List maskedKeyNames, List>? properties) @@ -574,7 +621,7 @@ private string BuildJsonLogLine( ["method"] = methodName, ["scopes"] = scopes is { Count: > 0 } ? scopes : null, ["maskedFields"] = maskedKeyNames.Count > 0 ? maskedKeyNames : null, - ["exception"] = exception is null ? null : FormatException(config, exception) + ["exception"] = formattedException }; if (properties is { Count: > 0 }) diff --git a/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs b/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs index d56b34b..d57c5db 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLoggerProvider.cs @@ -3,6 +3,7 @@ using Tjidde.Logging.Formatting; using Tjidde.Logging.Masking; using Tjidde.Logging.Options; +using Tjidde.Logging.Sinks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -23,6 +24,7 @@ public sealed class TjiddeLoggerProvider : ILoggerProvider, ISupportExternalScop private readonly object _sync = new(); private readonly ConcurrentDictionary _loggers = new(StringComparer.OrdinalIgnoreCase); private readonly ConsoleLogProcessor _processor = new(); + private readonly LogSinkDispatcher _sinks; private IExternalScopeProvider _scopeProvider = NoopExternalScopeProvider.Instance; private bool _disposed; @@ -48,9 +50,34 @@ public TjiddeLoggerProvider( ICustomerContextAccessor customerContextAccessor, IMaskedKeysAccessor maskedKeysAccessor, TimeProvider timeProvider) + : this(optionsMonitor, customerContextAccessor, maskedKeysAccessor, timeProvider, sinks: null) + { + } + + /// + /// Initializes a new instance of that takes its timestamps from + /// and passes every entry to besides the console. + /// + /// The options; changes apply to existing loggers immediately. + /// Supplies the customer of each entry. + /// Supplies the runtime masked keys. + /// The clock for timestamps. + /// + /// Extra destinations for the entries, called synchronously in this order. means none. + /// A sink that throws is ignored and does not affect the others. + /// + /// is . + public TjiddeLoggerProvider( + IOptionsMonitor optionsMonitor, + ICustomerContextAccessor customerContextAccessor, + IMaskedKeysAccessor maskedKeysAccessor, + TimeProvider timeProvider, + IEnumerable? sinks) { ArgumentNullException.ThrowIfNull(timeProvider); + _sinks = new LogSinkDispatcher(sinks); + _customerContextAccessor = customerContextAccessor; _maskedKeysAccessor = maskedKeysAccessor; _timeProvider = timeProvider; @@ -109,7 +136,7 @@ public void Dispose() { } } private TjiddeLogger CreateLoggerInstance(string categoryName) - => new(categoryName, _configuration, _customerContextAccessor, _processor, _scopeProvider, _timeProvider); + => new(categoryName, _configuration, _customerContextAccessor, _processor, _scopeProvider, _timeProvider, _sinks); private void OnOptionsChanged(TjiddeLoggerOptions options, string? name) { diff --git a/src/Tjidde.Logging/Options/TjiddeLogOutputFormat.cs b/src/Tjidde.Logging/Options/TjiddeLogOutputFormat.cs index d8825c1..ef2c6af 100644 --- a/src/Tjidde.Logging/Options/TjiddeLogOutputFormat.cs +++ b/src/Tjidde.Logging/Options/TjiddeLogOutputFormat.cs @@ -5,6 +5,9 @@ namespace Tjidde.Logging.Options; /// public enum TjiddeLogOutputFormat { + /// One human-readable line per entry: YYYY-MM-DD: HH:mm:ss: [LEVEL] Class=>... : message. Text = 0, + + /// One JSON object per entry, for log collectors. Json = 1 } diff --git a/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs b/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs index a559af8..2ceda7a 100644 --- a/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs +++ b/src/Tjidde.Logging/Options/TjiddeLoggerOptions.cs @@ -13,6 +13,14 @@ public sealed class TjiddeLoggerOptions /// public TjiddeLogOutputFormat OutputFormat { get; set; } = TjiddeLogOutputFormat.Text; + /// + /// Whether entries are written to the console. Set to to write only to the registered + /// s (for example in a desktop or terminal UI). A change applies immediately when the + /// options reload. OpenTelemetry export is not affected. + /// Default: true. + /// + public bool WriteToConsole { get; set; } = true; + internal const string MinimumLevelObsoleteMessage = "Use the standard Microsoft.Extensions.Logging filters instead: Logging:Tjidde:LogLevel in appsettings.json " + "(or AddFilter(...) in code). This option will be removed in 2.0."; diff --git a/src/Tjidde.Logging/Sinks/ILogSink.cs b/src/Tjidde.Logging/Sinks/ILogSink.cs new file mode 100644 index 0000000..45fcef8 --- /dev/null +++ b/src/Tjidde.Logging/Sinks/ILogSink.cs @@ -0,0 +1,30 @@ +namespace Tjidde.Logging.Sinks; + +/// +/// A destination for Tjidde log entries besides (or instead of) the console, for example an in-memory buffer +/// for a UI, a file or a test assertion. Register sinks in DI (services.AddSingleton<ILogSink, MySink>()) +/// or with AddTjiddeSink; the provider passes every entry it writes to every sink. +/// +/// +/// +/// is called synchronously on the thread that logs, after the entry is rendered and masked. +/// Keep it fast and non-blocking: queue slow work (network, disk) yourself. +/// +/// +/// Implementations must be thread-safe: several threads can log at the same time. +/// An exception thrown by a sink is caught and ignored; it never reaches the caller and never stops other sinks +/// or the console from receiving the entry. Entries logged from inside are not passed to the +/// sinks again (they still go to the console), so a sink that logs cannot recurse. +/// +/// +/// Sinks are resolved when the logger provider is created, so a sink must not depend on ILogger<T> +/// or ILoggerFactory through its constructor (that is a circular dependency). The container disposes sinks +/// it created; the provider does not. +/// +/// +public interface ILogSink +{ + /// Receives one log entry. Called synchronously; must be fast and thread-safe. + /// The entry; contains only masked data. + void Write(TjiddeLogEntry entry); +} diff --git a/src/Tjidde.Logging/Sinks/InMemoryLogSink.cs b/src/Tjidde.Logging/Sinks/InMemoryLogSink.cs new file mode 100644 index 0000000..0bd3ca4 --- /dev/null +++ b/src/Tjidde.Logging/Sinks/InMemoryLogSink.cs @@ -0,0 +1,86 @@ +namespace Tjidde.Logging.Sinks; + +/// +/// A thread-safe that keeps the most recent entries in memory, for example to show them in a UI +/// or to assert on them in tests. When is reached, the oldest entry is dropped. +/// +/// +/// Register it with builder.AddTjiddeInMemorySink() and inject to read the entries. +/// +public sealed class InMemoryLogSink : ILogSink +{ + /// The capacity used by the parameterless constructor. + public const int DefaultCapacity = 1000; + + private readonly Queue _entries = new(); + private readonly object _lock = new(); + + /// Creates a sink that keeps at most entries. + public InMemoryLogSink() + : this(DefaultCapacity) + { + } + + /// Creates a sink that keeps at most entries. + /// is less than 1. + public InMemoryLogSink(int capacity) + { + if (capacity < 1) + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "The capacity must be at least 1."); + + Capacity = capacity; + } + + /// + /// Raised after an entry was added, on the thread that logged it. Keep handlers short and marshal to the UI + /// thread yourself. An exception from a handler does not reach the code that logged. + /// + public event EventHandler? EntryAdded; + + /// Raised after removed all entries, on the thread that called it. + public event EventHandler? Cleared; + + /// The maximum number of entries kept. + public int Capacity { get; } + + /// The number of entries currently kept. + public int Count + { + get + { + lock (_lock) + return _entries.Count; + } + } + + /// + public void Write(TjiddeLogEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + lock (_lock) + { + _entries.Enqueue(entry); + while (_entries.Count > Capacity) + _entries.Dequeue(); + } + + EntryAdded?.Invoke(this, entry); + } + + /// Returns a copy of the kept entries, oldest first. Later writes do not change the returned list. + public IReadOnlyList GetSnapshot() + { + lock (_lock) + return _entries.ToArray(); + } + + /// Removes all kept entries and raises . + public void Clear() + { + lock (_lock) + _entries.Clear(); + + Cleared?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/Tjidde.Logging/Sinks/LogSinkDispatcher.cs b/src/Tjidde.Logging/Sinks/LogSinkDispatcher.cs new file mode 100644 index 0000000..88e5d3a --- /dev/null +++ b/src/Tjidde.Logging/Sinks/LogSinkDispatcher.cs @@ -0,0 +1,61 @@ +namespace Tjidde.Logging.Sinks; + +/// +/// Passes entries to the registered s, synchronously and in registration order. +/// A failing sink is ignored and does not affect the others. +/// +internal sealed class LogSinkDispatcher +{ + public static readonly LogSinkDispatcher Empty = new(null); + + // Set while this thread is inside a sink, so an entry logged by a sink is not dispatched again (no recursion). + [ThreadStatic] + private static bool _dispatching; + + private readonly ILogSink[] _sinks; + + public LogSinkDispatcher(IEnumerable? sinks) + { + var unique = new List(); + if (sinks is not null) + { + foreach (var sink in sinks) + { + // The same instance can be registered twice (e.g. AddTjiddeSink and AddSingleton); write to it once. + if (sink is not null && !unique.Exists(existing => ReferenceEquals(existing, sink))) + unique.Add(sink); + } + } + + _sinks = unique.ToArray(); + } + + /// Whether entries should be built for the sinks at all: there are sinks and this is not a nested call. + public bool IsActive => _sinks.Length > 0 && !_dispatching; + + public void Dispatch(TjiddeLogEntry entry) + { + if (!IsActive) + return; + + _dispatching = true; + try + { + foreach (var sink in _sinks) + { + try + { + sink.Write(entry); + } + catch (Exception) + { + // A sink must never break logging or keep the other sinks from receiving the entry. + } + } + } + finally + { + _dispatching = false; + } + } +} diff --git a/src/Tjidde.Logging/Sinks/TjiddeLogEntry.cs b/src/Tjidde.Logging/Sinks/TjiddeLogEntry.cs new file mode 100644 index 0000000..15ae173 --- /dev/null +++ b/src/Tjidde.Logging/Sinks/TjiddeLogEntry.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Extensions; +using Tjidde.Logging.Options; + +namespace Tjidde.Logging.Sinks; + +/// +/// One log entry as written by Tjidde.Logging, passed to every . Immutable. +/// +/// +/// Every text in the entry has gone through the same sensitive data masking as the console output +/// (when is on): the entry never contains +/// unmasked values. +/// +public sealed class TjiddeLogEntry +{ + /// When the entry was logged: UTC or local time, depending on . + public DateTimeOffset Timestamp { get; init; } + + /// The log level. Metrics entries have ; see . + public LogLevel Level { get; init; } + + /// The event ID of the entry: for LogMetrics entries. + public EventId EventId { get; init; } + + /// The logger category, usually the full type name. + public string Category { get; init; } = string.Empty; + + /// The last segment of , as shown after Class=>. + public string ClassName { get; init; } = string.Empty; + + /// The method name from a MethodName scope (or the stack trace, when enabled); otherwise . + public string? MethodName { get; init; } + + /// The customer from the customer context, or . + public string? Customer { get; init; } + + /// The rendered, masked message. + public string Message { get; init; } = string.Empty; + + /// The formatted, masked exception (type, message, stack trace, inner exceptions), or . + public string? FormattedException { get; init; } + + /// The complete line as Tjidde.Logging renders it for the console: text or JSON, depending on . + public string RenderedLine { get; init; } = string.Empty; + + /// The format of . + public TjiddeLogOutputFormat OutputFormat { get; init; } + + /// + /// Whether this is a metrics entry (written by LogMetrics: event name "Metrics"), + /// shown as [METRICS] in the console. + /// + public bool IsMetrics => MetricsLoggerExtensions.IsMetricsEntry(Level, EventId); + + /// Returns . + public override string ToString() => RenderedLine; +} diff --git a/tests/Tjidde.Logging.Tests/LogSinkTests.cs b/tests/Tjidde.Logging.Tests/LogSinkTests.cs new file mode 100644 index 0000000..a038d19 --- /dev/null +++ b/tests/Tjidde.Logging.Tests/LogSinkTests.cs @@ -0,0 +1,365 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Context; +using Tjidde.Logging.Extensions; +using Tjidde.Logging.Logging; +using Tjidde.Logging.Masking; +using Tjidde.Logging.Options; +using Tjidde.Logging.Sinks; +using Xunit; + +namespace Tjidde.Logging.Tests; + +/// Extra s next to the console, and the built-in . +[Collection("ConsoleOutput")] +public sealed class LogSinkTests +{ + private static string CaptureConsoleOutput(Action action) + { + var original = Console.Out; + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + action(); + } + finally + { + Console.SetOut(original); + } + return writer.ToString(); + } + + private static string[] Lines(string output) + => output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + + private static TjiddeLoggerProvider CreateProvider(TestOptionsMonitor monitor, params ILogSink[] sinks) + => new(monitor, new FixedCustomer("Acme"), new MaskedKeysStore(), TimeProvider.System, sinks); + + [Fact] + public void ExtraSink_ReceivesEntryWithMaskedValuesOnly() + { + var sink = new InMemoryLogSink(); + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions()); + + var output = CaptureConsoleOutput(() => + { + using var provider = CreateProvider(monitor, sink); + provider.SetScopeProvider(new LoggerExternalScopeProvider()); + var logger = provider.CreateLogger("My.App.LoginService"); + using (logger.BeginScope(new Dictionary { ["MethodName"] = "SignIn" })) + { + logger.LogWarning( + new EventId(42, "Login"), + new InvalidOperationException("failed with password=hunter2"), + "Login for {User} with {Password} and token=abc123", "alice", "s3cret!"); + } + }); + + var entry = sink.GetSnapshot().Should().ContainSingle().Subject; + entry.Level.Should().Be(LogLevel.Warning); + entry.EventId.Should().Be(new EventId(42, "Login")); + entry.Category.Should().Be("My.App.LoginService"); + entry.ClassName.Should().Be("LoginService"); + entry.MethodName.Should().Be("SignIn"); + entry.Customer.Should().Be("Acme"); + entry.OutputFormat.Should().Be(TjiddeLogOutputFormat.Text); + entry.IsMetrics.Should().BeFalse(); + entry.Timestamp.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromMinutes(1)); + + entry.Message.Should().Contain("alice").And.Contain("[REDACTED]"); + entry.FormattedException.Should().Contain("InvalidOperationException").And.Contain("[REDACTED]"); + foreach (var text in new[] { entry.Message, entry.FormattedException!, entry.RenderedLine }) + { + text.Should().NotContain("s3cret!"); + text.Should().NotContain("abc123"); + text.Should().NotContain("hunter2"); + } + + // The sink gets exactly the line the console gets. + Lines(output).Should().ContainSingle().Which.Should().Be(entry.RenderedLine); + } + + [Fact] + public void JsonFormat_RenderedLineIsJsonAndMessageIsMasked() + { + var sink = new InMemoryLogSink(); + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions { OutputFormat = TjiddeLogOutputFormat.Json, WriteToConsole = false }); + + using (var provider = CreateProvider(monitor, sink)) + provider.CreateLogger("Json.Category").LogInformation("Connect with {Password}", "p@ss"); + + var entry = sink.GetSnapshot().Should().ContainSingle().Subject; + entry.OutputFormat.Should().Be(TjiddeLogOutputFormat.Json); + entry.Message.Should().NotContain("p@ss").And.Contain("[REDACTED]"); + entry.RenderedLine.Should().NotContain("p@ss"); + + using var json = JsonDocument.Parse(entry.RenderedLine); + json.RootElement.GetProperty("message").GetString().Should().Be(entry.Message); + } + + [Fact] + public void MetricsEntry_IsRecognizedByEventId() + { + var sink = new InMemoryLogSink(); + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions { WriteToConsole = false }); + + using (var provider = CreateProvider(monitor, sink)) + provider.CreateLogger("Metrics.Category").LogMetrics("requests_total={Count}", 42); + + var entry = sink.GetSnapshot().Should().ContainSingle().Subject; + entry.IsMetrics.Should().BeTrue(); + entry.Level.Should().Be(LogLevel.Information); + entry.EventId.Should().Be(MetricsLoggerExtensions.MetricsEventId); + entry.RenderedLine.Should().Contain("[METRICS]"); + } + + [Fact] + public void WriteToConsoleFalse_WritesOnlyToSinks_AndReloadSwitchesTheConsoleBackOn() + { + var sink = new InMemoryLogSink(); + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions { WriteToConsole = false }); + + var output = CaptureConsoleOutput(() => + { + using var provider = CreateProvider(monitor, sink); + var logger = provider.CreateLogger("Console.Switch"); + + logger.LogInformation("first, sink only"); + + monitor.Set(new TjiddeLoggerOptions { WriteToConsole = true }); + logger.LogInformation("second, console and sink"); + + monitor.Set(new TjiddeLoggerOptions { WriteToConsole = false }); + logger.LogInformation("third, sink only"); + }); + + Lines(output).Should().ContainSingle().Which.Should().EndWith("second, console and sink"); + sink.GetSnapshot().Select(e => e.Message) + .Should().Equal("first, sink only", "second, console and sink", "third, sink only"); + } + + [Fact] + public void WriteToConsole_IsBoundFromConfigurationAndReloads() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["TjiddeLogger:WriteToConsole"] = "false" }) + .Build(); + + InMemoryLogSink? sink = null; + var output = CaptureConsoleOutput(() => + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddTjiddeLogger(configuration).AddTjiddeInMemorySink()); + using var provider = services.BuildServiceProvider(); + sink = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + + logger.LogInformation("hidden from console"); + configuration["TjiddeLogger:WriteToConsole"] = "true"; + configuration.Reload(); + logger.LogInformation("visible on console"); + }); + + Lines(output).Should().ContainSingle().Which.Should().EndWith("visible on console"); + sink!.Count.Should().Be(2); + } + + [Fact] + public void ThrowingSink_DoesNotBreakLogging_AndOtherSinksAndConsoleStillReceiveEntries() + { + var throwing = new ThrowingSink(); + var sink = new InMemoryLogSink(); + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions()); + + var output = CaptureConsoleOutput(() => + { + using var provider = CreateProvider(monitor, throwing, sink); + var logger = provider.CreateLogger("Throwing.Sink"); + + logger.Invoking(l => l.LogError("one")).Should().NotThrow(); + logger.Invoking(l => l.LogError("two")).Should().NotThrow(); + }); + + throwing.Calls.Should().Be(2); + sink.GetSnapshot().Select(e => e.Message).Should().Equal("one", "two"); + Lines(output).Should().HaveCount(2); + } + + [Fact] + public void SinkThatLogs_DoesNotRecurse() + { + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions { WriteToConsole = false }); + var recursive = new RecursiveSink(); + var sink = new InMemoryLogSink(); + + using (var provider = CreateProvider(monitor, recursive, sink)) + { + recursive.Logger = provider.CreateLogger("Recursive.Sink"); + recursive.Logger.LogInformation("outer"); + } + + recursive.Calls.Should().Be(1); + sink.GetSnapshot().Select(e => e.Message).Should().Equal("outer"); + } + + [Fact] + public void DependencyInjection_PassesEntriesToEveryRegisteredSink() + { + var instance = new InMemoryLogSink(10); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddLogging(builder => builder + .AddTjiddeLogger(o => o.WriteToConsole = false) + .AddTjiddeInMemorySink(capacity: 5) + .AddTjiddeInMemorySink() + .AddTjiddeSink() + .AddTjiddeSink() + .AddTjiddeSink(instance)); + + using var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + logger.LogInformation("hello"); + + var inMemory = provider.GetRequiredService(); + inMemory.Capacity.Should().Be(5, "the first registration wins"); + inMemory.Count.Should().Be(1); + instance.Count.Should().Be(1); + provider.GetServices().OfType().Single().Calls.Should().Be(1); + provider.GetRequiredService().Calls.Should().Be(1, "registering the same sink type twice has no effect"); + } + + [Fact] + public void InMemoryLogSink_KeepsOnlyTheNewestEntriesUpToCapacity() + { + var sink = new InMemoryLogSink(3); + + for (var i = 0; i < 10; i++) + sink.Write(new TjiddeLogEntry { Message = $"m{i}" }); + + sink.Capacity.Should().Be(3); + sink.Count.Should().Be(3); + sink.GetSnapshot().Select(e => e.Message).Should().Equal("m7", "m8", "m9"); + } + + [Fact] + public void InMemoryLogSink_RejectsCapacityBelowOne() + { + FluentActions.Invoking(() => new InMemoryLogSink(0)).Should().Throw(); + new InMemoryLogSink().Capacity.Should().Be(InMemoryLogSink.DefaultCapacity); + } + + [Fact] + public void InMemoryLogSink_SnapshotIsACopy_AndEventsAreRaised() + { + var sink = new InMemoryLogSink(); + var added = new List(); + var cleared = 0; + sink.EntryAdded += (_, e) => added.Add(e); + sink.Cleared += (_, _) => cleared++; + + var entry = new TjiddeLogEntry { Message = "a" }; + sink.Write(entry); + var snapshot = sink.GetSnapshot(); + sink.Write(new TjiddeLogEntry { Message = "b" }); + + snapshot.Should().ContainSingle(); + added.Select(e => e.Message).Should().Equal("a", "b"); + added[0].Should().BeSameAs(entry); + + sink.Clear(); + sink.Count.Should().Be(0); + snapshot.Should().ContainSingle(); + cleared.Should().Be(1); + } + + [Fact] + public async Task InMemoryLogSink_IsThreadSafe() + { + const int threads = 8; + const int perThread = 2000; + var bounded = new InMemoryLogSink(500); + var unbounded = new InMemoryLogSink(threads * perThread); + var errors = new ConcurrentQueue(); + using var done = new CancellationTokenSource(); + + var reader = Task.Run(() => + { + while (!done.IsCancellationRequested) + { + try + { + bounded.GetSnapshot().Count.Should().BeLessThanOrEqualTo(500); + _ = unbounded.Count; + } + catch (Exception ex) + { + errors.Enqueue(ex); + } + } + }); + + Parallel.For(0, threads, new ParallelOptions { MaxDegreeOfParallelism = threads }, t => + { + for (var i = 0; i < perThread; i++) + { + var entry = new TjiddeLogEntry { Message = $"{t}:{i}" }; + bounded.Write(entry); + unbounded.Write(entry); + } + }); + + done.Cancel(); + await reader.WaitAsync(TimeSpan.FromSeconds(10)); + + errors.Should().BeEmpty(); + bounded.Count.Should().Be(500); + unbounded.GetSnapshot().Select(e => e.Message).Should().OnlyHaveUniqueItems().And.HaveCount(threads * perThread); + } + + private sealed class FixedCustomer(string customer) : ICustomerContextAccessor + { + public string? GetCustomerContext() => customer; + } + + private sealed class ThrowingSink : ILogSink + { + public int Calls; + + public void Write(TjiddeLogEntry entry) + { + Interlocked.Increment(ref Calls); + throw new InvalidOperationException("sink failure"); + } + } + + private sealed class RecursiveSink : ILogSink + { + public int Calls; + public ILogger? Logger; + + public void Write(TjiddeLogEntry entry) + { + Interlocked.Increment(ref Calls); + Logger?.LogInformation("logged from inside a sink"); + } + } + + private sealed class CountingSink : ILogSink + { + public int Calls; + + public void Write(TjiddeLogEntry entry) => Interlocked.Increment(ref Calls); + } + + private sealed class CountingSink2 : ILogSink + { + public int Calls; + + public void Write(TjiddeLogEntry entry) => Interlocked.Increment(ref Calls); + } +} From 325a7113e2163b05c716ecb6311b4b3b6f21e99a Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 14:51:14 +0200 Subject: [PATCH 15/28] Use the built-in InMemoryLogSink in the samples The Blazor, Avalonia and terminal samples drop their own in-memory logger providers and read the built-in InMemoryLogSink. Metrics entries are logged with LogMetrics and recognized by their event id instead of the obsolete Metrics level. The terminal sample turns the console off so console output no longer draws over its UI. Co-Authored-By: Claude Opus 5.5 --- .../Components/Pages/Home.razor | 66 ++++++++++++------- samples/Tjidde.Logging.DemoApp/Program.cs | 21 +++--- samples/Tjidde.Logging.DesktopApp/App.axaml | 1 + .../Converters/LogLevelColorConverter.cs | 24 ++++++- samples/Tjidde.Logging.DesktopApp/Program.cs | 19 ++---- .../ViewModels/MainViewModel.cs | 37 ++++++----- .../Views/MainWindow.axaml | 14 ++-- .../Models/LogEntryLine.cs | 15 +++-- samples/Tjidde.Logging.TuiApp/Program.cs | 11 ++-- .../Services/ILogService.cs | 3 +- .../Services/LogService.cs | 15 +++-- .../Tjidde.Logging.TuiApp/Views/MainView.cs | 8 ++- 12 files changed, 139 insertions(+), 95 deletions(-) diff --git a/samples/Tjidde.Logging.DemoApp/Components/Pages/Home.razor b/samples/Tjidde.Logging.DemoApp/Components/Pages/Home.razor index 2e95bbe..a33f157 100644 --- a/samples/Tjidde.Logging.DemoApp/Components/Pages/Home.razor +++ b/samples/Tjidde.Logging.DemoApp/Components/Pages/Home.razor @@ -1,7 +1,7 @@ @page "/" @rendermode RenderMode.InteractiveServer @using Tjidde.Logging.Context -@using Tjidde.Logging.DemoApp.Logging +@using Tjidde.Logging.Sinks @using Tjidde.Logging.Extensions @using Microsoft.Extensions.Logging @inject ILogger Logger @@ -23,7 +23,7 @@ @@ -74,18 +74,18 @@
@foreach (var entry in Enumerable.Reverse(_entries)) { -
+
- @LevelLabel(entry.Level) + @LevelLabel(entry) @entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") @ShortCategory(entry.Category)
-
@entry.FormattedMessage
- @if (entry.ExceptionDetails is not null) +
@entry.Message
+ @if (entry.FormattedException is not null) {
Exception details -
@entry.ExceptionDetails
+
@entry.FormattedException
}
@@ -97,15 +97,18 @@
@code { - private static readonly (string Label, LogLevel Level)[] _levels = + // "Metrics" is not a log level: it is logged with LogMetrics (Information + MetricsEventId). + private const int MetricsOption = -1; + + private static readonly (string Label, int Level)[] _levels = [ - ("Trace", LogLevel.Trace), - ("Debug", LogLevel.Debug), - ("Information", LogLevel.Information), - ("Warning", LogLevel.Warning), - ("Error", LogLevel.Error), - ("Critical", LogLevel.Critical), - ("Metrics", MetricsLoggerExtensions.Metrics) + ("Trace", (int)LogLevel.Trace), + ("Debug", (int)LogLevel.Debug), + ("Information", (int)LogLevel.Information), + ("Warning", (int)LogLevel.Warning), + ("Error", (int)LogLevel.Error), + ("Critical", (int)LogLevel.Critical), + ("Metrics", MetricsOption) ]; private string _customer = string.Empty; @@ -114,17 +117,22 @@ private bool _includeException; private bool _includeInner; private string _exceptionMessage = "Something went wrong"; - private IReadOnlyList _entries = []; + private IReadOnlyList _entries = []; protected override void OnInitialized() { - Sink.OnChanged += RefreshEntries; - _entries = Sink.GetEntries(); + Sink.EntryAdded += OnEntryAdded; + Sink.Cleared += OnCleared; + _entries = Sink.GetSnapshot(); } + private void OnEntryAdded(object? sender, TjiddeLogEntry entry) => RefreshEntries(); + + private void OnCleared(object? sender, EventArgs e) => RefreshEntries(); + private void RefreshEntries() { - _entries = Sink.GetEntries(); + _entries = Sink.GetSnapshot(); InvokeAsync(StateHasChanged); } @@ -145,7 +153,10 @@ inner); } - Logger.Log((LogLevel)_selectedLevel, ex, "{Message}", _message.Trim()); + if (_selectedLevel == MetricsOption) + Logger.LogMetrics(ex, "{Message}", _message.Trim()); + else + Logger.Log((LogLevel)_selectedLevel, ex, "{Message}", _message.Trim()); CustomerContext.Clear(); } @@ -158,13 +169,18 @@ return dot >= 0 ? category[(dot + 1)..] : category; } - private static string LevelCssClass(LogLevel level) => (int)level == 10 + // Metrics entries are recognized by their event ID (MetricsEventId), not by a log level. + private static string LevelCssClass(TjiddeLogEntry entry) => entry.IsMetrics ? "metrics" - : level.ToString().ToLower(); + : entry.Level.ToString().ToLower(); - private static string LevelLabel(LogLevel level) => (int)level == 10 + private static string LevelLabel(TjiddeLogEntry entry) => entry.IsMetrics ? "Metrics" - : level.ToString(); + : entry.Level.ToString(); - public void Dispose() => Sink.OnChanged -= RefreshEntries; + public void Dispose() + { + Sink.EntryAdded -= OnEntryAdded; + Sink.Cleared -= OnCleared; + } } diff --git a/samples/Tjidde.Logging.DemoApp/Program.cs b/samples/Tjidde.Logging.DemoApp/Program.cs index 594de6d..90ee5c1 100644 --- a/samples/Tjidde.Logging.DemoApp/Program.cs +++ b/samples/Tjidde.Logging.DemoApp/Program.cs @@ -1,21 +1,16 @@ -using Tjidde.Logging.DemoApp.Logging; using Tjidde.Logging.Extensions; -using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); -// Register the in-memory sink as a singleton so the UI can read from it -builder.Services.AddSingleton(); - -// Add Tjidde logger (writes formatted output to the console) -// Add our in-memory provider so the Blazor UI can also display entries +// Tjidde logger: writes formatted output to the console, and to the built-in in-memory sink +// (the last 200 entries) so the Blazor UI can display them. Inject InMemoryLogSink to read them. builder.Logging.ClearProviders(); -builder.Logging.AddTjiddeLogger(options => -{ - options.IncludeScopes = true; -}); -builder.Services.AddSingleton(sp => - new InMemoryLoggerProvider(sp.GetRequiredService())); +builder.Logging + .AddTjiddeLogger(options => + { + options.IncludeScopes = true; + }) + .AddTjiddeInMemorySink(capacity: 200); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); diff --git a/samples/Tjidde.Logging.DesktopApp/App.axaml b/samples/Tjidde.Logging.DesktopApp/App.axaml index 1376484..473daea 100644 --- a/samples/Tjidde.Logging.DesktopApp/App.axaml +++ b/samples/Tjidde.Logging.DesktopApp/App.axaml @@ -10,6 +10,7 @@ + diff --git a/samples/Tjidde.Logging.DesktopApp/Converters/LogLevelColorConverter.cs b/samples/Tjidde.Logging.DesktopApp/Converters/LogLevelColorConverter.cs index 5271aa3..6c9627f 100644 --- a/samples/Tjidde.Logging.DesktopApp/Converters/LogLevelColorConverter.cs +++ b/samples/Tjidde.Logging.DesktopApp/Converters/LogLevelColorConverter.cs @@ -1,15 +1,22 @@ using Avalonia.Data.Converters; using Avalonia.Media; -using Tjidde.Logging.Extensions; +using Tjidde.Logging.Sinks; using Microsoft.Extensions.Logging; using System.Globalization; namespace Tjidde.Logging.DesktopApp.Converters; +/// Badge color for a log entry; metrics entries are recognized by their event ID (). public sealed class LogLevelColorConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { + if (value is TjiddeLogEntry { IsMetrics: true }) + return new SolidColorBrush(Color.Parse("#e879f9")); + + if (value is TjiddeLogEntry entry) + value = entry.Level; + if (value is LogLevel level) { return level switch @@ -20,7 +27,6 @@ public object Convert(object? value, Type targetType, object? parameter, Culture LogLevel.Warning => new SolidColorBrush(Color.Parse("#FF9800")), LogLevel.Error => new SolidColorBrush(Color.Parse("#F44336")), LogLevel.Critical => new SolidColorBrush(Color.Parse("#9C27B0")), - _ when level == MetricsLoggerExtensions.Metrics => new SolidColorBrush(Color.Parse("#e879f9")), _ => new SolidColorBrush(Color.Parse("#555555")) }; } @@ -30,3 +36,17 @@ public object Convert(object? value, Type targetType, object? parameter, Culture public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); } + +/// Badge text for a log entry: the level, or "Metrics" for metrics entries. +public sealed class LogLevelLabelConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch + { + TjiddeLogEntry { IsMetrics: true } => "Metrics", + TjiddeLogEntry entry => entry.Level.ToString(), + _ => value?.ToString() + }; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/samples/Tjidde.Logging.DesktopApp/Program.cs b/samples/Tjidde.Logging.DesktopApp/Program.cs index 1e70b18..7892dac 100644 --- a/samples/Tjidde.Logging.DesktopApp/Program.cs +++ b/samples/Tjidde.Logging.DesktopApp/Program.cs @@ -1,5 +1,4 @@ using Avalonia; -using Tjidde.Logging.DesktopApp.Logging; using Tjidde.Logging.DesktopApp.ViewModels; using Tjidde.Logging.Extensions; using Microsoft.Extensions.DependencyInjection; @@ -16,20 +15,16 @@ public static void Main(string[] args) { var services = new ServiceCollection(); - // Shared in-memory sink - var sink = new InMemoryLogSink(); - services.AddSingleton(sink); - - // Logging: Tjidde logger + in-memory capture + // Logging: Tjidde logger (console) + the built-in in-memory sink, which the UI reads services.AddLogging(builder => { builder.SetMinimumLevel(LogLevel.Trace); - builder.AddTjiddeLogger(options => - { - options.IncludeScopes = true; - }); - builder.Services.AddSingleton(sp => - new InMemoryLoggerProvider(sp.GetRequiredService())); + builder + .AddTjiddeLogger(options => + { + options.IncludeScopes = true; + }) + .AddTjiddeInMemorySink(); }); services.AddSingleton(); diff --git a/samples/Tjidde.Logging.DesktopApp/ViewModels/MainViewModel.cs b/samples/Tjidde.Logging.DesktopApp/ViewModels/MainViewModel.cs index d080bca..90d610c 100644 --- a/samples/Tjidde.Logging.DesktopApp/ViewModels/MainViewModel.cs +++ b/samples/Tjidde.Logging.DesktopApp/ViewModels/MainViewModel.cs @@ -3,8 +3,8 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using Tjidde.Logging.Context; -using Tjidde.Logging.DesktopApp.Logging; using Tjidde.Logging.Extensions; +using Tjidde.Logging.Sinks; using Microsoft.Extensions.Logging; namespace Tjidde.Logging.DesktopApp.ViewModels; @@ -16,7 +16,9 @@ public sealed class MainViewModel : INotifyPropertyChanged private string _customer = string.Empty; private string _message = string.Empty; - private LogLevel _selectedLevel = LogLevel.Information; + private const string MetricsLevel = "Metrics"; + + private string _selectedLevel = nameof(LogLevel.Information); private bool _includeException; private bool _includeInner; private string _exceptionMessage = "Something went wrong"; @@ -25,11 +27,12 @@ public MainViewModel(ILogger logger, InMemoryLogSink sink) { _logger = logger; _sink = sink; - _sink.OnChanged += OnSinkChanged; + _sink.EntryAdded += (_, _) => OnSinkChanged(); + _sink.Cleared += (_, _) => OnSinkChanged(); RefreshEntries(); } - public ObservableCollection Entries { get; } = new(); + public ObservableCollection Entries { get; } = new(); public string Customer { @@ -43,7 +46,7 @@ public string Message set { _message = value; OnPropertyChanged(); } } - public LogLevel SelectedLevel + public string SelectedLevel { get => _selectedLevel; set { _selectedLevel = value; OnPropertyChanged(); } @@ -67,15 +70,16 @@ public string ExceptionMessage set { _exceptionMessage = value; OnPropertyChanged(); } } - public LogLevel[] LogLevels { get; } = + // "Metrics" is not a log level: it is logged with LogMetrics (Information + MetricsEventId). + public string[] LogLevels { get; } = [ - LogLevel.Trace, - LogLevel.Debug, - LogLevel.Information, - LogLevel.Warning, - LogLevel.Error, - LogLevel.Critical, - MetricsLoggerExtensions.Metrics + nameof(LogLevel.Trace), + nameof(LogLevel.Debug), + nameof(LogLevel.Information), + nameof(LogLevel.Warning), + nameof(LogLevel.Error), + nameof(LogLevel.Critical), + MetricsLevel ]; public void SendLog() @@ -94,7 +98,10 @@ public void SendLog() string.IsNullOrWhiteSpace(_exceptionMessage) ? "Sample exception" : _exceptionMessage, inner); } - _logger.Log(_selectedLevel, ex, "{Message}", _message.Trim()); + if (_selectedLevel == MetricsLevel) + _logger.LogMetrics(ex, "{Message}", _message.Trim()); + else + _logger.Log(Enum.Parse(_selectedLevel), ex, "{Message}", _message.Trim()); CustomerContext.Clear(); } @@ -110,7 +117,7 @@ private void OnSinkChanged() private void RefreshEntries() { - var entries = _sink.GetEntries(); + var entries = _sink.GetSnapshot(); Entries.Clear(); foreach (var e in entries.Reverse()) Entries.Add(e); diff --git a/samples/Tjidde.Logging.DesktopApp/Views/MainWindow.axaml b/samples/Tjidde.Logging.DesktopApp/Views/MainWindow.axaml index 72f9adc..0c6545a 100644 --- a/samples/Tjidde.Logging.DesktopApp/Views/MainWindow.axaml +++ b/samples/Tjidde.Logging.DesktopApp/Views/MainWindow.axaml @@ -1,7 +1,7 @@ - + - + @@ -118,13 +118,13 @@ VerticalAlignment="Center"/> - - - Entry = entry; + public LogEntryLine(TjiddeLogEntry entry) => Entry = entry; public string GetStyledString() { + // Metrics entries are recognized by their event ID (MetricsEventId), not by a log level. var color = Entry.Level switch { + _ when Entry.IsMetrics => "cyan", LogLevel.Trace => "magenta", LogLevel.Debug => "gray", LogLevel.Information => "white", LogLevel.Warning => "yellow", LogLevel.Error => "red", LogLevel.Critical => "brightred", - _ when (int)Entry.Level == 700 => "cyan", _ => "white" }; var levelStr = FormatLevel(); - return $"[{Entry.Timestamp:HH:mm:ss}] [{{color:{color}}}] {levelStr}: {Entry.FormattedMessage}"; + return $"[{Entry.Timestamp:HH:mm:ss}] [{{color:{color}}}] {levelStr}: {Entry.Message}"; } public override string ToString() { var levelStr = FormatLevel(); - return $"[{Entry.Timestamp:HH:mm:ss}] {levelStr}: {Entry.FormattedMessage}"; + return $"[{Entry.Timestamp:HH:mm:ss}] {levelStr}: {Entry.Message}"; } private string FormatLevel() => - Entry.Level == (LogLevel)700 ? "METRICS" : Entry.Level.ToString().ToUpper(); + Entry.IsMetrics ? "METRICS" : Entry.Level.ToString().ToUpper(); } diff --git a/samples/Tjidde.Logging.TuiApp/Program.cs b/samples/Tjidde.Logging.TuiApp/Program.cs index c45cb11..cb3e658 100644 --- a/samples/Tjidde.Logging.TuiApp/Program.cs +++ b/samples/Tjidde.Logging.TuiApp/Program.cs @@ -1,5 +1,4 @@ using Tjidde.Logging.Extensions; -using Tjidde.Logging.TuiApp.Logging; using Tjidde.Logging.TuiApp.Services; using Tjidde.Logging.TuiApp.Views; using Microsoft.Extensions.DependencyInjection; @@ -26,19 +25,19 @@ static void Main(string[] args) private static ServiceProvider ConfigureServices() { var colorScheme = CreateColorScheme(); - var sink = new InMemoryLogSink(); - var services = new ServiceCollection(); - services.AddSingleton(sink); services.AddSingleton(colorScheme); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(new InMemoryLoggerProvider(sink)); + // Entries go to the built-in in-memory sink, which the log pane reads. The console sink is off: + // console output would draw over the terminal UI. services.AddLogging(builder => { - builder.AddTjiddeLogger(); + builder + .AddTjiddeLogger(options => options.WriteToConsole = false) + .AddTjiddeInMemorySink(); }); return services.BuildServiceProvider(); diff --git a/samples/Tjidde.Logging.TuiApp/Services/ILogService.cs b/samples/Tjidde.Logging.TuiApp/Services/ILogService.cs index 4bf7353..c7a2d2b 100644 --- a/samples/Tjidde.Logging.TuiApp/Services/ILogService.cs +++ b/samples/Tjidde.Logging.TuiApp/Services/ILogService.cs @@ -4,7 +4,8 @@ namespace Tjidde.Logging.TuiApp.Services; public interface ILogService { - void SendLog(string message, LogLevel level, string? customer, Exception? exception); + /// Logs at , or as a metrics entry (LogMetrics) when is set. + void SendLog(string message, LogLevel level, string? customer, Exception? exception, bool metrics = false); IReadOnlyList GetFormattedEntries(); void ClearLogs(); event Action? LogsChanged; diff --git a/samples/Tjidde.Logging.TuiApp/Services/LogService.cs b/samples/Tjidde.Logging.TuiApp/Services/LogService.cs index c1175ac..7d56186 100644 --- a/samples/Tjidde.Logging.TuiApp/Services/LogService.cs +++ b/samples/Tjidde.Logging.TuiApp/Services/LogService.cs @@ -1,5 +1,6 @@ using Tjidde.Logging.Context; -using Tjidde.Logging.TuiApp.Logging; +using Tjidde.Logging.Extensions; +using Tjidde.Logging.Sinks; using Tjidde.Logging.TuiApp.Models; using Microsoft.Extensions.Logging; @@ -16,15 +17,19 @@ public LogService(ILogger logger, InMemoryLogSink sink) { _logger = logger; _sink = sink; - _sink.OnChanged += () => LogsChanged?.Invoke(); + _sink.EntryAdded += (_, _) => LogsChanged?.Invoke(); + _sink.Cleared += (_, _) => LogsChanged?.Invoke(); } - public void SendLog(string message, LogLevel level, string? customer, Exception? exception) + public void SendLog(string message, LogLevel level, string? customer, Exception? exception, bool metrics = false) { CustomerContext.Set(customer); try { - _logger.Log(level, exception, "{Message}", message); + if (metrics) + _logger.LogMetrics(exception, "{Message}", message); + else + _logger.Log(level, exception, "{Message}", message); } finally { @@ -33,7 +38,7 @@ public void SendLog(string message, LogLevel level, string? customer, Exception? } public IReadOnlyList GetFormattedEntries() => - _sink.GetEntries() + _sink.GetSnapshot() .Reverse() .Select(e => new LogEntryLine(e).GetStyledString()) .ToList(); diff --git a/samples/Tjidde.Logging.TuiApp/Views/MainView.cs b/samples/Tjidde.Logging.TuiApp/Views/MainView.cs index 30aa601..8780837 100644 --- a/samples/Tjidde.Logging.TuiApp/Views/MainView.cs +++ b/samples/Tjidde.Logging.TuiApp/Views/MainView.cs @@ -102,13 +102,15 @@ private FrameView CreateLeftPane() var customer = txtCustomer.Text.ToString(); CustomerContext.Set(customer); var level = ParseLogLevel(comboLevel.SelectedItem); + var metrics = comboLevel.SelectedItem == MetricsIndex; var ex = BuildException(chkException.Checked, txtExMsg.Text.ToString(), chkInner.Checked); _logService.SendLog( msg.Trim(), level, string.IsNullOrWhiteSpace(customer) ? null : customer.Trim(), - ex); + ex, + metrics); RefreshLogs(); }; @@ -178,6 +180,9 @@ private static void RegisterHelpKey(Toplevel top) }; } + // "Metrics" is not a log level: it is logged with LogMetrics (Information + MetricsEventId). + private const int MetricsIndex = 6; + private static LogLevel ParseLogLevel(int selectedIndex) => selectedIndex switch { 0 => LogLevel.Trace, @@ -186,7 +191,6 @@ private static void RegisterHelpKey(Toplevel top) 3 => LogLevel.Warning, 4 => LogLevel.Error, 5 => LogLevel.Critical, - 6 => (LogLevel)700, _ => LogLevel.Information }; From 8d19311ae2daaae9409f90fa148919903c02f7cf Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 15:16:38 +0200 Subject: [PATCH 16/28] Add BenchmarkDotNet benchmarks for the logging path benchmarks/Tjidde.Logging.Benchmarks (net10.0, not packable, in the solution) measures one log call through TjiddeLogger with the console off: a simple message, structured properties, a sensitive property, active scopes, a no-op sink and the IsEnabled == false path, each for Text and Json output. benchmarks/README.md explains how to run them and records the baseline. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + TjiddeLogger.sln | 17 +++ benchmarks/README.md | 57 +++++++++ .../BenchmarkInfrastructure.cs | 67 +++++++++++ .../LoggerBenchmarks.cs | 108 ++++++++++++++++++ .../Tjidde.Logging.Benchmarks/Program.cs | 8 ++ .../Tjidde.Logging.Benchmarks.csproj | 22 ++++ 7 files changed, 282 insertions(+) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/Tjidde.Logging.Benchmarks/BenchmarkInfrastructure.cs create mode 100644 benchmarks/Tjidde.Logging.Benchmarks/LoggerBenchmarks.cs create mode 100644 benchmarks/Tjidde.Logging.Benchmarks/Program.cs create mode 100644 benchmarks/Tjidde.Logging.Benchmarks/Tjidde.Logging.Benchmarks.csproj diff --git a/.gitignore b/.gitignore index eaee47b..8963d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ desktop.ini # Logs *.log + +# BenchmarkDotNet results +BenchmarkDotNet.Artifacts/ diff --git a/TjiddeLogger.sln b/TjiddeLogger.sln index 079c8f6..bc0daf2 100644 --- a/TjiddeLogger.sln +++ b/TjiddeLogger.sln @@ -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 @@ -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 @@ -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 diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..ab1f2b8 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,57 @@ +# Tjidde.Logging benchmarks + +[BenchmarkDotNet](https://benchmarkdotnet.org/) benchmarks for the logging path of `TjiddeLogger`: one call from +`ILogger.Log` to the rendered line. They measure the logger, not the console: every logger runs with +`WriteToConsole = false`, so nothing is queued for the console writer. Sensitive data masking is on (the default) +and the customer context is a fixed value, so every line has a customer part. + +| Benchmark | What it logs | +|------------------------|-----------------------------------------------------------------------------------------------| +| `Simple` | A constant message without properties (`LoggerMessage.Define`). | +| `Structured` | A template with three non-sensitive properties (`{UserId}`, `{ItemCount}`, `{Destination}`). | +| `SensitiveProperty` | A template with a sensitive property (`{Password}`): masked, message rebuilt from the template. | +| `StructuredWithScopes` | `Structured` with two active scopes: a text scope and a `MethodName` scope with two properties. | +| `StructuredWithSink` | `Structured` with one no-op `ILogSink`, so the `TjiddeLogEntry` is built as well. | +| `Disabled` | A `Debug` entry on a logger with minimum level `Information`: the `IsEnabled == false` path. | + +Every benchmark runs for both output formats (`Format = Text` and `Format = Json`). The other benchmarks have +no sinks and a framework `LoggerExternalScopeProvider` without active scopes, as in a normal host. + +## Running + +``` +dotnet run -c Release --project benchmarks/Tjidde.Logging.Benchmarks -- --filter '*' +``` + +Useful options: + +- `--filter '*Structured*'`: only the matching benchmarks. +- `--job short`: fewer iterations, for a quick check (less accurate). +- `--artifacts `: where the reports go (default `BenchmarkDotNet.Artifacts`, ignored by git). + +The benchmark project targets `net10.0` and needs the .NET 10 SDK. + +## Baseline + +Commit `325a711`, before any optimization. Default job, .NET 10.0.12, Intel Core i7-8565U (laptop, 4 cores), +Windows 11. The microsecond results on this machine are noisy (BenchmarkDotNet reports multimodal distributions), +so compare the means as rough figures; the allocations are exact. + +| Method | Format | Mean | Allocated | +|----------------------|--------|----------:|----------:| +| Simple | Text | 4,066 ns | 1224 B | +| Structured | Text | 10,722 ns | 1568 B | +| SensitiveProperty | Text | 15,507 ns | 2376 B | +| StructuredWithScopes | Text | 23,864 ns | 2736 B | +| StructuredWithSink | Text | 15,735 ns | 1680 B | +| Disabled | Text | 7.8 ns | - | +| Simple | Json | 12,218 ns | 2145 B | +| Structured | Json | 26,271 ns | 3914 B | +| SensitiveProperty | Json | 26,299 ns | 4042 B | +| StructuredWithScopes | Json | 39,634 ns | 4436 B | +| StructuredWithSink | Json | 21,645 ns | 4026 B | +| Disabled | Json | 10.5 ns | - | + +Most of the time goes to sensitive data masking: `SensitiveDataMasker.MaskMessage` runs two regular expressions +per sensitive key (17 built-in keys, so 34 regex scans) over the message and over every string property and scope +value, even when the text contains none of the keys. diff --git a/benchmarks/Tjidde.Logging.Benchmarks/BenchmarkInfrastructure.cs b/benchmarks/Tjidde.Logging.Benchmarks/BenchmarkInfrastructure.cs new file mode 100644 index 0000000..6977a64 --- /dev/null +++ b/benchmarks/Tjidde.Logging.Benchmarks/BenchmarkInfrastructure.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tjidde.Logging.Context; +using Tjidde.Logging.Sinks; + +namespace Tjidde.Logging.Benchmarks; + +/// An options monitor with fixed options and no change notifications. +internal sealed class StaticOptionsMonitor : IOptionsMonitor +{ + public StaticOptionsMonitor(T value) => CurrentValue = value; + + public T CurrentValue { get; } + + public T Get(string? name) => CurrentValue; + + public IDisposable? OnChange(Action listener) => null; +} + +/// A fixed customer, so every entry carries a customer part. +internal sealed class FixedCustomerContextAccessor : ICustomerContextAccessor +{ + private readonly string? _customer; + + public FixedCustomerContextAccessor(string? customer) => _customer = customer; + + public string? GetCustomerContext() => _customer; +} + +/// +/// A scope provider that always reports the same scopes: a text scope and a structured scope with a +/// MethodName, as BeginMethodScope creates. Deterministic, unlike an AsyncLocal-based provider +/// whose scopes would have to be pushed inside the measured call. +/// +internal sealed class FixedScopeProvider : IExternalScopeProvider +{ + private readonly object[] _scopes = + [ + "Order pipeline", + new KeyValuePair[] + { + new("MethodName", "ProcessOrder"), + new("OrderId", 12345), + new("Region", "eu-west") + } + ]; + + public void ForEachScope(Action callback, TState state) + { + foreach (var scope in _scopes) + callback(scope, state); + } + + public IDisposable Push(object? state) => NullDisposable.Instance; + + private sealed class NullDisposable : IDisposable + { + public static readonly NullDisposable Instance = new(); + public void Dispose() { } + } +} + +/// A sink that discards every entry, to measure building the entry without any I/O. +internal sealed class NoOpSink : ILogSink +{ + public void Write(TjiddeLogEntry entry) { } +} diff --git a/benchmarks/Tjidde.Logging.Benchmarks/LoggerBenchmarks.cs b/benchmarks/Tjidde.Logging.Benchmarks/LoggerBenchmarks.cs new file mode 100644 index 0000000..0bc42d8 --- /dev/null +++ b/benchmarks/Tjidde.Logging.Benchmarks/LoggerBenchmarks.cs @@ -0,0 +1,108 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging; +using Tjidde.Logging.Logging; +using Tjidde.Logging.Masking; +using Tjidde.Logging.Options; +using Tjidde.Logging.Sinks; + +namespace Tjidde.Logging.Benchmarks; + +/// +/// Measures one log call through , from ILogger.Log to the rendered line. +/// The console is off ( = false) so console I/O is not measured; +/// sensitive data masking is on (the default). Every scenario runs for both output formats. +/// +[MemoryDiagnoser] +public class LoggerBenchmarks +{ + private static readonly Action SimpleMessage = + LoggerMessage.Define(LogLevel.Information, new EventId(1, "Simple"), "Order processed successfully"); + + private static readonly Action StructuredMessage = + LoggerMessage.Define(LogLevel.Information, new EventId(2, "Structured"), + "User {UserId} ordered {ItemCount} items for {Destination}"); + + private static readonly Action SensitiveMessage = + LoggerMessage.Define(LogLevel.Information, new EventId(3, "Sensitive"), + "Login for {UserName} with {Password}"); + + private static readonly Action DebugMessage = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "Debug"), "Cache lookup for {Key}"); + + private readonly List _providers = []; + + private ILogger _logger = null!; + private ILogger _scopedLogger = null!; + private ILogger _sinkLogger = null!; + private ILogger _infoLevelLogger = null!; + + [Params(TjiddeLogOutputFormat.Text, TjiddeLogOutputFormat.Json)] + public TjiddeLogOutputFormat Format { get; set; } + + [GlobalSetup] + public void Setup() + { + // Logger without scopes: IncludeScopes is on (the default), but no scope is active. + _logger = CreateLogger(new TjiddeLoggerOptions()); + + // Logger with a text scope and a structured MethodName scope active. + _scopedLogger = CreateLogger(new TjiddeLoggerOptions(), scopeProvider: new FixedScopeProvider()); + + // Logger with one sink that discards the entries, so the TjiddeLogEntry is built. + _sinkLogger = CreateLogger(new TjiddeLoggerOptions(), sinks: [new NoOpSink()]); + + // Logger with minimum level Information, for the IsEnabled == false path. +#pragma warning disable CS0618 // MinimumLevel is obsolete but still honored; it is the cheapest way to filter here + _infoLevelLogger = CreateLogger(new TjiddeLoggerOptions { MinimumLevel = LogLevel.Information }); +#pragma warning restore CS0618 + } + + [GlobalCleanup] + public void Cleanup() + { + foreach (var provider in _providers) + provider.Dispose(); + } + + private ILogger CreateLogger(TjiddeLoggerOptions options, IExternalScopeProvider? scopeProvider = null, ILogSink[]? sinks = null) + { + options.OutputFormat = Format; + options.WriteToConsole = false; + + var provider = new TjiddeLoggerProvider( + new StaticOptionsMonitor(options), + new FixedCustomerContextAccessor("ACME"), + new MaskedKeysStore(), + TimeProvider.System, + sinks); + + // Without fixed scopes: the framework's own scope provider with no scope pushed, as in a host. + provider.SetScopeProvider(scopeProvider ?? new LoggerExternalScopeProvider()); + _providers.Add(provider); + return provider.CreateLogger("Tjidde.Logging.Benchmarks.OrderService"); + } + + /// A constant message without properties. + [Benchmark(Baseline = true)] + public void Simple() => SimpleMessage(_logger, null); + + /// A message template with three non-sensitive properties. + [Benchmark] + public void Structured() => StructuredMessage(_logger, 42, 3, "Amsterdam", null); + + /// A message template with a sensitive property: masked and the message rebuilt from the template. + [Benchmark] + public void SensitiveProperty() => SensitiveMessage(_logger, "alice", "hunter2!", null); + + /// The structured message with two active scopes (text and MethodName/properties). + [Benchmark] + public void StructuredWithScopes() => StructuredMessage(_scopedLogger, 42, 3, "Amsterdam", null); + + /// The structured message with one (no-op) sink registered, so the sink entry is built too. + [Benchmark] + public void StructuredWithSink() => StructuredMessage(_sinkLogger, 42, 3, "Amsterdam", null); + + /// A debug entry on a logger whose minimum level is Information: IsEnabled is false. + [Benchmark] + public void Disabled() => DebugMessage(_infoLevelLogger, 7, null); +} diff --git a/benchmarks/Tjidde.Logging.Benchmarks/Program.cs b/benchmarks/Tjidde.Logging.Benchmarks/Program.cs new file mode 100644 index 0000000..ed0cf89 --- /dev/null +++ b/benchmarks/Tjidde.Logging.Benchmarks/Program.cs @@ -0,0 +1,8 @@ +using BenchmarkDotNet.Running; + +namespace Tjidde.Logging.Benchmarks; + +public static class Program +{ + public static void Main(string[] args) => BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); +} diff --git a/benchmarks/Tjidde.Logging.Benchmarks/Tjidde.Logging.Benchmarks.csproj b/benchmarks/Tjidde.Logging.Benchmarks/Tjidde.Logging.Benchmarks.csproj new file mode 100644 index 0000000..be17974 --- /dev/null +++ b/benchmarks/Tjidde.Logging.Benchmarks/Tjidde.Logging.Benchmarks.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + latest + enable + false + true + + + + + + + + + + + + From 83d380e2e9c90d0124e434caa8453be8e1e53e66 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 15:22:02 +0200 Subject: [PATCH 17/28] Skip masking expressions for keys that do not occur in the text MaskMessage ran two regular expressions per sensitive key over every message, property and scope value. Every match contains its key, so a key that does not occur in the text (OrdinalIgnoreCase) is skipped; on .NET 9+ one SearchValues scan rules out all keys at once. The check is only used for ASCII text and ASCII keys, where it agrees with the expressions' IgnoreCase matching; anything else still runs them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + .../Masking/SensitiveDataMasker.cs | 85 +++++++++++++++++-- .../SensitiveDataMaskerTests.cs | 32 +++++++ 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d71f353..9d5fc42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ 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` 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. + ### 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. diff --git a/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs b/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs index a9febd7..41410fe 100644 --- a/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs +++ b/src/Tjidde.Logging/Masking/SensitiveDataMasker.cs @@ -1,3 +1,6 @@ +#if NET9_0_OR_GREATER +using System.Buffers; +#endif using System.Text.RegularExpressions; namespace Tjidde.Logging.Masking; @@ -171,9 +174,16 @@ private sealed class KeySet private const RegexOptions PatternOptions = RegexOptions.IgnoreCase; private readonly HashSet _normalizedKeys = new(StringComparer.OrdinalIgnoreCase); - private readonly List<(Regex Pattern, MatchEvaluator Mask)> _patterns = []; + private readonly List _patterns = []; +#if NET9_0_OR_GREATER + // All ASCII keys at once: one vectorized scan tells whether a message can match any of their patterns. + private readonly SearchValues? _asciiKeySearch; +#endif + private readonly bool _hasNonAsciiKey; private readonly string[] _literals; private readonly string _placeholder; + private readonly MatchEvaluator _maskSeparatedValue; + private readonly MatchEvaluator _maskSpacedValue; private readonly object? _sourceReference; private readonly bool _hasSource; private readonly HashSet _sourceKeys; @@ -187,6 +197,8 @@ public KeySet( object? sourceReference = null) { _placeholder = placeholder; + _maskSeparatedValue = MaskSeparatedValue; + _maskSpacedValue = MaskSpacedValue; _literals = literals.Where(literal => !string.IsNullOrWhiteSpace(literal)).ToArray(); _sourceReference = sourceReference; _hasSource = source is not null; @@ -203,17 +215,45 @@ public KeySet( // "password=value", "password: value", "\"password\": \"value\"", "Authorization: Bearer value". // Quoted values are masked up to the closing quote, so values with spaces are masked completely. - _patterns.Add((new Regex( + var separated = new Regex( $@"(?{escaped}[""']?\s*[:=]\s*)(?:(?[""'])(?:\\.|(?!\k)[^\\\r\n])*\k|(?(?:{AuthSchemes})\s+)?\S+)", PatternOptions, - matchTimeout), MaskSeparatedValue)); + matchTimeout); // "password somevalue" (value must be 4+ chars to avoid natural language) - _patterns.Add((new Regex( + var spaced = new Regex( $@"(?{escaped}\s+[""']?(?:(?:{AuthSchemes})\s+)?)\S{{4,}}", PatternOptions, - matchTimeout), MaskSpacedValue)); + matchTimeout); + + var isAscii = IsAscii(key); + _hasNonAsciiKey |= !isAscii; + _patterns.Add(new KeyPatterns(key, isAscii, separated, spaced)); } + +#if NET9_0_OR_GREATER + var asciiKeys = _patterns.Where(p => p.KeyIsAscii).Select(p => p.Key).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (asciiKeys.Length > 0) + _asciiKeySearch = SearchValues.Create(asciiKeys, StringComparison.OrdinalIgnoreCase); +#endif + } + + /// The patterns of one key; every match of either pattern contains the key itself. + private sealed record KeyPatterns(string Key, bool KeyIsAscii, Regex Separated, Regex Spaced); + + private static bool IsAscii(string text) + { +#if NET8_0_OR_GREATER + return System.Text.Ascii.IsValid(text); +#else + foreach (var c in text) + { + if (c > '\u007F') + return false; + } + + return true; +#endif } public bool Contains(string normalizedKey) => _normalizedKeys.Contains(normalizedKey); @@ -228,11 +268,28 @@ public bool IsBuiltFrom(IReadOnlyCollection keys, bool referenceMeansUnc public string MaskPatterns(string message) { + if (_patterns.Count == 0) + return message; + try { var result = message; - foreach (var (pattern, mask) in _patterns) - result = pattern.Replace(result, mask); + + // Every match contains its key, so a key that does not occur in the message cannot match and its + // patterns are skipped. The ordinal check only rules a key out for ASCII text and an ASCII key: there it + // agrees with the patterns' IgnoreCase matching, while non-ASCII text can match case-insensitively in + // ways an ordinal comparison does not (for example the Kelvin sign and "k"); those always run the patterns. + var resultIsAscii = IsAscii(result); +#if NET9_0_OR_GREATER + if (resultIsAscii && !_hasNonAsciiKey && (_asciiKeySearch is null || !result.AsSpan().ContainsAny(_asciiKeySearch))) + return result; +#endif + + foreach (var key in _patterns) + { + result = Replace(key.Separated, result, _maskSeparatedValue, key, ref resultIsAscii); + result = Replace(key.Spaced, result, _maskSpacedValue, key, ref resultIsAscii); + } return result; } @@ -243,6 +300,20 @@ public string MaskPatterns(string message) } } + private static string Replace(Regex pattern, string text, MatchEvaluator mask, KeyPatterns key, ref bool textIsAscii) + { + if (textIsAscii && key.KeyIsAscii && !text.Contains(key.Key, StringComparison.OrdinalIgnoreCase)) + return text; + + var replaced = pattern.Replace(text, mask); + + // Regex.Replace returns the same instance when nothing matched; the placeholder may be non-ASCII. + if (!ReferenceEquals(replaced, text)) + textIsAscii = IsAscii(replaced); + + return replaced; + } + public string MaskLiterals(string message) { var result = message; diff --git a/tests/Tjidde.Logging.Tests/SensitiveDataMaskerTests.cs b/tests/Tjidde.Logging.Tests/SensitiveDataMaskerTests.cs index 489f978..8cc5b13 100644 --- a/tests/Tjidde.Logging.Tests/SensitiveDataMaskerTests.cs +++ b/tests/Tjidde.Logging.Tests/SensitiveDataMaskerTests.cs @@ -205,6 +205,38 @@ public void RuntimeKeys_ChangesApplyToAnExistingMasker() masker.IsSensitiveKey("SuperSecret42").Should().BeFalse(); } + [Theory] + [InlineData("Order processed successfully")] + [InlineData("Tokens of appreciation")] + [InlineData("caf\u00e9 au lait, no keys here")] + public void MaskMessage_LeavesTextWithoutSensitiveValuesUnchanged(string message) + { + _masker.MaskMessage(message).Should().Be(message); + } + + [Fact] + public void MaskMessage_MasksKeyWrittenWithNonAsciiCaseVariant() + { + // The Kelvin sign matches "k" case-insensitively, so the quick key check must not skip this text. + _masker.MaskMessage("to\u212Aen=abc123").Should().Be("to\u212Aen=[REDACTED]"); + } + + [Fact] + public void MaskMessage_MasksAsciiTextForNonAsciiKey() + { + var masker = new SensitiveDataMasker(additionalKeys: ["\u212Aeycode"]); + + masker.MaskMessage("keycode=abc123").Should().Be("keycode=[REDACTED]"); + } + + [Fact] + public void MaskMessage_MasksEveryKey_WhenPlaceholderIsNonAscii() + { + var masker = new SensitiveDataMasker(placeholder: "\u2588\u2588"); + + masker.MaskMessage("password=abc token=def").Should().Be("password=\u2588\u2588 token=\u2588\u2588"); + } + private sealed class FakeMaskedKeysAccessor : IMaskedKeysAccessor { public IReadOnlyCollection Keys { get; set; } = []; From 5265a816af6b2681f43aa64e8c2a676aca04f245 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 15:22:06 +0200 Subject: [PATCH 18/28] Add package validation and public API tracking - Enable package validation against the published 1.0.1 package (PackageValidationBaselineVersion 1.0.1). dotnet pack reports no breaking changes; strict mode shows only additions, identical for net7.0, net8.0, net9.0 and net10.0. - Add Microsoft.CodeAnalysis.PublicApiAnalyzers 5.6.0 (PrivateAssets=all). PublicAPI.Shipped.txt holds exactly the 1.0.1 API (generated from the 1.0.1 sources, which strict package validation confirms match the nuget.org package); PublicAPI.Unshipped.txt holds everything added since. The build has no RS0016/RS0017 warnings. - Wikis (en/nl): after a release move Unshipped to Shipped and raise PackageValidationBaselineVersion. Co-Authored-By: Claude Opus 5.5 --- docs/wiki-en.md | 12 +++ docs/wiki-nl.md | 12 +++ src/Tjidde.Logging/PublicAPI.Shipped.txt | 86 ++++++++++++++++++++++ src/Tjidde.Logging/PublicAPI.Unshipped.txt | 59 +++++++++++++++ src/Tjidde.Logging/Tjidde.Logging.csproj | 14 ++++ 5 files changed, 183 insertions(+) create mode 100644 src/Tjidde.Logging/PublicAPI.Shipped.txt create mode 100644 src/Tjidde.Logging/PublicAPI.Unshipped.txt diff --git a/docs/wiki-en.md b/docs/wiki-en.md index 1bc8aa7..e1d9fed 100644 --- a/docs/wiki-en.md +++ b/docs/wiki-en.md @@ -677,6 +677,18 @@ git push origin v1.0.4 The tag sets the package version: `v1.0.4` publishes `1.0.4`, and pre-releases such as `v1.1.0-beta.1` work too. The `` in the `.csproj` only applies to local builds. Publishing a version that already exists on nuget.org is skipped instead of failing. +### Public API and package validation + +Two checks guard against accidental breaking changes: + +- **Public API files.** `Microsoft.CodeAnalysis.PublicApiAnalyzers` compares the public API with two files next to `Tjidde.Logging.csproj`: `PublicAPI.Shipped.txt` (the API of the last release) and `PublicAPI.Unshipped.txt` (everything added since). New public API that is in neither file gives warning RS0016; add it to `PublicAPI.Unshipped.txt` (the RS0016 code fix in the IDE, or `dotnet format analyzers src/Tjidde.Logging/Tjidde.Logging.csproj --diagnostics RS0016`). A listed API that no longer matches, for example after a changed signature or default value, gives RS0017. +- **Package validation.** `dotnet pack` downloads the version in `` from nuget.org and fails if the new package breaks compatibility with it. + +After every release: + +1. Move all lines from `PublicAPI.Unshipped.txt` to `PublicAPI.Shipped.txt`. Both files keep `#nullable enable` as their first line, so `PublicAPI.Unshipped.txt` ends up with only that line. +2. Raise `` in `src/Tjidde.Logging/Tjidde.Logging.csproj` to the version you just released. + --- ## Future Work diff --git a/docs/wiki-nl.md b/docs/wiki-nl.md index 88107fb..f4728f9 100644 --- a/docs/wiki-nl.md +++ b/docs/wiki-nl.md @@ -677,6 +677,18 @@ git push origin v1.0.4 De tag bepaalt de pakketversie: `v1.0.4` publiceert `1.0.4`, en pre-releases zoals `v1.1.0-beta.1` werken ook. De `` in de `.csproj` geldt alleen voor lokale builds. Een versie die al op nuget.org staat, wordt overgeslagen in plaats van dat de workflow faalt. +### Publieke API en package validation + +Twee controles voorkomen onbedoelde breaking changes: + +- **Publieke-API-bestanden.** `Microsoft.CodeAnalysis.PublicApiAnalyzers` vergelijkt de publieke API met twee bestanden naast `Tjidde.Logging.csproj`: `PublicAPI.Shipped.txt` (de API van de laatste release) en `PublicAPI.Unshipped.txt` (alles wat daarna is toegevoegd). Nieuwe publieke API die in geen van beide staat, geeft waarschuwing RS0016; voeg die toe aan `PublicAPI.Unshipped.txt` (met de RS0016-codefix in de IDE, of `dotnet format analyzers src/Tjidde.Logging/Tjidde.Logging.csproj --diagnostics RS0016`). Een vermelde API die niet meer klopt, bijvoorbeeld na een gewijzigde signatuur of standaardwaarde, geeft RS0017. +- **Package validation.** `dotnet pack` haalt de versie uit `` op van nuget.org en faalt als het nieuwe pakket daarmee niet compatibel is. + +Na elke release: + +1. Verplaats alle regels uit `PublicAPI.Unshipped.txt` naar `PublicAPI.Shipped.txt`. Beide bestanden houden `#nullable enable` als eerste regel, dus in `PublicAPI.Unshipped.txt` blijft alleen die regel over. +2. Hoog `` in `src/Tjidde.Logging/Tjidde.Logging.csproj` op naar de versie die je net hebt uitgebracht. + --- ## Toekomstige ontwikkelingen diff --git a/src/Tjidde.Logging/PublicAPI.Shipped.txt b/src/Tjidde.Logging/PublicAPI.Shipped.txt new file mode 100644 index 0000000..2d9b2c2 --- /dev/null +++ b/src/Tjidde.Logging/PublicAPI.Shipped.txt @@ -0,0 +1,86 @@ +#nullable enable +static readonly Tjidde.Logging.Extensions.MetricsLoggerExtensions.Metrics -> Microsoft.Extensions.Logging.LogLevel +static Tjidde.Logging.Context.CustomerContext.Clear() -> void +static Tjidde.Logging.Context.CustomerContext.Current.get -> string? +static Tjidde.Logging.Context.CustomerContext.Set(string? customerIdentifier) -> void +static Tjidde.Logging.Extensions.MethodScopeLoggerExtensions.BeginMethodScope(this Microsoft.Extensions.Logging.ILogger! logger, string! methodName = "") -> System.IDisposable? +static Tjidde.Logging.Extensions.MetricsLoggerExtensions.LogMetrics(this Microsoft.Extensions.Logging.ILogger! logger, Microsoft.Extensions.Logging.EventId eventId, string! message, params object?[]! args) -> void +static Tjidde.Logging.Extensions.MetricsLoggerExtensions.LogMetrics(this Microsoft.Extensions.Logging.ILogger! logger, string! message, params object?[]! args) -> void +static Tjidde.Logging.Extensions.MetricsLoggerExtensions.LogMetrics(this Microsoft.Extensions.Logging.ILogger! logger, System.Exception? exception, string! message, params object?[]! args) -> void +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.AddTjiddeLogger(this Microsoft.Extensions.Logging.ILoggingBuilder! builder) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.AddTjiddeLogger(this Microsoft.Extensions.Logging.ILoggingBuilder! builder, Microsoft.Extensions.Configuration.IConfiguration! configuration, string! sectionPath = "TjiddeLogger") -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.AddTjiddeLogger(this Microsoft.Extensions.Logging.ILoggingBuilder! builder, Microsoft.Extensions.Configuration.IConfigurationSection! configurationSection) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.AddTjiddeLogger(this Microsoft.Extensions.Logging.ILoggingBuilder! builder, System.Action! configure) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.UseTjiddeJsonFormat(this Microsoft.Extensions.Logging.ILoggingBuilder! builder) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.UseTjiddeTextFormat(this Microsoft.Extensions.Logging.ILoggingBuilder! builder) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Masking.MaskedKeysContext.Add(params string![]! keys) -> void +static Tjidde.Logging.Masking.MaskedKeysContext.Clear() -> void +static Tjidde.Logging.Masking.MaskedKeysContext.GetKeys() -> System.Collections.Generic.IReadOnlyCollection! +static Tjidde.Logging.Masking.MaskedKeysContext.Remove(params string![]! keys) -> void +Tjidde.Logging.Context.AsyncLocalCustomerContextAccessor +Tjidde.Logging.Context.AsyncLocalCustomerContextAccessor.AsyncLocalCustomerContextAccessor() -> void +Tjidde.Logging.Context.AsyncLocalCustomerContextAccessor.GetCustomerContext() -> string? +Tjidde.Logging.Context.CustomerContext +Tjidde.Logging.Context.ICustomerContextAccessor +Tjidde.Logging.Context.ICustomerContextAccessor.GetCustomerContext() -> string? +Tjidde.Logging.Extensions.MethodScopeLoggerExtensions +Tjidde.Logging.Extensions.MetricsLoggerExtensions +Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions +Tjidde.Logging.Formatting.ExceptionFormatter +Tjidde.Logging.Formatting.ExceptionFormatter.ExceptionFormatter(bool includeStackTrace = true, bool includeInnerExceptions = true) -> void +Tjidde.Logging.Formatting.ExceptionFormatter.Format(System.Exception! exception) -> string! +Tjidde.Logging.Formatting.IExceptionFormatter +Tjidde.Logging.Formatting.IExceptionFormatter.Format(System.Exception! exception) -> string! +Tjidde.Logging.Logging.TjiddeLoggerProvider +Tjidde.Logging.Logging.TjiddeLoggerProvider.CreateLogger(string! categoryName) -> Microsoft.Extensions.Logging.ILogger! +Tjidde.Logging.Logging.TjiddeLoggerProvider.Dispose() -> void +Tjidde.Logging.Logging.TjiddeLoggerProvider.SetScopeProvider(Microsoft.Extensions.Logging.IExternalScopeProvider! scopeProvider) -> void +Tjidde.Logging.Logging.TjiddeLoggerProvider.TjiddeLoggerProvider(Microsoft.Extensions.Options.IOptionsMonitor! optionsMonitor, Tjidde.Logging.Context.ICustomerContextAccessor! customerContextAccessor, Tjidde.Logging.Masking.IMaskedKeysAccessor! maskedKeysAccessor) -> void +Tjidde.Logging.Masking.GlobalMaskedKeysAccessor +Tjidde.Logging.Masking.GlobalMaskedKeysAccessor.GetKeys() -> System.Collections.Generic.IReadOnlyCollection! +Tjidde.Logging.Masking.GlobalMaskedKeysAccessor.GlobalMaskedKeysAccessor() -> void +Tjidde.Logging.Masking.IMaskedKeysAccessor +Tjidde.Logging.Masking.IMaskedKeysAccessor.GetKeys() -> System.Collections.Generic.IReadOnlyCollection! +Tjidde.Logging.Masking.ISensitiveDataMasker +Tjidde.Logging.Masking.ISensitiveDataMasker.IsSensitiveKey(string! key) -> bool +Tjidde.Logging.Masking.ISensitiveDataMasker.MaskDictionary(System.Collections.Generic.IEnumerable>! properties) -> System.Collections.Generic.IReadOnlyDictionary! +Tjidde.Logging.Masking.ISensitiveDataMasker.MaskMessage(string! message) -> string! +Tjidde.Logging.Masking.ISensitiveDataMasker.MaskValue(string! key, string! value) -> string! +Tjidde.Logging.Masking.MaskedKeysContext +Tjidde.Logging.Masking.SensitiveDataMasker +Tjidde.Logging.Masking.SensitiveDataMasker.IsSensitiveKey(string! key) -> bool +Tjidde.Logging.Masking.SensitiveDataMasker.MaskDictionary(System.Collections.Generic.IEnumerable>! properties) -> System.Collections.Generic.IReadOnlyDictionary! +Tjidde.Logging.Masking.SensitiveDataMasker.MaskMessage(string! message) -> string! +Tjidde.Logging.Masking.SensitiveDataMasker.MaskValue(string! key, string! value) -> string! +Tjidde.Logging.Masking.SensitiveDataMasker.SensitiveDataMasker(string! placeholder = "[REDACTED]", System.Collections.Generic.IEnumerable? additionalKeys = null, System.Collections.Generic.IEnumerable? dynamicKeys = null) -> void +Tjidde.Logging.Options.TjiddeLoggerOptions +Tjidde.Logging.Options.TjiddeLoggerOptions.AdditionalSensitiveKeys.get -> System.Collections.Generic.IList! +Tjidde.Logging.Options.TjiddeLoggerOptions.AdditionalSensitiveKeys.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.CategoryMinimumLevels.get -> System.Collections.Generic.IDictionary! +Tjidde.Logging.Options.TjiddeLoggerOptions.CategoryMinimumLevels.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.EnableOpenTelemetryExport.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.EnableOpenTelemetryExport.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.EnableSensitiveDataMasking.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.EnableSensitiveDataMasking.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.IncludeInnerExceptions.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.IncludeInnerExceptions.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.IncludeScopes.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.IncludeScopes.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.IncludeStackTrace.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.IncludeStackTrace.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.MaskPlaceholder.get -> string! +Tjidde.Logging.Options.TjiddeLoggerOptions.MaskPlaceholder.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.MinimumLevel.get -> Microsoft.Extensions.Logging.LogLevel +Tjidde.Logging.Options.TjiddeLoggerOptions.MinimumLevel.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.OpenTelemetryActivitySourceName.get -> string! +Tjidde.Logging.Options.TjiddeLoggerOptions.OpenTelemetryActivitySourceName.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.OpenTelemetryCreateFallbackActivity.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.OpenTelemetryCreateFallbackActivity.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.OutputFormat.get -> Tjidde.Logging.Options.TjiddeLogOutputFormat +Tjidde.Logging.Options.TjiddeLoggerOptions.OutputFormat.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.ResolveMethodNameFromStackTrace.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.ResolveMethodNameFromStackTrace.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.TjiddeLoggerOptions() -> void +Tjidde.Logging.Options.TjiddeLogOutputFormat +Tjidde.Logging.Options.TjiddeLogOutputFormat.Json = 1 -> Tjidde.Logging.Options.TjiddeLogOutputFormat +Tjidde.Logging.Options.TjiddeLogOutputFormat.Text = 0 -> Tjidde.Logging.Options.TjiddeLogOutputFormat diff --git a/src/Tjidde.Logging/PublicAPI.Unshipped.txt b/src/Tjidde.Logging/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..bf3ff81 --- /dev/null +++ b/src/Tjidde.Logging/PublicAPI.Unshipped.txt @@ -0,0 +1,59 @@ +#nullable enable +const Tjidde.Logging.Extensions.MetricsLoggerExtensions.MetricsEventIdValue = 10000 -> int +const Tjidde.Logging.Extensions.MetricsLoggerExtensions.MetricsEventName = "Metrics" -> string! +const Tjidde.Logging.Sinks.InMemoryLogSink.DefaultCapacity = 1000 -> int +override Tjidde.Logging.Sinks.TjiddeLogEntry.ToString() -> string! +static readonly Tjidde.Logging.Extensions.MetricsLoggerExtensions.MetricsEventId -> Microsoft.Extensions.Logging.EventId +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.AddTjiddeInMemorySink(this Microsoft.Extensions.Logging.ILoggingBuilder! builder, int capacity = 1000) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.AddTjiddeSink(this Microsoft.Extensions.Logging.ILoggingBuilder! builder) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.AddTjiddeSink(this Microsoft.Extensions.Logging.ILoggingBuilder! builder, TSink! sink) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static Tjidde.Logging.Extensions.TjiddeLoggingBuilderExtensions.UseIsolatedMaskedKeys(this Microsoft.Extensions.Logging.ILoggingBuilder! builder) -> Microsoft.Extensions.Logging.ILoggingBuilder! +Tjidde.Logging.Logging.TjiddeLoggerProvider.TjiddeLoggerProvider(Microsoft.Extensions.Options.IOptionsMonitor! optionsMonitor, Tjidde.Logging.Context.ICustomerContextAccessor! customerContextAccessor, Tjidde.Logging.Masking.IMaskedKeysAccessor! maskedKeysAccessor, System.TimeProvider! timeProvider) -> void +Tjidde.Logging.Logging.TjiddeLoggerProvider.TjiddeLoggerProvider(Microsoft.Extensions.Options.IOptionsMonitor! optionsMonitor, Tjidde.Logging.Context.ICustomerContextAccessor! customerContextAccessor, Tjidde.Logging.Masking.IMaskedKeysAccessor! maskedKeysAccessor, System.TimeProvider! timeProvider, System.Collections.Generic.IEnumerable? sinks) -> void +Tjidde.Logging.Masking.MaskedKeysStore +Tjidde.Logging.Masking.MaskedKeysStore.Add(params string![]! keys) -> void +Tjidde.Logging.Masking.MaskedKeysStore.Clear() -> void +Tjidde.Logging.Masking.MaskedKeysStore.GetKeys() -> System.Collections.Generic.IReadOnlyCollection! +Tjidde.Logging.Masking.MaskedKeysStore.MaskedKeysStore() -> void +Tjidde.Logging.Masking.MaskedKeysStore.Remove(params string![]! keys) -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.UseUtcTimestamp.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.UseUtcTimestamp.set -> void +Tjidde.Logging.Options.TjiddeLoggerOptions.WriteToConsole.get -> bool +Tjidde.Logging.Options.TjiddeLoggerOptions.WriteToConsole.set -> void +Tjidde.Logging.Sinks.ILogSink +Tjidde.Logging.Sinks.ILogSink.Write(Tjidde.Logging.Sinks.TjiddeLogEntry! entry) -> void +Tjidde.Logging.Sinks.InMemoryLogSink +Tjidde.Logging.Sinks.InMemoryLogSink.Capacity.get -> int +Tjidde.Logging.Sinks.InMemoryLogSink.Clear() -> void +Tjidde.Logging.Sinks.InMemoryLogSink.Cleared -> System.EventHandler? +Tjidde.Logging.Sinks.InMemoryLogSink.Count.get -> int +Tjidde.Logging.Sinks.InMemoryLogSink.EntryAdded -> System.EventHandler? +Tjidde.Logging.Sinks.InMemoryLogSink.GetSnapshot() -> System.Collections.Generic.IReadOnlyList! +Tjidde.Logging.Sinks.InMemoryLogSink.InMemoryLogSink() -> void +Tjidde.Logging.Sinks.InMemoryLogSink.InMemoryLogSink(int capacity) -> void +Tjidde.Logging.Sinks.InMemoryLogSink.Write(Tjidde.Logging.Sinks.TjiddeLogEntry! entry) -> void +Tjidde.Logging.Sinks.TjiddeLogEntry +Tjidde.Logging.Sinks.TjiddeLogEntry.Category.get -> string! +Tjidde.Logging.Sinks.TjiddeLogEntry.Category.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.ClassName.get -> string! +Tjidde.Logging.Sinks.TjiddeLogEntry.ClassName.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.Customer.get -> string? +Tjidde.Logging.Sinks.TjiddeLogEntry.Customer.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.EventId.get -> Microsoft.Extensions.Logging.EventId +Tjidde.Logging.Sinks.TjiddeLogEntry.EventId.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.FormattedException.get -> string? +Tjidde.Logging.Sinks.TjiddeLogEntry.FormattedException.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.IsMetrics.get -> bool +Tjidde.Logging.Sinks.TjiddeLogEntry.Level.get -> Microsoft.Extensions.Logging.LogLevel +Tjidde.Logging.Sinks.TjiddeLogEntry.Level.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.Message.get -> string! +Tjidde.Logging.Sinks.TjiddeLogEntry.Message.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.MethodName.get -> string? +Tjidde.Logging.Sinks.TjiddeLogEntry.MethodName.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.OutputFormat.get -> Tjidde.Logging.Options.TjiddeLogOutputFormat +Tjidde.Logging.Sinks.TjiddeLogEntry.OutputFormat.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.RenderedLine.get -> string! +Tjidde.Logging.Sinks.TjiddeLogEntry.RenderedLine.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.Timestamp.get -> System.DateTimeOffset +Tjidde.Logging.Sinks.TjiddeLogEntry.Timestamp.init -> void +Tjidde.Logging.Sinks.TjiddeLogEntry.TjiddeLogEntry() -> void diff --git a/src/Tjidde.Logging/Tjidde.Logging.csproj b/src/Tjidde.Logging/Tjidde.Logging.csproj index 8ae6348..8308e68 100644 --- a/src/Tjidde.Logging/Tjidde.Logging.csproj +++ b/src/Tjidde.Logging/Tjidde.Logging.csproj @@ -26,6 +26,11 @@ false + + + true + 1.0.1 + + + + From 8460951fce6c5e7c5fdaf5e8e29e00b3efc66078 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 15:25:20 +0200 Subject: [PATCH 19/28] Reduce allocations when building an entry - Create the masked-key list only when a property is masked, and size the property list from the state's count. - Read the scopes in one pass for the MethodName scope and the scope texts, with a static callback and a per-thread reader instead of two closures and LINQ. A scope whose enumeration throws while reading the method name still fails the entry, as before. - Write the text line straight into one reused StringBuilder per thread instead of building date, time, method and customer strings first. The output is unchanged. A new test logs from a scope value's ToString to check that a nested entry does not disturb the outer one. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../Logging/StringBuilderCache.cs | 37 +++ src/Tjidde.Logging/Logging/TjiddeLogger.cs | 213 +++++++++++------- tests/Tjidde.Logging.Tests/LogSinkTests.cs | 59 +++++ 4 files changed, 234 insertions(+), 76 deletions(-) create mode 100644 src/Tjidde.Logging/Logging/StringBuilderCache.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5fc42..1858a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### 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` 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. ### Fixed - `LogMetrics` threw an `ArgumentOutOfRangeException` when another provider, such as the Microsoft console logger (`AddConsole()`), was also registered. diff --git a/src/Tjidde.Logging/Logging/StringBuilderCache.cs b/src/Tjidde.Logging/Logging/StringBuilderCache.cs new file mode 100644 index 0000000..85d0185 --- /dev/null +++ b/src/Tjidde.Logging/Logging/StringBuilderCache.cs @@ -0,0 +1,37 @@ +using System.Text; + +namespace Tjidde.Logging.Logging; + +/// +/// One reusable per thread for building log lines. A nested log call on the same thread +/// (for example from a value's ToString()) finds the cache empty and gets a new builder, so builders are never +/// shared. Builders that grew beyond are not kept. +/// +internal static class StringBuilderCache +{ + private const int DefaultCapacity = 256; + private const int MaxCachedCapacity = 4096; + + [ThreadStatic] + private static StringBuilder? _cached; + + public static StringBuilder Acquire() + { + var sb = _cached; + if (sb is null) + return new StringBuilder(DefaultCapacity); + + _cached = null; + sb.Clear(); + return sb; + } + + public static string GetStringAndRelease(StringBuilder sb) + { + var result = sb.ToString(); + if (sb.Capacity <= MaxCachedCapacity) + _cached = sb; + + return result; + } +} diff --git a/src/Tjidde.Logging/Logging/TjiddeLogger.cs b/src/Tjidde.Logging/Logging/TjiddeLogger.cs index a65bd8d..4a57ef3 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLogger.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLogger.cs @@ -6,6 +6,7 @@ using Tjidde.Logging.Sinks; using Microsoft.Extensions.Logging; using System.Diagnostics; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -189,9 +190,20 @@ private void WriteEntry( ref string? message) { var customer = _customerContextAccessor.GetCustomerContext(); - var methodName = ResolveMethodName(config); - var maskedKeyNames = new List(); + // One pass over the scopes for both the MethodName scope and the scope texts (when scopes are included). + string? methodName; + List? scopes = null; + if (config.Options.IncludeScopes && _scopeProvider is not null) + (methodName, scopes) = ReadScopes(config, _scopeProvider); + else + methodName = null; + + if (methodName is null && config.Options.ResolveMethodNameFromStackTrace) + methodName = ExtractMethodFromStack(); + + // Allocated only when a property is actually masked. + List? maskedKeyNames = null; List>? properties = null; string? originalFormat = null; @@ -199,7 +211,10 @@ private void WriteEntry( // reach any output (text, JSON or OpenTelemetry) unmasked. if (state is IEnumerable> structuredState) { - properties = new List>(); + // FormattedLogValues and LoggerMessage state know their count ({OriginalFormat} included). + properties = structuredState is IReadOnlyCollection> { Count: > 0 } sized + ? new List>(sized.Count) + : new List>(); foreach (var kvp in structuredState) { if (kvp.Key.Equals("{OriginalFormat}", StringComparison.Ordinal)) @@ -208,15 +223,11 @@ private void WriteEntry( continue; } - properties.Add(new KeyValuePair(kvp.Key, SafeMaskPropertyValue(config, kvp.Key, kvp.Value, maskedKeyNames))); + properties.Add(new KeyValuePair(kvp.Key, SafeMaskPropertyValue(config, kvp.Key, kvp.Value, ref maskedKeyNames))); } } - message = RenderMessage(config, state, exception, formatter, originalFormat, properties, maskedKeyNames.Count > 0); - - List? scopes = null; - if (config.Options.IncludeScopes && _scopeProvider is not null) - scopes = CollectScopes(config); + message = RenderMessage(config, state, exception, formatter, originalFormat, properties, maskedKeyNames is not null); // Formatted (and masked) once, for the rendered line and the sinks. var formattedException = exception is null ? null : FormatException(config, exception); @@ -434,11 +445,11 @@ private string RenderMessage( return config.Options.EnableSensitiveDataMasking ? config.Masker.MaskMessage(message) : message; } - private object? SafeMaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, List maskedKeyNames) + private object? SafeMaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, ref List? maskedKeyNames) { try { - return MaskPropertyValue(config, key, value, maskedKeyNames); + return MaskPropertyValue(config, key, value, ref maskedKeyNames); } catch (Exception) { @@ -447,14 +458,14 @@ private string RenderMessage( } } - private object? MaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, List maskedKeyNames) + private object? MaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, ref List? maskedKeyNames) { if (!config.Options.EnableSensitiveDataMasking) return value; if (config.Masker.IsSensitiveKey(key)) { - maskedKeyNames.Add(key); + (maskedKeyNames ??= []).Add(key); return config.Masker.MaskValue(key, value?.ToString() ?? string.Empty); } @@ -489,7 +500,7 @@ private void ExportToOpenTelemetry( string message, Exception? exception, List? scopes, - List maskedKeyNames, + List? maskedKeyNames, List>? properties) { if (!config.Options.EnableOpenTelemetryExport) @@ -528,7 +539,7 @@ private void ExportToOpenTelemetry( string message, Exception? exception, List? scopes, - List maskedKeyNames, + List? maskedKeyNames, List>? properties) { yield return new KeyValuePair("log.category", _categoryName); @@ -559,7 +570,7 @@ private void ExportToOpenTelemetry( if (scopes is { Count: > 0 }) yield return new KeyValuePair("log.scopes", string.Join(" > ", scopes)); - if (maskedKeyNames.Count > 0) + if (maskedKeyNames is { Count: > 0 }) yield return new KeyValuePair("log.masked_fields", string.Join(",", maskedKeyNames)); if (properties is not null) @@ -578,21 +589,34 @@ private string BuildTextLogLine( string message, string? formattedException, List? scopes, - List maskedKeyNames) + List? maskedKeyNames) { - var sb = new System.Text.StringBuilder(); - sb.Append(BuildLogLine(timestamp, FormatLogLevel(logLevel, eventId), _className, methodName, customer, message)); + // Written straight into one (reused) builder: no intermediate strings for the date, time and parts. + var sb = StringBuilderCache.Acquire(); + AppendLogLine(sb, timestamp, FormatLogLevel(logLevel, eventId), _className, methodName, customer, message); - if (maskedKeyNames.Count > 0) - sb.Append($" [Masked: {string.Join(", ", maskedKeyNames)}]"); + if (maskedKeyNames is { Count: > 0 }) + AppendJoined(sb.Append(" [Masked: "), ", ", maskedKeyNames).Append(']'); if (formattedException is not null) - sb.Append($" | Exception: {formattedException}"); + sb.Append(" | Exception: ").Append(formattedException); if (scopes is { Count: > 0 }) - sb.Append($" | Scopes: {string.Join(" > ", scopes)}"); + AppendJoined(sb.Append(" | Scopes: "), " > ", scopes); - return sb.ToString(); + return StringBuilderCache.GetStringAndRelease(sb); + } + + private static StringBuilder AppendJoined(StringBuilder sb, string separator, List values) + { + for (var i = 0; i < values.Count; i++) + { + if (i > 0) + sb.Append(separator); + sb.Append(values[i]); + } + + return sb; } private string BuildJsonLogLine( @@ -605,7 +629,7 @@ private string BuildJsonLogLine( string message, string? formattedException, List? scopes, - List maskedKeyNames, + List? maskedKeyNames, List>? properties) { var payload = new Dictionary @@ -620,7 +644,7 @@ private string BuildJsonLogLine( ["customer"] = customer, ["method"] = methodName, ["scopes"] = scopes is { Count: > 0 } ? scopes : null, - ["maskedFields"] = maskedKeyNames.Count > 0 ? maskedKeyNames : null, + ["maskedFields"] = maskedKeyNames is { Count: > 0 } ? maskedKeyNames : null, ["exception"] = formattedException }; @@ -656,7 +680,12 @@ private string BuildJsonLogLine( } } - private static string BuildLogLine( + /// + /// Appends {date}: {time}: [{level}] Class=>{className}[ Method=>{method}]: [Client=>{customer}: ]{message}. + /// The date and time are formatted with the current culture, like . + /// + private static void AppendLogLine( + StringBuilder sb, DateTimeOffset timestamp, string level, string className, @@ -664,12 +693,17 @@ private static string BuildLogLine( string? customer, string message) { - var date = timestamp.ToString("yyyy-MM-dd"); - var time = timestamp.ToString("HH:mm:ss"); - var method = string.IsNullOrWhiteSpace(methodName) ? string.Empty : $" Method=>{methodName}"; - var customerPart = string.IsNullOrWhiteSpace(customer) ? string.Empty : $"Client=>{customer}: "; + sb.Append($"{timestamp:yyyy-MM-dd}: {timestamp:HH:mm:ss}: [").Append(level).Append("] Class=>").Append(className); - return $"{date}: {time}: [{level}] Class=>{className}{method}: {customerPart}{message}"; + if (!string.IsNullOrWhiteSpace(methodName)) + sb.Append(" Method=>").Append(methodName); + + sb.Append(": "); + + if (!string.IsNullOrWhiteSpace(customer)) + sb.Append("Client=>").Append(customer).Append(": "); + + sb.Append(message); } private static string FormatLogLevel(LogLevel logLevel, EventId eventId) => logLevel switch @@ -684,27 +718,83 @@ _ when MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId) => "METRICS", _ => "???" }; - private string? ResolveMethodName(TjiddeLoggerConfiguration config) - => ExtractMethodFromScope(config) - ?? (config.Options.ResolveMethodNameFromStackTrace ? ExtractMethodFromStack() : null); + /// + /// Reads the active scopes once: the method name from the innermost MethodName scope value, and the scope + /// texts for the output (masked, without MethodName). Scopes is when no scope adds text. + /// + private static (string? MethodName, List? Scopes) ReadScopes(TjiddeLoggerConfiguration config, IExternalScopeProvider scopeProvider) + { + // Reused per thread so a call without scopes allocates nothing; a nested log call (from a scope value's + // ToString) finds the cache empty and uses its own reader. + var reader = ScopeReader.Cached ?? new ScopeReader(); + ScopeReader.Cached = null; + + reader.Config = config; + try + { + scopeProvider.ForEachScope(static (scope, r) => r.Read(scope), reader); + return (reader.MethodName, reader.Scopes); + } + finally + { + reader.Reset(); + ScopeReader.Cached = reader; + } + } - private string? ExtractMethodFromScope(TjiddeLoggerConfiguration config) + private sealed class ScopeReader { - if (!config.Options.IncludeScopes || _scopeProvider is null) - return null; + [ThreadStatic] + public static ScopeReader? Cached; - string? methodName = null; - _scopeProvider.ForEachScope((scope, _) => + public TjiddeLoggerConfiguration Config = null!; + public string? MethodName; + public List? Scopes; + + public void Reset() { - if (scope is not IEnumerable> kvps) return; - foreach (var kvp in kvps) + Config = null!; + MethodName = null; + Scopes = null; + } + + public void Read(object? scope) + { + // The method name: an enumeration that throws here fails the entry (it is not caught), as before. + if (scope is IEnumerable> methodScope) { - if (kvp.Key.Equals("MethodName", StringComparison.OrdinalIgnoreCase) && kvp.Value is string m) - methodName = m; + foreach (var kvp in methodScope) + { + if (kvp.Key.Equals("MethodName", StringComparison.OrdinalIgnoreCase) && kvp.Value is string m) + MethodName = m; + } } - }, (object?)null); - return methodName; + // The scope text: a scope whose enumeration throws is skipped instead of failing the whole entry. + try + { + switch (scope) + { + case string s when !string.IsNullOrWhiteSpace(s): + (Scopes ??= []).Add(Config.Options.EnableSensitiveDataMasking ? Config.Masker.MaskMessage(s) : s); + break; + case IEnumerable> kvps: + { + foreach (var kvp in kvps) + { + if (!kvp.Key.Equals("MethodName", StringComparison.OrdinalIgnoreCase)) + (Scopes ??= []).Add(FormatScopeProperty(Config, kvp.Key, kvp.Value)); + } + + break; + } + } + } + catch (Exception) + { + (Scopes ??= []).Add($"[unserializable scope: {scope?.GetType().FullName}]"); + } + } } private static string? ExtractMethodFromStack() @@ -738,7 +828,7 @@ _ when MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId) => "METRICS", return null; } - private string FormatScopeProperty(TjiddeLoggerConfiguration config, string key, object? value) + private static string FormatScopeProperty(TjiddeLoggerConfiguration config, string key, object? value) { var raw = SafeToString(value) ?? string.Empty; if (!config.Options.EnableSensitiveDataMasking) @@ -750,35 +840,6 @@ private string FormatScopeProperty(TjiddeLoggerConfiguration config, string key, return $"{key}={text}"; } - private List CollectScopes(TjiddeLoggerConfiguration config) - { - var scopes = new List(); - _scopeProvider?.ForEachScope((scope, list) => - { - try - { - switch (scope) - { - case string s when !string.IsNullOrWhiteSpace(s): - list.Add(config.Options.EnableSensitiveDataMasking ? config.Masker.MaskMessage(s) : s); - break; - case IEnumerable> kvps: - { - list.AddRange(from kvp in kvps where !kvp.Key.Equals("MethodName", StringComparison.OrdinalIgnoreCase) select FormatScopeProperty(config, kvp.Key, kvp.Value)); - - break; - } - } - } - catch (Exception) - { - // A scope whose enumeration throws is skipped instead of failing the whole entry. - list.Add($"[unserializable scope: {scope?.GetType().FullName}]"); - } - }, scopes); - return scopes; - } - private static ConsoleColor GetColor(LogLevel logLevel, EventId eventId) => logLevel switch { _ when MetricsLoggerExtensions.IsMetricsEntry(logLevel, eventId) => ConsoleColor.Magenta, diff --git a/tests/Tjidde.Logging.Tests/LogSinkTests.cs b/tests/Tjidde.Logging.Tests/LogSinkTests.cs index a038d19..43b89d3 100644 --- a/tests/Tjidde.Logging.Tests/LogSinkTests.cs +++ b/tests/Tjidde.Logging.Tests/LogSinkTests.cs @@ -207,6 +207,65 @@ public void SinkThatLogs_DoesNotRecurse() sink.GetSnapshot().Select(e => e.Message).Should().Equal("outer"); } + [Theory] + [InlineData(TjiddeLogOutputFormat.Text)] + [InlineData(TjiddeLogOutputFormat.Json)] + public void EntryLoggedWhileReadingScopes_DoesNotAffectTheOuterEntry(TjiddeLogOutputFormat format) + { + // A scope value whose ToString() logs: the nested call runs while the outer entry is reading its scopes, + // on the same thread, and both entries must still get their own method name, scopes and line. + var monitor = new TestOptionsMonitor(new TjiddeLoggerOptions { WriteToConsole = false, OutputFormat = format }); + var sink = new InMemoryLogSink(); + var value = new LoggingValue(); + + using (var provider = CreateProvider(monitor, sink)) + { + provider.SetScopeProvider(new LoggerExternalScopeProvider()); + var logger = provider.CreateLogger("My.App.OrderService"); + value.Logger = logger; + using (logger.BeginScope("Order pipeline")) + using (logger.BeginScope(new Dictionary { ["MethodName"] = "Process", ["Item"] = value })) + { + logger.LogInformation("outer {Count}", 1); + } + } + + var entries = sink.GetSnapshot(); + entries.Select(e => e.Message).Should().Equal("inner [REDACTED]", "outer 1"); + entries.Select(e => e.MethodName).Should().Equal("Process", "Process"); + if (format == TjiddeLogOutputFormat.Text) + { + entries[0].RenderedLine.Should().EndWith("inner [REDACTED] [Masked: Password] | Scopes: Order pipeline > Item=value"); + entries[1].RenderedLine.Should().EndWith("outer 1 | Scopes: Order pipeline > Item=value"); + } + else + { + foreach (var entry in entries) + { + using var json = JsonDocument.Parse(entry.RenderedLine); + json.RootElement.GetProperty("scopes").EnumerateArray().Select(e => e.GetString()) + .Should().Equal("Order pipeline", "Item=value"); + } + } + } + + private sealed class LoggingValue + { + private bool _logged; + public ILogger? Logger; + + public override string ToString() + { + if (!_logged) + { + _logged = true; + Logger?.LogInformation("inner {Password}", "hunter2"); + } + + return "value"; + } + } + [Fact] public void DependencyInjection_PassesEntriesToEveryRegisteredSink() { From 750d03ce189d4ac69666fba44605d1431d7526f6 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 15:28:03 +0200 Subject: [PATCH 20/28] Bump FluentAssertions from 8.9.0 to 8.11.0 Co-Authored-By: Claude Opus 5.5 --- tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj index c45184c..20530fb 100644 --- a/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj +++ b/tests/Tjidde.Logging.Tests/Tjidde.Logging.Tests.csproj @@ -12,7 +12,7 @@ - + + @@ -57,20 +57,20 @@ - - - - - + + + + + - - - - - + + + + + From 3c868f975d6f014c30ed3a9e31e880e71b054324 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 15:35:11 +0200 Subject: [PATCH 24/28] Split Dependabot NuGet config into library/tests and samples/benchmarks Dependabot takes the highest Microsoft.Extensions.* version in a file as the current one and rewrites every occurrence, which collapsed the per-TFM ItemGroups in the library and tests. Microsoft.Extensions.* is now ignored for /src/* and /tests/* (updated by hand per TFM); /samples/* and /benchmarks/* get grouped microsoft-extensions and avalonia updates. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e41a488..3254518 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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*" @@ -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 @@ -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: From d38a6d9ca9cc7e59adbd9d5facf45811d43777c9 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 16:06:27 +0200 Subject: [PATCH 25/28] Document benchmark results Co-Authored-By: Codex --- benchmarks/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/benchmarks/README.md b/benchmarks/README.md index ab1f2b8..586c393 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -55,3 +55,31 @@ so compare the means as rough figures; the allocations are exact. Most of the time goes to sensitive data masking: `SensitiveDataMasker.MaskMessage` runs two regular expressions per sensitive key (17 built-in keys, so 34 regex scans) over the message and over every string property and scope value, even when the text contains none of the keys. + +## After optimization + +Commit under test: `improvements` after the masking pre-check, allocation reductions and `Utf8JsonWriter` JSON +changes. Measured with `--job short` on .NET 10.0.12, Intel Core i7-8565U (laptop, 4 cores), Windows 11. ShortRun +uses only three measured iterations, so use the means as a quick regression signal; allocations are the more stable +part of this run. + +| Method | Format | Mean | Allocated | Mean vs baseline | Alloc vs baseline | +|----------------------|--------|-----------:|----------:|-----------------:|------------------:| +| Simple | Text | 499.2 ns | 296 B | -87.7% | -75.8% | +| Structured | Text | 813.3 ns | 568 B | -92.4% | -63.8% | +| SensitiveProperty | Text | 1,194.2 ns | 1128 B | -92.3% | -52.5% | +| StructuredWithScopes | Text | 1,272.0 ns | 1016 B | -94.7% | -62.9% | +| StructuredWithSink | Text | 895.0 ns | 680 B | -94.3% | -59.5% | +| Disabled | Text | 3.3 ns | - | -57.6% | n/a | +| Simple | Json | 1,124.7 ns | 936 B | -90.8% | -56.4% | +| Structured | Json | 1,638.8 ns | 1400 B | -93.8% | -64.2% | +| SensitiveProperty | Json | 1,951.7 ns | 1912 B | -92.6% | -52.7% | +| StructuredWithScopes | Json | 2,072.0 ns | 1808 B | -94.8% | -59.2% | +| StructuredWithSink | Json | 1,769.5 ns | 1512 B | -91.8% | -62.4% | +| Disabled | Json | 3.0 ns | - | -71.0% | n/a | + +The masking pre-check removes almost all regex work for entries whose text and values do not contain a configured +key, which explains the large wins in the simple and structured text paths. The allocation work removes per-entry +temporary lists, scope closures and intermediate text strings, cutting text allocations by roughly 53-76%. The JSON +writer change avoids building two dictionaries and writes common primitive values directly, which brings JSON +allocations down by roughly 53-64% while preserving the rendered output. From e1923ee098079d739c4c2600ae7abea472d8e064 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 16:11:23 +0200 Subject: [PATCH 26/28] Enforce clean CI builds Co-Authored-By: Codex --- .editorconfig | 7 ++++++ .github/workflows/publish.yml | 2 +- Directory.Build.props | 11 +++++++++ .../Formatting/ExceptionFormatter.cs | 4 ++-- src/Tjidde.Logging/Logging/TjiddeLogger.cs | 23 ++++++++++++------- src/Tjidde.Logging/Tjidde.Logging.csproj | 3 ++- .../Tjidde.Logging.Tests/SharedMaskerTests.cs | 10 ++++---- 7 files changed, 43 insertions(+), 17 deletions(-) create mode 100644 .editorconfig create mode 100644 Directory.Build.props diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..00e56d3 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 695f346..0773053 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..ca7fa72 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + false + + + + true + true + true + + diff --git a/src/Tjidde.Logging/Formatting/ExceptionFormatter.cs b/src/Tjidde.Logging/Formatting/ExceptionFormatter.cs index 26400a3..522e057 100644 --- a/src/Tjidde.Logging/Formatting/ExceptionFormatter.cs +++ b/src/Tjidde.Logging/Formatting/ExceptionFormatter.cs @@ -36,14 +36,14 @@ private void AppendException(StringBuilder sb, Exception exception, int depth) if (sb.Length > 0 && depth == 0) sb.Append(' '); - sb.Append($"{prefix}[{exception.GetType().FullName}: {exception.Message}"); + sb.Append(prefix).Append('[').Append(exception.GetType().FullName).Append(": ").Append(exception.Message); if (_includeStackTrace && !string.IsNullOrWhiteSpace(exception.StackTrace)) { var frames = exception.StackTrace .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) .Select(l => l.Trim()); - sb.Append($" | StackTrace: {string.Join(" | ", frames)}"); + sb.Append(" | StackTrace: ").AppendJoin(" | ", frames); } sb.Append(']'); diff --git a/src/Tjidde.Logging/Logging/TjiddeLogger.cs b/src/Tjidde.Logging/Logging/TjiddeLogger.cs index 93dabee..2e7e531 100644 --- a/src/Tjidde.Logging/Logging/TjiddeLogger.cs +++ b/src/Tjidde.Logging/Logging/TjiddeLogger.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using System.Buffers; using System.Diagnostics; +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -333,7 +334,7 @@ private void WriteFallbackEntry( /// structured state yields its unrendered template, because the framework's formatter inserts raw /// (unmasked) values of sensitive properties. /// - private string? FallbackMessage(TjiddeLoggerConfiguration config, TState state, Exception? exception, Func formatter) + private static string? FallbackMessage(TjiddeLoggerConfiguration config, TState state, Exception? exception, Func formatter) { if (config.Options.EnableSensitiveDataMasking && state is IEnumerable> structuredState) { @@ -368,7 +369,7 @@ private void WriteFallbackEntry( } } - private string? TryMask(TjiddeLoggerConfiguration config, string? text) + private static string? TryMask(TjiddeLoggerConfiguration config, string? text) { if (text is null || !config.Options.EnableSensitiveDataMasking) return text; @@ -413,7 +414,7 @@ private static string SafeExceptionMessage(Exception exception) private static string Unserializable(object value) => $"[unserializable: {value.GetType().FullName}]"; - private string RenderMessage( + private static string RenderMessage( TjiddeLoggerConfiguration config, TState state, Exception? exception, @@ -446,7 +447,7 @@ private string RenderMessage( return config.Options.EnableSensitiveDataMasking ? config.Masker.MaskMessage(message) : message; } - private object? SafeMaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, ref List? maskedKeyNames) + private static object? SafeMaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, ref List? maskedKeyNames) { try { @@ -459,7 +460,7 @@ private string RenderMessage( } } - private object? MaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, ref List? maskedKeyNames) + private static object? MaskPropertyValue(TjiddeLoggerConfiguration config, string key, object? value, ref List? maskedKeyNames) { if (!config.Options.EnableSensitiveDataMasking) return value; @@ -474,7 +475,7 @@ private string RenderMessage( return value is string text ? config.Masker.MaskMessage(text) : value; } - private string FormatException(TjiddeLoggerConfiguration config, Exception exception) + private static string FormatException(TjiddeLoggerConfiguration config, Exception exception) { string formatted; try @@ -841,7 +842,7 @@ private static string SerializeJsonLine(in JsonLine line) /// throwing getter, too deep) only affects itself: it falls back to ToString(), or to /// [unserializable: TypeName] when that throws too. /// - private object? ToSerializableValue(TjiddeLoggerConfiguration config, object? value) + private static object? ToSerializableValue(TjiddeLoggerConfiguration config, object? value) { // Values that serialize to themselves are written directly, which gives the same JSON as their element. // Strings with surrogates go through the serializer, whose round trip may change unpaired surrogates. @@ -893,7 +894,13 @@ private static void AppendLogLine( string? customer, string message) { - sb.Append($"{timestamp:yyyy-MM-dd}: {timestamp:HH:mm:ss}: [").Append(level).Append("] Class=>").Append(className); + sb.Append(timestamp.ToString("yyyy-MM-dd", CultureInfo.CurrentCulture)) + .Append(": ") + .Append(timestamp.ToString("HH:mm:ss", CultureInfo.CurrentCulture)) + .Append(": [") + .Append(level) + .Append("] Class=>") + .Append(className); if (!string.IsNullOrWhiteSpace(methodName)) sb.Append(" Method=>").Append(methodName); diff --git a/src/Tjidde.Logging/Tjidde.Logging.csproj b/src/Tjidde.Logging/Tjidde.Logging.csproj index 8308e68..bd0cc11 100644 --- a/src/Tjidde.Logging/Tjidde.Logging.csproj +++ b/src/Tjidde.Logging/Tjidde.Logging.csproj @@ -6,7 +6,8 @@ latest enable true - false + false + latest-recommended Tjidde.Logging diff --git a/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs b/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs index 3da5aeb..6fc4b78 100644 --- a/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs +++ b/tests/Tjidde.Logging.Tests/SharedMaskerTests.cs @@ -38,7 +38,7 @@ public void Loggers_ShareOneMaskerPerConfiguration_AndSwitchTogetherOnOptionsCha [Theory] [InlineData(true)] [InlineData(false)] - public void ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows(bool useGlobalContext) + public async Task ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows(bool useGlobalContext) { var id = Guid.NewGuid().ToString("N"); var permanentKey = $"perm-{id}"; @@ -88,16 +88,16 @@ public void ConcurrentLogging_WhileMaskedKeysChange_KeepsMaskingAndNeverThrows(b try { - Task.WaitAll(loggers); + await Task.WhenAll(loggers); } - catch (AggregateException ex) + catch { - errors.AddRange(ex.InnerExceptions); + errors.AddRange(loggers.Where(task => task.Exception is not null).SelectMany(task => task.Exception!.InnerExceptions)); } finally { stop.Cancel(); - churn.Wait(); + await churn; } } } From 19e41a3e8c8d6478880e94ccb5a6f163bb06a6ae Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 16:13:36 +0200 Subject: [PATCH 27/28] Align documentation with current output Co-Authored-By: Codex --- README.md | 20 ++++++++------------ docs/wiki-en.md | 14 +++++++------- docs/wiki-nl.md | 14 +++++++------- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index e4df770..5d61511 100644 --- a/README.md +++ b/README.md @@ -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. @@ -35,7 +38,7 @@ dotnet add package Tjidde.Logging Or via PackageReference: ```xml - + ``` --- @@ -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(); @@ -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 @@ -179,14 +182,7 @@ 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] ``` --- @@ -290,7 +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. -- **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. --- diff --git a/docs/wiki-en.md b/docs/wiki-en.md index e1d9fed..455b9e4 100644 --- a/docs/wiki-en.md +++ b/docs/wiki-en.md @@ -43,7 +43,7 @@ dotnet add package Tjidde.Logging Or add it manually to your `.csproj`: ```xml - + ``` --- @@ -111,15 +111,15 @@ public class OrderService Every log entry is written as a **single line** in the following format: ``` -YYYY-MM-DD: HH:mm:ss: [LEVEL] CS=>ClassName Method=>MethodName: Client=>Customer: Message [Masked: key1, key2] | Exception: [...] | Scopes: scope1 > scope2 +YYYY-MM-DD: HH:mm:ss: [LEVEL] Class=>ClassName Method=>MethodName: Client=>Customer: Message [Masked: key1, key2] | Exception: [...] | Scopes: scope1 > scope2 ``` ### Example output ``` -2026-03-18: 09:15:41: [INFORMATION] CS=>OrderService Method=>PlaceOrder: Client=>AcmeCorp: Order 42 placed successfully -2026-03-18: 09:15:42: [ERROR] CS=>PaymentService Method=>ProcessPayment: Client=>AcmeCorp: Payment failed | Exception: [System.InvalidOperationException: Gateway timeout] -2026-03-18: 09:15:43: [WARNING] CS=>AuthService Method=>Login: Login with password [REDACTED] +2026-03-18: 09:15:41: [INFORMATION] Class=>OrderService Method=>PlaceOrder: Client=>AcmeCorp: Order 42 placed successfully +2026-03-18: 09:15:42: [ERROR] Class=>PaymentService Method=>ProcessPayment: Client=>AcmeCorp: Payment failed | Exception: [System.InvalidOperationException: Gateway timeout] +2026-03-18: 09:15:43: [WARNING] Class=>AuthService Method=>Login: Login with password [REDACTED] ``` ### Log levels @@ -377,7 +377,7 @@ CustomerContext.Set("AcmeCorp"); // All log entries from this point forward will include "AcmeCorp:" in the output. _logger.LogInformation("Processing started"); -// → 2026-03-18: 09:00:00: [INFORMATION] MyService: AcmeCorp: Processing started +// → 2026-03-18: 09:00:00: [INFORMATION] Class=>MyService: Client=>AcmeCorp: Processing started ``` ### Clearing the context @@ -570,7 +570,7 @@ _logger.LogMetrics(new EventId(200), "throughput_rps={Rps}", rps); ### Example output ``` -2026-03-18: 11:00:00: [METRICS] OrderService ProcessOrder: AcmeCorp: requests_total=42 +2026-03-18: 11:00:00: [METRICS] Class=>OrderService Method=>ProcessOrder: Client=>AcmeCorp: requests_total=42 ``` The `[METRICS]` label is printed in **Magenta** in the console for easy visual distinction. diff --git a/docs/wiki-nl.md b/docs/wiki-nl.md index f4728f9..b862961 100644 --- a/docs/wiki-nl.md +++ b/docs/wiki-nl.md @@ -43,7 +43,7 @@ dotnet add package Tjidde.Logging Of voeg het handmatig toe aan uw `.csproj`: ```xml - + ``` --- @@ -111,15 +111,15 @@ public class OrderService Elke logmelding wordt als **één regel** geschreven in het volgende formaat: ``` -YYYY-MM-DD: HH:mm:ss: [NIVEAU] CS=>KlasseNaam Method=>MethodeNaam: Client=>Klant: Bericht [Masked: sleutel1, sleutel2] | Exception: [...] | Scopes: scope1 > scope2 +YYYY-MM-DD: HH:mm:ss: [NIVEAU] Class=>KlasseNaam Method=>MethodeNaam: Client=>Klant: Bericht [Masked: sleutel1, sleutel2] | Exception: [...] | Scopes: scope1 > scope2 ``` ### Voorbeelduitvoer ``` -2026-03-18: 09:15:41: [INFORMATION] CS=>OrderService Method=>PlaceOrder: Client=>AcmeCorp: Bestelling 42 succesvol geplaatst -2026-03-18: 09:15:42: [ERROR] CS=>PaymentService Method=>ProcessPayment: Client=>AcmeCorp: Betaling mislukt | Exception: [System.InvalidOperationException: Gateway timeout] -2026-03-18: 09:15:43: [WARNING] CS=>AuthService Method=>Login: Inloggen met password [REDACTED] +2026-03-18: 09:15:41: [INFORMATION] Class=>OrderService Method=>PlaceOrder: Client=>AcmeCorp: Bestelling 42 succesvol geplaatst +2026-03-18: 09:15:42: [ERROR] Class=>PaymentService Method=>ProcessPayment: Client=>AcmeCorp: Betaling mislukt | Exception: [System.InvalidOperationException: Gateway timeout] +2026-03-18: 09:15:43: [WARNING] Class=>AuthService Method=>Login: Inloggen met password [REDACTED] ``` ### Logniveaus @@ -377,7 +377,7 @@ CustomerContext.Set("AcmeCorp"); // Alle logmeldingen vanaf dit punt bevatten "AcmeCorp:" in de uitvoer. _logger.LogInformation("Verwerking gestart"); -// → 2026-03-18: 09:00:00: [INFORMATION] MyService: AcmeCorp: Verwerking gestart +// → 2026-03-18: 09:00:00: [INFORMATION] Class=>MyService: Client=>AcmeCorp: Verwerking gestart ``` ### Context wissen @@ -570,7 +570,7 @@ _logger.LogMetrics(new EventId(200), "throughput_rps={Rps}", rps); ### Voorbeelduitvoer ``` -2026-03-18: 11:00:00: [METRICS] OrderService ProcessOrder: AcmeCorp: requests_total=42 +2026-03-18: 11:00:00: [METRICS] Class=>OrderService Method=>ProcessOrder: Client=>AcmeCorp: requests_total=42 ``` Het label `[METRICS]` wordt in **Magenta** weergegeven in de console voor eenvoudige visuele herkenning. From 82d0109779994f2233225fadf667234c30e11703 Mon Sep 17 00:00:00 2001 From: Tjidde_NL Date: Sat, 3 Oct 2026 22:44:27 +0200 Subject: [PATCH 28/28] Ignore local Claude Code worktrees Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8963d3c..3a544f6 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ desktop.ini # BenchmarkDotNet results BenchmarkDotNet.Artifacts/ + +# Claude Code worktrees and local state +.claude/