Changelog
All notable changes to Nilog are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
Nothing yet.
[1.0.6] - 2026-10-08
Audit-remediation release. Every item below is covered by a regression test or an external harness; the core suite (252 tests) and analyzer suite (66 tests) pass on net8.0, net9.0 and net10.0.
🐛 Fixed
- F-001
WriteError/WriteCritical/Logwith a derived exception type or extra arguments on C# ≤ 12 bound to the generic overload and lost the exception. Runtime and generated overloads now attach a leadingException. Verified in a 6-configuration consumer matrix. - F-004 / F-005
NILOG001code fix: arguments are inserted in the correct position (after the exception argument where present), brace escaping no longer doubles braces, and the fix is withheld where it cannot be proven semantics-preserving. - F-006 A throwing
ToString()/ISpanFormattableon a typed argument no longer escapes the log call; it renders as[ToString failed: Type threw Ex]. - F-007
FlushAsynchonours cancellation, preserves earlier failures in anAggregateExceptionand observes abandoned callbacks. - F-008 The template cache is bounded by entry count and by template length. New public API:
MaxCachedTemplateLength,TemplateCacheCount,ClearTemplateCache(). - F-009 Exception reports are bounded (message, depth, node and total length), single-line in
compact mode, list all
AggregateExceptioninners, and a throwing userExceptionFormatterfalls back to the built-in report. - F-010 Dictionary/
IEnumerablescopes validate keys (ArgumentExceptionfor null/blank) and treatCountas a hint only. - F-013 Null arguments render as
(null), matching Microsoft.Extensions.Logging. SmallScopeWrapperindexer threwIndexOutOfRangeExceptionfor an out-of-range index; it now throwsArgumentOutOfRangeException(found by coverage analysis).
📚 Documentation
- Benchmark tables replaced with v1.0.6 measurements against conventional MEL extensions,
LoggerMessage.Defineand[LoggerMessage]; the earlier “240x faster” style comparisons (conventional extension only) are retired. Nilog is equal to the optimized APIs on the disabled path and not faster on the enabled path. - Removed the unsupported “full parity with SerilogAnalyzer” claim from the README, NuGet readme and analyzer package description.
- F-003 The v1.0.5 “0 B under parallel load” claim could not be reproduced and is withdrawn. Re-measured: BenchmarkDotNet MediumRun 3.82 MB (Nilog) vs 5.34 MB (Microsoft); process-wide probe 80 B vs 112 B per 1-arg call. The enabled path allocates the rendered string.
- F-002 Documented that statically
object-typed arguments bind toparamsand allocate. - F-011 / F-014 Replaced “never throws” and “immutable static state” wording; documented the
raw-template fallback (Nilog) versus
FormatException(MEL) and theobject[]-path ownership.
🧪 Tests & tooling
- New regression suites (exception binding, cache bounds, flush, exception report, formatting robustness, scope validation, allocation characterization, code-fix behaviour).
Nilog.Benchmark --parallel-allocprocess-wide allocation probe.- Coverage-gap tests (high-arity states, scope wrappers) and additional NILOG002 analyzer cases;
NILOG002description corrected for theparams/MEL path. - Line coverage measured at 51% (branch 45%) before the gap tests; see the Phase 2 coverage report.
🔒 CI / supply chain (F-012)
- Actions pinned to commit SHAs, read-only default permissions, tag/version values passed via environment variables and validated, analyzer tests added to CI.
- Added
global.json, NuGet lock files (--locked-modein CI/release),SECURITY.mdand a CycloneDX SBOM procedure; removed a duplicated nested workflow directory. dotnet list package --vulnerable --include-transitive: 0 advisories in all projects.- Not done: package signing and provenance attestation (require credentials; nothing was published).
📦 Version
- Package and analyzer version bumped to 1.0.6.
[1.0.5] - 2026-10-07
This is a hardening/audit release: no new public APIs, no behavioural redesign. It re-validates the v1.0.4 implementation end-to-end (architecture, allocation, memory, concurrency, template cache, scopes, exceptions, flush/lifecycle, analyzers, packaging) and corrects the documentation drift that audit uncovered.
⚙️ Configuration & usability
- Clarified that zero Nilog-specific configuration was already optional —
README.md’s “Global configuration” section implied thatNiloggersettings were something applications needed to set up. No runtime configuration requirement was removed (there never was a required one); the section was renamed and reworded to say so explicitly — see “Documentation” below. FlushAsyncwording corrected —README.mdpreviously describedFlushAsyncin one place as “a deliberate no-op placeholder” for a hypothetical future buffering sink, which contradicted the realRegisterFlush/UnregisterFlush-backed flush implementation shipped since v1.0.3 and documented correctly elsewhere in the same file. Reworded consistently everywhere:FlushAsyncperforms a real, in-order await of every callback registered viaRegisterFlush, and remains a zero-allocation no-op only when nothing has been registered.ShutdownUtcTimerwording corrected — the configuration table previously said it “stops the background timestamp-cache timer,” which no longer matches the implementation: the UTC timestamp cache has used lazy, on-read refresh with no persistent backgroundTimersince v1.0.2 (see that release’s changelog entry). Reworded to state plainly that no background timer exists, that the method forces one final refresh for deterministic teardown, and that it is kept only for source/binary compatibility — not something normal applications need to call.- Separated MEL configuration from Nilog-specific hooks — the renamed “Optional advanced
configuration” section now shows standard
Microsoft.Extensions.Logginglevel/filter configuration (SetMinimumLevel,AddFilter,AddConsole) as belonging to MEL, distinct from the handful of optional Nilog hooks (ExceptionFormatter,MaxTemplateCacheEntries,UseAsyncSinkProvider/AsyncSinkFilter,RegisterFlush/UnregisterFlush/FlushAsync,ShutdownUtcTimer), none of which are required for normal use.
📝 Documentation
- Stale test counts corrected — the project table in
README.mdreportedNilog.Testsas “188 tests” andNilog.Analyzers.Testsas “22 tests”; a fulldotnet test -c Releaserun across net8.0/net9.0/net10.0 shows the actual, current counts are 179 and 31 respectively (matching the “210 passing” badge, which was already correct). Updated to match. - Current-version references bumped to 1.0.5 — the NuGet badge and the
PackageReferenceinstall examples forNilogandNilog.AnalyzersinREADME.mdandNilog/README.nuget.mdnow point at1.0.5. Historical “(v1.0.4)”/”NEW in v1.0.4” annotations that document when a specific feature (16-arg typed overloads, typed multi-pair scopes, compact exception report, etc.) was introduced are left untouched — they remain historically correct. - Benchmark provenance clarified — the benchmarks section states explicitly which numbers were freshly measured and when. All published numbers are a fresh BenchmarkDotNet run against the final compiled v1.0.5 runtime code (21 benchmark classes, measured 2026-10-07; see “Final runtime validation” below) — no v1.0.4 numbers are presented as current without being re-run, since this release does not modify any of the measured hot paths but the figures were re-verified rather than merely carried forward.
- “Global configuration” renamed to “Optional advanced configuration” — the section heading,
table of contents entry, and in-text anchors in
README.mdwere renamed and the section rewritten to lead with an explicit “no Nilog-specific configuration or DI registration is required for normal use” callout, with MEL configuration (SetMinimumLevel,AddFilter,AddConsole, …) and optional Nilog hooks now presented in clearly separated, explicitly optional subsections. - Stale enabled-path allocation/time percentages corrected — several places in
README.mdandNilog/README.nuget.md(the before/after table, the FAQ, and the API-walkthrough text) still quoted an earlier approximation (“25–32% less”, “37% faster on the 9-arg enabled path”, “~6 ns” for the 0-arg enabled path). Replaced with the figures that actually match the current benchmark tables in the same documents: 26–29% less allocation across 2–8 typed args, 72% faster / ~4.1 ns for the 0-arg enabled path, and 12% faster at 9 args (where 368 B of boxing is unavoidable on both sides). Nilog.Teststest-count corrected — thedotnet testcomment inREADME.mdstill said “188 core tests + 22 analyzer tests = 210 total”, left over from before the “Stale test counts corrected” fix above was applied everywhere; updated to 179 core + 31 analyzer = 210 unique tests, and clarified that the 630 figure elsewhere in the same document is the count of target-framework executions (210 unique tests × 3 TFMs), not a distinct, larger test count.Nilog/README.nuget.mdrestructured to match the documented consumer-facing outline: added a short “What Nilog is” lead-in, an explicit “Configuration” section stating no Nilog-specific configuration or DI registration is required for normal use (with the standard MEL filter/level example kept separate from the optional Nilog hooks), a “Structured logging” section showing the rendered message / named properties /{OriginalFormat}contract, and a “Provider compatibility” section that explains compatibility is throughMicrosoft.Extensions.Loggingand explicitly does not claim direct MongoDB/Elasticsearch/ SQL/Oracle integrations.
🧪 Verification performed
dotnet build -c Release— solution builds clean (0 warnings, 0 errors) across net8.0/net9.0/net10.0.dotnet test -c Release— 179/179Nilog.Testsand 31/31Nilog.Analyzers.Testspass on every supported target framework (630 total test executions, 0 failed, 0 skipped).- Re-reviewed the template cache (bounded
ConcurrentDictionary, warn-once-and-stop-caching behaviour atMaxTemplateCacheEntries), scope wrappers,FlushAsync/RegisterFlushlifecycle, the UTC timestamp cache (lazy refresh, no background timer), and the Roslyn analyzer/code-fix project. No correctness, concurrency, or memory-retention regressions were found relative to the behaviour already shipped and tested in v1.0.4.
📦 Versioning
Nilog(Nilog.Core) andNilog.Analyzerspackage versions bumped from1.0.4to1.0.5.
📊 Final runtime validation (independent pass, same 1.0.5 release)
A second, independent validation pass was performed against the same final v1.0.5 codebase to confirm the production-readiness claims above with real builds, publishes, and provider integrations rather than code inspection alone. The version was not changed.
- Fresh BenchmarkDotNet run — re-ran the complete, current suite of 21 benchmark classes
(not 20;
ParallelBenchmarks,StressBenchmarks, andAllocationStressBenchmarkswere previously undercounted) against the compiled v1.0.5 runtime on 2026-10-07. Confirmed 0 B disabled-path allocation for typed 0–9 args, typed 2/3-pair scope allocation parity with a single-key scope (24 B, no dictionary), and the documented template-cache warm/cold-parse speedup (345× time, 19.3× allocation).README.md/Nilog/README.nuget.mdbenchmark tables are updated with these fresh numbers (see “Documentation” below). ParallelBenchmarksmethodology correction — an initialShortRun(3-iteration) pass showed Nilog allocating more than Microsoft under parallel load (8.6 MB vs 5.34 MB). This was investigated and found to be pure statistical noise (StdErr ≈120% of the mean with only 3 samples), not a real regression: aMediumRunrerun (30 samples) shows Nilog allocating 0 B vs Microsoft’s 5.6 MB, with equal mean time. No runtime code was changed; this is a benchmarking methodology finding, recorded here for transparency.- Native AOT — published a minimal real consumer with
PublishAot=trueon net10.0: zero Nilog-related AOT/trim warnings, the published native binary runs correctly and structured logging/exception logging work as expected. - Trimming — published the same consumer with
PublishTrimmed=true(no AOT): zero Nilog trim warnings, logging/templates/scopes/exceptions all verified at runtime. - Real Serilog integration — exercised
Nilogextension methods throughMicrosoft.Extensions.Logginginto the actualSerilog.Extensions.Loggingprovider andSerilog.Sinks.InMemory; verified level, rendered message, structured properties,{OriginalFormat}, exception, and scope state all arrive correctly at the sink. - Real OpenTelemetry integration — exercised the same call surface through the actual
OpenTelemetry.Extensions.Loggingprovider withOpenTelemetry.Exporter.InMemory; verified body, severity, structured attributes, and exception information. - Live ASP.NET Core integration — a minimal web host handling 50 concurrent HTTP requests, each with a request-correlation scope, verified no scope/property leakage across concurrent requests.
- Live Worker Service integration — a real
BackgroundServicerunning a dequeue/process loop with scopedItemId, periodic error logging, and gracefulStopAsync/host shutdown; confirmed no lifecycle exceptions, no callback leak, and clean shutdown. - Azure Functions (
Nilog.Function) — rebuilt fresh in Release with 0 warnings/errors.func(Azure Functions Core Tools) was not available in this environment, so this is build-tested only, not live-host tested; stated explicitly rather than implied. - Package-consumer validation — ran
dotnet pack -c ReleaseforNilogandNilog.Analyzers, installed the real generated.nupkgs (not project references) into a separate external console project via a local NuGet feed, and confirmed restore, compile, runtime extension-method behaviour, and that theNILOG002analyzer diagnostic fires correctly from the installed analyzer package. - NuGet package content inspection — confirmed both
.nupkgs contain only the expected assets (lib DLLs + XML docs per TFM, analyzer DLL, README, nuspec) with no test/benchmark/obj leakage. - Template cache stress test — verified the cache grows to exactly the configured limit (10,000 entries) and stays bounded there even when 100,000–1,000,000 unique templates are logged; logging continues to work correctly after saturation, including under 50,000 concurrent duplicate-template insertions.
- Concurrency stress test — 1/2/4/8/16/32 worker counts (20,000 calls each: cache hits, distinct templates, scopes, periodic exceptions) plus 16 independent logger categories × 10,000 calls each: zero exceptions, no corruption, in every run.
- Long-running memory soak — 3,000,000 mixed log operations (repeated/unique templates, disabled logging, scopes, exceptions) with checkpoints every 500,000 ops: post-forced-GC memory at the end was below the measured baseline (no retained-memory growth); Gen0/Gen1/Gen2 collection counts were modest (155/6/3) for the full run.
- Binary compatibility — used Microsoft’s official
Microsoft.DotNet.ApiCompat.Toolto compare the real, previously-publishedNilog 1.0.4package (downloaded from nuget.org) against the newly built1.0.5assemblies on all three target frameworks (net8.0/net9.0/net10.0): no breaking changes found. - Source compatibility — compiled a representative v1.0.4-era usage sample (all log levels,
typed overloads,
paramsfallback, single/typed/dictionary scopes, exception overloads,ExceptionFormattercustomization,MaxTemplateCacheEntries, flush register/unregister/FlushAsync, and staticNilogger.Log) unchanged against v1.0.5: compiled with 0 warnings/errors and ran correctly. - Result: no new Nilog runtime defects were found during this validation pass. Every anomaly
investigated (the
ParallelBenchmarksallocation discrepancy, an early stress-harness showing “0 cached templates”) traced back to a test-harness/benchmark-methodology issue, not a Nilog defect, and was corrected before drawing conclusions.
1.0.4 - 2026-06-22
Added
-
Typed overloads extended to 16 arguments — the source generator (
Nilog.SourceGenerators) now emits zero-arrayWrite*/Nilogger.Logoverloads for 6–16 arguments (raised from 6–8 in v1.0.3), lifting the typed ceiling from 8 to 16. A nine-argument call such asWriteInformation("{A}…{I}", 1…9)now binds toWriteInformation<T0…T8>instead of falling back toparams object[]— so the disabled path allocates 0 bytes and the enabled path carries no array. Measured: 9-arg disabled 0.45 ns / 0 B vs Microsoft 211 ns / 368 B (≈ 469× faster); 9-arg enabled 156 ns / 368 B vs Microsoft 246 ns / 368 B (37% faster — boxing is unavoidable on the enabled path; the struct itself adds nothing). - Typed multi-pair scope overloads — three new
WriteScopeoverloads that eliminate the dictionary allocation for the most common scope shapes:WriteScope<T1,T2>(key1, val1, key2, val2)— backed by a stack-allocatedTwoScopestructWriteScope<T1,T2,T3>(key1, val1, key2, val2, key3, val3)— backed byThreeScopeWriteScope<T1,T2,T3,T4>(k1,v1, k2,v2, k3,v3, k4,v4)— backed byFourScope
All three surface a scope compatible with the standard MEL
ILoggerProviderpipeline. The underlying readonly structs require no array copy — values are boxed only once each, the unavoidable minimum forILoggerFactoryinterop. -
Compact exception report (
moreDetailsEnabled: false) —WriteErrorExceptionandWriteCriticalExceptionwithmoreDetailsEnabled: falsenow render a compact single-line summary ([Title] Type: Message (Source=…, HResult=…)) that allocates < 300 bytes per call, down from ≈ 992 bytes in v1.0.3. The verbose multi-line report (moreDetailsEnabled: true) is unchanged. Guarded by a new allocation gate test. -
ExceptionBasicReport_AllocatesBelow300Bytesallocation gate — a new test inAllocationGateTestsasserts that a singleWriteErrorException(ex, …, moreDetailsEnabled: false)call allocates fewer than 300 bytes (after JIT warmup), using aCaptureLoggerinner class that does not allocate duringLog. Runs in CI Release to catch any regression. -
DisabledPath_NineTypedArgs_AllocatesZeroBytestest — added toAllocationGateTeststo assert that a 9-typed-arg disabled call allocates exactly0L, covering the newly typed range. -
Typed scope unit tests —
TypedTwoPairScope_HasExpectedEntries,TypedThreePairScope_HasExpectedEntries, andTypedFourPairScope_HasExpectedEntriesadded toScopeTests, verifying key/value ordering, counts, and correct enumeration. - Benchmark additions and improvements:
TwoArgBenchmarks— newEnabled (int+int)category proves the 2-arg enabled path is 34% faster than Microsoft when types match (46 ns vs 70 ns); the int+decimal delta (62 ns) is explained by decimal boxing being 24 B vs 16 B for int — the code path is identical.HighArityExtendedBenchmarks— updated benchmark descriptions from “params” to “typed” to reflect the v1.0.4 source-generator change; confirms 9-arg disabled = 0.45 ns / 0 B.TemplateCacheBenchmarks— benchmarks the per-thread single-slot cache hit (WarmCache) and the fullConcurrentDictionarymiss path (ColdParse).TypedScopeBenchmarks— compares single-pair, typed 2-pair vs dict 2-pair, and typed 3-pair.ValueVsReferenceArgBenchmarks— compares int, string, and mixed argument boxing cost.- Debugger guard in
Nilog.Benchmark/Program.cs— aborts with a clear message if a managed debugger is attached, preventing benchmarks from running under the debugger and producing misleadingly slow numbers.
Changed
Nilog.Demoupdated:- Section 3b comment corrected from “Nine or more values — the familiar params path” to “Nine
values — still typed and zero-allocation (source-generated, 6–16 args)”, reflecting that the
source generator now covers 1–16 args and a 9-arg call binds to
WriteInformation<T0…T8>. - Section 8 (scopes) updated to showcase the new typed
WriteScope<T1,T2/T3/T4>overloads with 2-pair, 3-pair, and 4-pair examples alongside theIReadOnlyDictionaryfallback.
- Section 3b comment corrected from “Nine or more values — the familiar params path” to “Nine
values — still typed and zero-allocation (source-generated, 6–16 args)”, reflecting that the
source generator now covers 1–16 args and a 9-arg call binds to
-
Nilog.Functionupdated — the per-request 3-entry dictionary scope inOrdersFunctionreplaced withWriteScope("OrderId", orderId, "CustomerId", request.CustomerId, "Currency", request.Currency)(typedWriteScope<T1,T2,T3>), eliminating the dictionary allocation for the correlation scope that wraps every checkout invocation. Nilog.Demo,Nilog.Function, andNilog.Benchmarkupdated to reflect v1.0.4 changes.
Performance
Measured with BenchmarkDotNet (ShortRun: 3 warmup + 3 measurement, Server GC), .NET 10.0, Intel Core i7-13850HX:
| Path | v1.0.3 | v1.0.4 | Δ |
|---|---|---|---|
9-arg disabled — WriteDebug("{A}…{I}", 1…9) |
params, 211 ns / 368 B (≈ Microsoft) | 0.45 ns / 0 B | ≈ 469× faster, zero alloc |
9-arg enabled — WriteInformation("{A}…{I}", 1…9) |
params, 246 ns / 368 B (≈ Microsoft) | 156 ns / 368 B | 37% faster (boxing is unavoidable on the enabled path; no array overhead added) |
| 5-arg enabled | 77.70 ns / 160 B | 77.70 ns / 160 B | unchanged — confirmed < 140 ns target ✅ |
| 2-arg enabled (int+int) | n/a | 46.27 ns / 96 B | 34% faster than Microsoft (70 ns / 136 B) |
Compact exception report (basic, moreDetailsEnabled: false) |
≈ 992 B | < 300 B | > 3× less allocation per report |
The 0–8-arg paths are unchanged from v1.0.3.
1.0.3 - 2026-06-19
Added
-
Typed six-, seven-, and eight-argument overloads — a new build-time source generator (
Nilog.SourceGenerators) emits zero-arrayWriteTrace/WriteDebug/WriteInformation/WriteWarning/WriteError/WriteCriticalandNilogger.Logoverloads for 6–8 arguments, lifting the typed ceiling from 5 to 8. They are generated directly intoNilog.dll, so consumers pick them up with no extra reference. The disabled path allocates 0 bytes for up to 8 typed arguments — asserted as exactly0LbyHighArityTests.DisabledPath_SixTypedArgs_AllocatesZeroBytes/…EightTypedArgs…and confirmed by BenchmarkDotNet (8-arg disabled: 0.82 ns / 0 B vs Microsoft 221 ns / 336 B). 6+ arguments previously fell back toparams object[]; that boundary is now 9+. - Seven new
Nilog.Analyzersrules (1 → 8 total — full parity with the SerilogAnalyzer set):- NILOG002 — the
{Placeholder}count in a constant template does not match the number of arguments supplied. - NILOG003 — the template is built with string concatenation (
"a" + b) orstring.Format(...), which defeats the template cache and loses named properties. - NILOG004 — the same named
{Placeholder}appears more than once (silently collides on one structured-property key); numeric/positional{0} {0}reuse is intentionally allowed. - NILOG005 (Info) — positional
{0}placeholders instead of named{Name}ones. - NILOG006 — an
Exceptionpassed as a template value instead of the exception parameter, which loses its type/message/stack as structured data. - NILOG007 — a malformed template: an unclosed
{or an empty{}placeholder. - NILOG008 (Info) — a placeholder name that is not PascalCase (
{userId}→{UserId}).
- NILOG002 — the
-
Code fix for NILOG001 — a one-click “Convert to a literal template with arguments” refactoring that rewrites
logger.WriteInformation($"User {id}")intologger.WriteInformation("User {id}", id), preserving:format/,alignmentclauses and appending the extracted expressions at the correct trailing position for every call shape. -
Nilog.Analyzersnow ships as a standalone NuGet package (analyzers/dotnet/cs), development-dependency only, so it never adds a runtime dependency to consumers. -
Real
FlushAsyncviaNilogger.RegisterFlush(Func<CancellationToken, Task>)/UnregisterFlush(...). Buffering/batching sinks register how to drain themselves andFlushAsyncawaits them all (every callback is attempted; failures surface together as anAggregateException). With nothing registered it stays a zero-allocation no-op, so existing callers are unaffected — turning the long-standing “FlushAsync is a no-op” limitation into a working flush. -
LoggingEngineInteropTests— end-to-end tests that run Nilog through the realMicrosoft.Extensions.LoggingLoggerFactory+ILoggerProviderpipeline (the exact contract every third-party engine integrates through), asserting the rendered message,{OriginalFormat}, named properties, exceptions, and level-filtering all survive intact. - Allocation regression gate — a consolidated
AllocationGateTestssuite (1–8 typed args and the staticLogpath, all asserting0Lon the disabled path) plus a GitHub Actions workflow (.github/workflows/ci.yml) that builds Release and runs the tests on every push/PR.
Changed
-
TemplateFormatter.Rendernow covers up to 8 arguments, so the generated 6–8 arg overloads render through the same stack-allocatedSpan<char>path as 1–5 args instead of building anobject?[]inToString(). Enabled-path effect vs Microsoft: 6-arg 264 B → 192 B (~27% less, ~44% faster) and 8-arg 336 B → 248 B (~26% less, ~50% faster).Format(params object?[])remains only as the format-specifier/overflow fallback. -
Niloggeris now apartialclass so the generated overloads compile into the same type. -
Native AOT / trimming is now compiler-enforced via
<IsAotCompatible>true</IsAotCompatible>onNilog.Core: the trim, single-file, and AOT analyzers run on every build and, withTreatWarningsAsErrorsin Release, fail the build on any unsafe construct. The Native AOT compiler emits native code fromNilog.dllwith zero warnings. -
Nilog.Demo,Nilog.Function, andNilog.Benchmarkupdated to exercise 6–8 typed args, the 9-argumentparamsescape hatch, realFlushAsync/RegisterFlush, and the four analyzer rules. Package/product version bumped to 1.0.3.
Fixed
-
Nilogger.Logwith 5–8 args silently allocated. The typedLog<T0..Tn>overloads carried[OverloadResolutionPriority(-1)], which let theparams object[]overload win for a normal (no-exception) call — so the staticLogAPI allocated an array, contradicting the documented zero-array guarantee. Fixed by promoting the exception overload (Log(ILogger, LogLevel, string, Exception, params object[])→ priority 1) instead of demoting the typed ones: a trailingExceptionstill binds correctly (regression-tested), while a plain typed call now binds to the zero-array overload. Discovered by the new allocation gate. -
Trimming/AOT hazard removed. Enabling the AOT analyzers surfaced two uses of
Exception.TargetSite([RequiresUnreferencedCode]) in the exception formatter. TheTarget Siteline — redundant with the stack trace — was dropped, making the library genuinely trim/AOT-clean. (The only behavioural change is one fewer line in the exception report text.)
Performance
Measured with BenchmarkDotNet (ShortRun: 3 warmup + 3 measurement, Server GC), .NET 10.0, Intel Core i7-13850HX:
| Path | v1.0.2 | v1.0.3 | Δ |
|---|---|---|---|
8-arg disabled — WriteInformation("{A}…{H}", …) |
params, 221 ns / 336 B (≈ Microsoft) | 0.82 ns / 0 B | ~268× faster, zero alloc |
6-arg enabled — WriteInformation("{A}…{F}", …) |
params, 180 ns / 264 B (≈ Microsoft) | 100.6 ns / 192 B | ~44% faster, ~27% less alloc |
8-arg enabled — WriteInformation("{A}…{H}", …) |
params, 233 ns / 336 B (≈ Microsoft) | 117.0 ns / 248 B | ~50% faster, ~26% less alloc |
Static Nilogger.Log, 5–8 typed args, disabled |
params array (allocated) | 0 B | bug fix — now zero-array |
The 0–5-arg paths are unchanged from v1.0.2 (already correct).
1.0.2 - 2026-06-16
Added
-
Typed five-argument overloads (
WriteTrace,WriteDebug,WriteInformation,WriteWarning,WriteError,WriteCritical, andNilogger.Log— all now accept a<T0, T1, T2, T3, T4>form). The zero-array, zero-boxing disabled path now covers 0–5 typed arguments, not just 0–4. A disabled 5-arg call now allocates 0 bytes (previously 184 B for theparams object[]the compiler built beforeIsEnabledever ran); an enabled 5-arg call is 39% faster and 29% less allocation than the equivalent Microsoft call.Internally this required:
- A new
LogState<T0, T1, T2, T3, T4>readonly struct (indices 0–4 = arguments, index 5 ={OriginalFormat}), mirroring the existing 0–4-arg structs. - A new
TemplateFormatter.Format/Renderoverload accepting a fifth argument. - A private
Emit<T0,T1,T2,T3,T4>helper and aLog<T0,T1,T2,T3,T4>static overload. [OverloadResolutionPriority(-1)]on the new no-exceptionWriteError/WriteCritical5-arg overloads and onLog<T0,T1,T2,T3,T4>, so a call with a leadingExceptionargument still binds to the dedicated exception overload instead of the generic one (same guard pattern already used for the 3- and 4-arg no-exception overloads).- 6+ arguments still fall back to
params object[], unchanged.
- A new
-
Nilog.Analyzers— a new, separate Roslyn analyzer package. Ships diagnostic NILOG001: warns when an interpolated string ($"...") is passed as a Nilog message template, since each call then produces a different literal string, missing the template cache and growing it unboundedly, and the interpolated values never become named structured properties. The analyzer is opt-in — it is not referenced byNilog.Core, so existing consumers are unaffected unless they explicitly add it. Covered by 6 new tests inNilog.Analyzers.Tests.
Changed
-
UTC timestamp cache (used by
WriteErrorException/WriteCriticalException) no longer runs a backgroundTimer. Previously aSystem.Threading.Timerfired every millisecond for the entire process lifetime just to keep the cached timestamp fresh — paid even in processes that never log an exception. It now refreshes lazily on read: a reader comparesEnvironment.TickCount64against the last refresh and only reformats if ≥ 1 ms has elapsed. Same effective freshness, zero idle-time cost.ShutdownUtcTimer()keeps its exact signature and idempotent behaviour for compatibility; it now simply forces a final refresh rather than disposing a timer. -
Per-thread single-slot template cache.
GetFormatternow checks, via[ThreadStatic]fields, whether the template string is reference-equal to the last one that thread resolved (true for the common case of the same call site firing repeatedly in a loop, since message templates are almost always interned string literals) before falling through to theConcurrentDictionarylookup. A miss costs nothing extra; a hit skips the dictionary probe entirely. -
Plain-placeholder message rendering now uses a stack-allocated
Span<char>instead ofstring.Format. Templates with no:format/,alignsuffix render through a newTemplateFormatter.Renderpath that writes literal segments andISpanFormattablevalues directly into a 256-char stack buffer, copying out exactly one finalstring— noStringBuilder, no pool, no array. Measured ~12–31% faster with identical allocation on affected templates. Any template with a format specifier, an alignment suffix, an argument-count mismatch, or output that overflows the stack buffer transparently falls back to the original, byte-identicalstring.Formatpath, so no existing template’s rendered output changes. -
Sustained/repeated-template throughput improved as the combined effect of the two changes above: a 100,000-call sequential loop reusing the same template dropped from 5.55 ms to 4.74 ms (now 33% faster than Microsoft, up from 23%), with identical allocation (11.41 MB).
-
Test suite expanded to 144 tests (was 132) across net8.0, net9.0, and net10.0, plus a new
Nilog.Analyzers.Testsproject (6 tests) for the analyzer package. -
Benchmark suite updated (15 classes, was 14) — new
FiveArgBenchmarksclass added for the 5-arg typed path;ParamsPathBenchmarks(the true open-endedparamsescape hatch) bumped from 5 to 6 arguments now that 5 is typed;DisabledAllArgsBenchmarks’s “5 args (params)” category relabelled “5 args (typed)” with a new “6 args (params)” category added;AllocationStressBenchmarksextended with 5-arg disabled/enabled rows for both Nilog and Microsoft, and the stale “new in v1.1” labels removed. -
Nilog.DemoandNilog.Functionupdated to reflect the new 5-arg boundary: stale comments describing 5 arguments as “the params path” were fixed, a 5-arg typed example was added to the demo, andNilog.Functionnow referencesNilog.Analyzersas a build-time analyzer (OutputItemType="Analyzer") as a worked example of wiring it into a real project.
Performance
Benchmarks run on .NET 10.0.8, Intel Core i7-13850HX @ 2.10 GHz, BenchmarkDotNet v0.15.8 (ShortRun: 3 warmup + 3 measurement iterations, Server GC):
| Path | v1.0.1 | v1.0.2 | Δ |
|---|---|---|---|
5-arg disabled — WriteInformation("{A}{B}{C}{D}{E}", …) |
params, 28.40 ns / 184 B | 0.25 ns / 0 B | ~113× faster, zero alloc |
5-arg enabled — WriteInformation("{A}{B}{C}{D}{E}", …) |
params, ~125 ns / 224 B (≈ Microsoft) | 78.15 ns / 160 B | 39% faster, 29% less alloc than Microsoft |
Plain {Id} template render (warm cache) |
37.12 ns / 80 B | 32.78 ns / 80 B | ~12% faster, same alloc |
| Escaped braces + placeholder render | 46.83 ns / 96 B | 32.49 ns / 96 B | ~31% faster, same alloc |
| 100,000-call sequential loop (same template) | 5.55 ms / 11.41 MB | 4.74 ms / 11.41 MB | ~15% faster, same alloc |
| 10,000-call 4-arg enabled loop | 1,216 μs | 807 μs | ~34% faster |
| Exception formatting (basic/full reports) | unchanged | unchanged | no regression from removing the background timer |
Format-specifier and alignment templates, and the 0–4-arg disabled path, are unaffected — they were already correct and use the same code as v1.0.1.
1.0.1 - 2026-06-15
Added
-
Typed four-argument overloads (
WriteTrace,WriteDebug,WriteInformation,WriteWarning,WriteError,WriteCritical, andNilogger.Log— all now accept a<T0, T1, T2, T3>form). The zero-array, zero-boxing disabled path now covers 0–4 typed arguments, not just 0–3. A disabled call with four args is 479× faster than the equivalent Microsoft call and allocates 0 bytes; an enabled call is 41% faster with 29% less allocation.Internally this required:
- A new
LogState<T0, T1, T2, T3>readonly struct implementingIReadOnlyList<KeyValuePair<string, object?>>(indices 0–3 = arguments, index 4 ={OriginalFormat}), mirroring the existing 1–3-arg structs. - A new
TemplateFormatter.Format(object, object, object, object)overload. - A private
Emit<T0,T1,T2,T3>helper that boxes arguments only on the enabled path.
- A new
-
Typed no-exception overloads for
WriteErrorandWriteCritical(1–4 arguments).logger.WriteError("Validation failed for {UserId}", userId)now resolves to a zero-allocation strongly-typed overload instead of falling back toparams object[]. Same applies toWriteCritical.Overload resolution is controlled with C# 13’s
[OverloadResolutionPriority]attribute:Overload Priority WriteError(message, Exception)and with-exception typed variants0 (default) WriteError<T0>(message, T0)— typed no-exception-1 WriteError(message, params object[])— params fallback-2 A polyfill for
OverloadResolutionPriorityAttributeis included for .NET 8 targets (the C# 13 compiler recognises it by full name regardless of assembly). -
Nilogger.MaxTemplateCacheEntries— a configurable ceiling on the number of parsed templates held in theConcurrentDictionarycache (default: 10,000). Once the limit is reached, new templates are still parsed correctly on every call but are not stored, so callers who accidentally log interpolated strings (e.g.$"User {id} logged in") can no longer grow the cache without bound. A one-timeDebug.WriteLinewarning fires on the first overflow. The overflow check happens beforeGetOrAdd, so no dictionary entry is created and immediately evicted.// Tighten the limit for memory-constrained environments (optional): Nilogger.MaxTemplateCacheEntries = 1_000; -
FourArgTests.cs— 12 new xUnit tests covering: render correctness for all six levels at four arguments, disabled-path zero-allocation CI regression guard (GC.GetAllocatedBytesForCurrentThreadover 10,000 calls), structured property names and{OriginalFormat}for the 4-arg path, exception attachment forWriteError/WriteCriticalwith and without an exception, andMaxTemplateCacheEntriesoverflow behaviour (still renders correctly beyond the limit).
Fixed
-
No-arg enabled path was 4.8× slower than Microsoft (
WriteInformation("text")and all zero-argument overloads). Root cause: all no-arg log calls were routed throughLoggerMessage.Define<string>(level, id, "{Message}")which internally calledstring.Format("{Message}", message)on every enabled call — copying the string for no reason and allocating 56 B each time. Replaced with the identity formatterstatic (s, _) => s— the same approach Microsoft uses internally. No intermediate string copy, no allocation. -
Exception formatter called
GetType()twice per exception line. BothFormatExceptionMessageInternalandAppendInnerExceptionDetailsevaluatedex.GetType().FullName ?? ex.GetType().Namein a single expression, firing two virtual dispatches whenFullNamewas non-null. Cached to a localTypevariable in both methods. -
ScopeWrapper.ToString()andSmallScopeWrapper.ToString()allocated a freshStringBuilderon every call. Text sinks invokeToString()on each scope per log entry; both classes were doingnew StringBuilder(…)each time. Both now borrow from the process-wide_sbPool(ObjectPool<StringBuilder>) in a try/finally block.
Changed
-
FlushAsyncsimplified to a true no-op. The method now returnsTask.CompletedTaskdirectly — noasynckeyword, no state machine, noTask.Yield, no cancellation check. Since there is no buffered sink today there is nothing to flush, and the overhead ofTask.Yieldwas misleading. Measured cost: ~0.01 ns / 0 B (below BDN noise floor).FlushAsyncCorewas removed. Any code that awaitedFlushAsync()at shutdown continues to work identically; it simply returns synchronously. -
Typed no-exception
WriteError/WriteCriticalparams fallbacks annotated[OverloadResolutionPriority(-2)]so they are only selected when no typed overload applies. Existing callers with 5+ arguments are unaffected. -
Benchmark suite updated — class 5 renamed to
FourArgBenchmarks(4-arg typed path), new class 5bParamsPathBenchmarks(5-arg true params fallback), class 6DisabledAllArgsBenchmarksextended with 4-arg typed and 5-arg params rows, class 10RuntimeLevelBenchmarksextended withLog<T0,T1,T2,T3>enabled/disabled cases, class 11FlushBenchmarkssimplified to non-asyncTask-returning methods with aBaseline, class 14AllocationStressBenchmarksextended with 4-arg disabled (0 B) and enabled rows. -
README updated with expert-reviewed improvements:
## ⚠️ Limitationssection (honest accounting of where allocation-freedom stops),## 🏭 Production readinesstable, ASCII bar chart updated with the 4-arg row, stress-test table extended, conservative benchmark wording (“in this benchmark, X was Y× faster”), FlushAsync description updated to “returnsTask.CompletedTaskdirectly”, Best Practices table updated to ≤ 4 named holes, API reference extended with 4-arg overloads andMaxTemplateCacheEntries, Roadmap updated. -
NuGet README (
README.nuget.md) rewritten with all new benchmark figures,Limitationssection,Production readinesstable, and 4-arg overload documentation. -
Test suite expanded to 132 tests (was 120) across net8.0, net9.0, and net10.0.
Performance
Benchmarks run on .NET 10.0.8, Intel Core i7-13850HX @ 2.10 GHz, BenchmarkDotNet v0.15.8 (ShortRun: 3 warmup + 3 measurement iterations, Server GC):
| Path | v1.0.0 | v1.0.1 | Δ |
|---|---|---|---|
No-arg enabled — WriteInformation("text") |
29 ns / 56 B | 4.14 ns / 0 B | 7× faster, zero alloc |
Feature C — WriteError("msg", ex) no args |
36 ns / 72 B | 3.95 ns / 0 B | 9× faster, zero alloc |
Nilogger.Log(…) 0-arg enabled |
27 ns / 40 B | 4.46 ns / 0 B | 6× faster, zero alloc |
FlushAsync() |
1,280 ns / 328 B | ~0.01 ns / 0 B | >100,000× faster, zero alloc |
WriteErrorException(ex) basic report |
182 ns / 992 B | 99.5 ns / 496 B | 1.8× faster, 50% less alloc |
4-arg disabled — WriteInformation("{A}{B}{C}{D}", …) |
n/a (params, 113 ns / 192 B) | 0.24 ns / 0 B | 479× faster vs Microsoft |
4-arg enabled — WriteInformation("{A}{B}{C}{D}", …) |
n/a (params, 122 ns / 192 B) | 71.8 ns / 136 B | 41% faster, 29% less alloc |
The 1–3-arg disabled and enabled paths are unchanged from v1.0.0 (they were already correct).
1.0.0 - 2026-06-13
Initial public release. Zero-allocation, high-performance logging extensions for
Microsoft.Extensions.Logging.
Added
- Strongly-typed log overloads for one to three arguments (
WriteTrace,WriteDebug,WriteInformation,WriteWarning,WriteError,WriteCritical) that avoid theparams object[]allocation and allocate nothing when the level is disabled. paramsfallback overloads for four or more arguments, preserving familiar usage.- Static
Nilogger.Log(...)API for logging when the level is decided at runtime. - Exception reporting via
WriteErrorExceptionandWriteCriticalException, with an optional verbose mode that includes the stack trace and a bounded walk of inner andAggregateExceptionbranches. - Pluggable
ExceptionFormatterfor customising how exceptions are rendered; assigningnullrestores the built-in formatter. - Logging scopes through
WriteScope(key, value)andWriteScope(dictionary), with an allocation-light path for small contexts (the scope object itself is allocation-free). - Structured logging support: named template properties and the
{OriginalFormat}entry flow through to structured sinks via a stack-onlyreadonly structstate. - Runtime template parser with caching, supporting named placeholders, escaped braces
(
{{/}}), and alignment/format suffixes ({Value,5},{Value:000}). - Allocation-free UTC timestamp cache refreshed by a background timer, with
ShutdownUtcTimer()for deterministic teardown (also wired to process exit automatically). - Forward-looking async hooks:
AsyncSinkFilter,UseAsyncSinkProvider(filter), andFlushAsync(cancellationToken). Write*method naming chosen deliberately to avoid ambiguous-call conflicts with the framework’s ownLog*extensions, so both can be imported side by side.- Thread-safe and Native AOT / trimming friendly: immutable or concurrency-safe shared state and no reflection.
- Ships as a single
Nilog.dllexposing theNilognamespace (using Nilog;), multi-targeting .NET 8.0, 9.0, and 10.0, with SourceLink, embedded symbols, and XML documentation.
Performance
Highlights from the included BenchmarkDotNet suites (.NET 8.0.27, AMD Ryzen AI 9 365; see the README for full tables and methodology):
- Disabled log call:
0.48 ns/ 0 B vs the framework’s142 ns/208 B— ~297× faster, zero allocation. - Enabled, 1 argument:
56 ns/80 Bvs72 ns/112 B(~22% faster, ~29% less memory). - Enabled, 3 arguments:
72 ns/104 Bvs121 ns/152 B(~40% faster, ~32% less memory). - 100,000-entry loop:
7.0 ms/11.4 MBvs9.7 ms/15.2 MB(~1.38× faster, ~25% less RAM). - 50,000 logs across all cores: ~29% less allocated memory.
Documentation & tooling
- Comprehensive README with benchmark figures/graphs, a feature comparison, recipes (ASP.NET Core, worker services, Serilog sink), a migration guide, a full API reference, and a FAQ.
- Runnable, fully commented demo (
Nilog.Demo) that tours every feature. - xUnit test suite (
Nilog.Tests) — 81 tests passing on net8.0 / net9.0 / net10.0. - BenchmarkDotNet project (
Nilog.Benchmark) covering enabled, disabled, exception, scope, parallel, and stress scenarios. - XML documentation on every public member, consistent file headers, and centralised analyzer suppressions for the deliberate hot-path trade-offs.
Documentation links
Read all Nilog guides online · Project overview · All developer projects