Nilog 1.0.4

dotnet add package Nilog --version 1.0.4
                    
NuGet\Install-Package Nilog -Version 1.0.4
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Nilog" Version="1.0.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Nilog" Version="1.0.4" />
                    
Directory.Packages.props
<PackageReference Include="Nilog" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Nilog --version 1.0.4
                    
#r "nuget: Nilog, 1.0.4"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Nilog@1.0.4
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Nilog&version=1.0.4
                    
Install as a Cake Addin
#tool nuget:?package=Nilog&version=1.0.4
                    
Install as a Cake Tool

⚑ Nilog

Zero-allocation, high-performance logging for Microsoft.Extensions.Logging

Same ILogger. Same {Named} templates. None of the garbage.

NuGet License: MIT .NET Disabled path 16-arg typed Enabled path Analyzer AOT

πŸ“– Full docs, recipes, and architecture: github.com/gcfernando/Nilog


The stock ILogger extensions allocate a params object[] on every single call β€” even when the level is switched off and the message is thrown straight in the bin. On a hot path that is millions of pointless allocations and a busy garbage collector.

Nilog swaps that array for a stack-only struct. A disabled call allocates nothing and returns in under half a nanosecond.

using Nilog;

// 0–16 args: zero allocation when the level is disabled. 30–56% faster when it's enabled.
logger.WriteInformation("User {UserId} ordered {Count} items", userId, count);

// Up to sixteen args β€” still zero-array typed, no object[] ever built (extended to 16 in v1.0.4)
logger.WriteInformation("User {UserId} bought {Sku} x{Qty} in {Region} via {Channel} ({Tier}) at {Ts} ref {Ref} batch {B}",
    userId, sku, qty, region, channel, tier, ts, refId, batch);

⚑ At a glance

πŸš€ Zero-alloc disabled path 0–16 typed args β†’ 0 bytes (proven by unit tests asserting exactly 0L allocated). Microsoft costs 45–211 ns and 96–368 B per filtered call.
πŸ†• 6–16 arg typed overloads (v1.0.4) Source-generated Write*/Nilogger.Log overloads now reach sixteen arguments β€” 0 bytes disabled; 469Γ— faster than Microsoft at 9-arg (0.45 ns vs 211 ns).
πŸ†• Typed multi-pair scopes (v1.0.4) WriteScope<T1,T2>, WriteScope<T1,T2,T3>, WriteScope<T1,T2,T3,T4> β€” no dictionary allocation, no array copy for the most common scope shapes.
πŸ†• Compact exception report (v1.0.4) WriteErrorException(ex, more: false) β†’ < 300 B (down from β‰ˆ 992 B); single-line [Title] Type: Message summary.
πŸ† Faster even when enabled 30–57% faster and 25–32% less allocation than Microsoft across 1–8 args; 37% faster at 9 args.
πŸ”₯ No-arg enabled: beats Microsoft Plain WriteInformation("text") β†’ ~3.8 ns / 0 B vs Microsoft's ~6.1 ns / 0 B.
πŸ†• Span-based rendering Plain {Name} templates render through a stack-allocated Span<char> β€” no StringBuilder, no pool, no array.
πŸ†• Nilog.Analyzers β€” 8 rules + code fix NILOG001–NILOG008: interpolation (one-click fix), count mismatch, concatenated templates, duplicate, positional, exception-as-value, malformed, and non-PascalCase placeholders β€” full parity with SerilogAnalyzer.
πŸ†• WriteError/WriteCritical typed no-exception logger.WriteError("Error {Id}", id) β†’ zero-array typed overload (no params fallback).
πŸ”Œ True drop-in Same ILogger, same {Named} templates, same structured output to every sink.
🧩 Zero setup Just using Nilog; β€” no DI, no registration, no config.
🧯 Never throws A bad template falls back to raw text; FormatException never escapes a log call.
🧡 Thread-safe & AOT-ready No reflection. Safe under contention, friendly to trimming and Native AOT.
πŸ”’ Bounded template cache MaxTemplateCacheEntries (default 10,000) stops caching new entries instead of growing unboundedly.

πŸ“Š Benchmarks

Measured with BenchmarkDotNet Β· .NET 10.0 Β· Intel Core i7-13850HX Β· Windows 11. ShortRun job β€” 3 warmup + 3 measurement iterations, Server GC. The 9-arg disabled-path figures (v1.0.4) are from Nilog.Benchmark's HighArityExtendedBenchmarks; reproduce with dotnet run -c Release --project Nilog.Benchmark -f net10.0 -- --filter "*HighArityExtended*".

πŸ† Disabled-path: the zero-allocation proof

When the level is filtered off, Microsoft still builds the object[] before calling IsEnabled. Nilog checks first β€” and builds nothing.

─── 1-arg disabled call ──────────────────────────────────────────────────
Microsoft  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  44.74 ns β”‚  96 B ← always allocates
Nilog      ▏                                       0.46 ns β”‚   0 B ← 97Γ— faster in this benchmark

─── 5-arg disabled call (typed overload) ─────────────────────────────────
Microsoft  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 132.91 ns β”‚ 224 B
Nilog      ▏                                       0.23 ns β”‚   0 B ← 577Γ— faster in this benchmark

─── 8-arg disabled call (typed overload β€” v1.0.3) ────────────────────────
Microsoft  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 221.03 ns β”‚ 336 B
Nilog      ▏                                       0.82 ns β”‚   0 B ← 268Γ— faster in this benchmark

─── 9-arg disabled call (typed overload β€” NEW in v1.0.4) ─────────────────
Microsoft  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 211.31 ns β”‚ 368 B
Nilog      ▏                                       0.45 ns β”‚   0 B ← 469Γ— faster in this benchmark
Args Microsoft Nilog Speedup Bytes saved
0 5.54 ns / 0 B 🟒 0.19 ns / 0 B 29Γ— β€”
1 44.74 ns / 96 B 🟒 0.46 ns / 0 B 97Γ— 96 B
2 85.95 ns / 152 B 🟒 0.26 ns / 0 B 336Γ— 152 B
3 93.06 ns / 168 B 🟒 0.47 ns / 0 B 198Γ— 168 B
4 (typed) 112.37 ns / 192 B 🟒 0.41 ns / 0 B 274Γ— 192 B
5 (typed) 132.91 ns / 224 B 🟒 0.23 ns / 0 B 577Γ— 224 B
6 (typed, v1.0.3) 153.06 ns / 264 B 🟒 <1 ns / 0 B >150Γ— 264 B
8 (typed, v1.0.3) 221.03 ns / 336 B 🟒 0.82 ns / 0 B 268Γ— 336 B
9 (typed, v1.0.4) 211.31 ns / 368 B 🟒 0.45 ns / 0 B 469Γ— 368 B
…10–16 (typed, v1.0.4) varies 🟒 <1 ns / 0 B >200Γ— full array
17+ (params) both sides allocate the array β€” Nilog's IsEnabled guard fires first

The 0 B figures are not estimates β€” asserted as exactly 0L allocated bytes by AllocationGateTests (including DisabledPath_NineTypedArgs_AllocatesZeroBytes added in v1.0.4) and confirmed by BenchmarkDotNet's MemoryDiagnoser.

πŸ”₯ Enabled calls β€” Nilog still wins

Scenario Microsoft Nilog Time saved Alloc saved
0-arg (plain static message) 6.11 ns / 0 B 🟒 3.84 ns / 0 B 37% faster β€”
1-arg 50.23 ns / 112 B 🟒 35.19 ns / 80 B 30% faster 29% less
3-arg 116.78 ns / 152 B 🟒 51.74 ns / 104 B 56% faster 32% less
4-arg 106.14 ns / 192 B 🟒 63.64 ns / 136 B 40% faster 29% less
5-arg (typed) 126.55 ns / 224 B 🟒 77.70 ns / 160 B 39% faster 29% less
6-arg (typed, v1.0.3) 180.33 ns / 264 B 🟒 100.62 ns / 192 B 44% faster 27% less
8-arg (typed, v1.0.3) 232.94 ns / 336 B 🟒 117.04 ns / 248 B 50% faster 26% less
9-arg (typed, NEW v1.0.4) 246.01 ns / 368 B 🟒 156.21 ns / 368 B 37% faster β€”

πŸ’₯ Stress test β€” 10,000-call loop, every typed arity

Scenario Time Allocation
πŸ”΄ Microsoft disabled 3/4/5-arg Γ— 10,000 733–1,477 ΞΌs 1.56–2.75 MB
🟒 Nilog disabled 3/4/5-arg Γ— 10,000 ~2.9 ΞΌs flat 0 B
Microsoft enabled 3/4/5-arg Γ— 10,000 913–1,377 ΞΌs 1.88–2.75 MB
🟒 Nilog enabled 3/4/5-arg Γ— 10,000 582–896 ΞΌs 1.40–2.11 MB

The disabled path stays flat at ~2.9 ΞΌs with 0 B, no matter whether the template carries 3, 4, or 5 arguments. The enabled loop is consistently ~34–36% faster, ~24–26% less allocation than Microsoft across the same range.

⚑ Special paths

Scenario Mean Alloc
FlushAsync() ~0.2 ns 0 B
WriteError("msg", ex) β€” no args 4.1 ns 0 B
WriteError("Error {Id}", id) β€” typed, no exception 31.1 ns 72 B
Nilogger.Log(…) 0-arg enabled 4.0 ns 0 B
Sequential 100,000-call loop (same template) 4.83 ms 11.41 MB (35% faster, 25% less RAM than Microsoft's 7.38 ms / 15.22 MB)

⚠️ Limitations

Nilog removes the call-site object[] allocation for common logging calls, but it does not make every logging scenario allocation-free. We list these honestly rather than overclaim.

Scenario Allocation
0–16 typed arguments, disabled path 0 bytes (raised from 0–8 in v1.0.4)
0–16 typed arguments, enabled path rendered message string only β€” stack-allocated span path, no array (disabled path is 0 B)
17+ arguments falls back to params object[]; the IsEnabled guard still fires before any work is done
Enabled logging may still allocate depending on the sink, formatter, and value types β€” Nilog cannot control what a downstream sink does
Dynamic / interpolated / concatenated templates each unique string grows the template cache (Nilog.Analyzers NILOG001/NILOG003 catch this at compile time)
FlushAsync real flush β€” awaits every callback registered via Nilogger.RegisterFlush(...); a zero-allocation no-op only when nothing is registered

Why some of these are by design, not bugs: Nilog is a thin, allocation-aware layer over ILogger. It deliberately does not own the sink, the transport, or the async pipeline β€” that is what makes it a true drop-in that works with any Microsoft.Extensions.Logging provider and any hosting/cloud platform. Allocation past the call site (string rendering, sink I/O) belongs to the formatter and sink you already chose.


πŸ—ΊοΈ Roadmap

Status of planned work. βœ… shipped Β· 🚧 in progress Β· πŸ”­ considering Β· β›” decided against.

Item Status Notes
Typed overloads to 16 arguments βœ… 1.0.4 Source generator now emits 6–16 arg zero-array overloads; 9-arg disabled: 0.45 ns / 0 B (469Γ— faster).
Typed multi-pair scope overloads βœ… 1.0.4 WriteScope<T1,T2>, WriteScope<T1,T2,T3>, WriteScope<T1,T2,T3,T4> β€” no dictionary allocation.
Compact exception report βœ… 1.0.4 moreDetailsEnabled: false now allocates < 300 B (down from β‰ˆ 992 B); gate test added.
Lift the typed-overload ceiling beyond 5 args βœ… 1.0.3 Source generator first emitted 6–8 arg zero-array overloads.
More analyzer rules beyond NILOG001 βœ… 1.0.3 Added NILOG002–NILOG008 β€” 1 β†’ 8 rules, parity with SerilogAnalyzer.
Ship Nilog.Analyzers as a standalone NuGet package βœ… 1.0.3 Development-dependency package; adds no runtime dependency.
Real FlushAsync for buffering sinks βœ… 1.0.3 RegisterFlush/UnregisterFlush; no-op only when nothing is registered.
Compiler-enforced Native AOT / trim safety βœ… 1.0.3 IsAotCompatible=true; removed a real Exception.TargetSite trim hazard.
Code-fix provider for NILOG001 βœ… 1.0.3 One-click rewrite of $"..." into a literal template + appended args.
Code fixes for NILOG002 / NILOG003 πŸ”­ Ambiguous to auto-rewrite safely; diagnostics ship without an auto-fix for now.
ILogger-free static sink adapters β›” decided against Would fork the API and undermine Nilog's "true drop-in ILogger" design.

If you need something here sooner, open an issue at github.com/gcfernando/Nilog/issues.


πŸ“¦ Install

dotnet add package Nilog
<PackageReference Include="Nilog" Version="1.0.4" />

Targets .NET 8.0, 9.0, and 10.0. Dependencies: Microsoft.Extensions.Logging.Abstractions and Microsoft.Extensions.ObjectPool. Native AOT / trimming friendly.


πŸš€ Quick start

using Microsoft.Extensions.Logging;
using Nilog; // <- that's the whole setup

ILogger logger = LoggerFactory
    .Create(b => b.AddConsole())
    .CreateLogger("App");

// Plain message β€” ~3.8 ns, 0 bytes
logger.WriteInformation("Service started");

// Structured, strongly-typed, zero array allocation (1–16 args)
logger.WriteInformation("User {UserId} signed in from {Ip}", 42, "10.0.0.1");

// Up to sixteen args β€” 6–16 source-generated in v1.0.4, zero array, zero boxing on disabled path
logger.WriteInformation("User {UserId} bought {Sku} x{Qty} in {Region} via {Channel} ({Tier})",
    userId, sku, qty, region, channel, tier);

// Exception with typed context β€” no array, no boxing
try { Risky(); }
catch (Exception ex)
{
    logger.WriteError("Checkout failed for cart {CartId}", ex, cartId);
}

πŸ†š Nilog vs the alternatives

Every row is phrased so βœ… is always the good result (βœ… yes/good Β· ❌ no Β· βž– partial).

Question Microsoft ILogger Serilog Nilog
Plugs into your existing ILogger & DI? βœ… βž– βœ…
Supports {Named} templates + structured properties? βœ… βœ… βœ…
Avoids the object[] allocation per call (1–16 args)? ❌ ❌ βœ…
Allocates nothing when the level is disabled? ❌ ❌ βœ…
LoggerMessage speed with no boilerplate? ❌ ❌ βœ…
Built-in formatted exception report (compact + verbose)? βž– βž– βœ…
Typed multi-pair scope (no dict allocation)? ❌ ❌ βœ… (NEW v1.0.4)
Zero-allocation single-key scope object? ❌ ❌ βœ…
Catches the interpolation footgun at compile time? ❌ ❌ βœ… (Nilog.Analyzers)
Needs zero setup (just using Nilog;)? βœ… ❌ βœ…

🧭 Choosing the right method

I want to… Call Allocates?
Log a constant message logger.WriteInformation("Started") none
Log 1–16 structured values logger.WriteInformation("User {Id}", id) none (typed)
Log 17+ structured values logger.WriteInformation("{A} … {Q}", …) one object[]
Log an error with exception logger.WriteError("Failed {Id}", ex, id) none (typed)
Log an error without exception logger.WriteError("Bad request") none
Exception report β€” compact summary logger.WriteErrorException(ex, "Title") < 300 B
Exception report β€” full verbose logger.WriteErrorException(ex, "Title", more: true) report buffer only
Dynamic level at runtime Nilogger.Log(logger, level, "…", a, b) none for 0–16 typed
Attach 1-pair scope using (logger.WriteScope("Key", value)) { … } ~24 B (boxed value)
Attach 2–4 pair scope (typed, no dict) using (logger.WriteScope("K1", v1, "K2", v2)) { … } only boxed values
Catch $"..." mistakes at build time add the Nilog.Analyzers package n/a

Tip: Keep templates to ≀ 16 named holes to stay on the zero-array typed path.


✨ Features

Six levels, typed for 0–16 args, params for 17+

logger.WriteTrace("Polling queue, {Count} items", count);
logger.WriteDebug("Cache miss for key {Key}", key);
logger.WriteInformation("Order {OrderId} confirmed", orderId);
logger.WriteWarning("Retry {Attempt}/{Max} for {Job}", attempt, max, job);
logger.WriteError("Payment failed for {OrderId}", ex, orderId);
logger.WriteCritical("Database unreachable on {Host}", ex, host);

// Nine-arg typed (NEW in v1.0.4) β€” zero array, 0.45 ns / 0 B on disabled path
logger.WriteInformation("{A} {B} {C} {D} {E} {F} {G} {H} {I}", a, b, c, d, e, f, g, h, i);

// Up to sixteen args β€” all zero-array on the disabled path
logger.WriteInformation("User {UserId} bought {Sku} x{Qty} in {Region} via {Channel} ({Tier}) ref {Ref} at {Ts}",
    userId, sku, qty, region, channel, tier, refId, ts);

Runtime-level API β€” zero alloc for 0–16 typed args

LogLevel level = config.Verbose ? LogLevel.Debug : LogLevel.Information;
Nilogger.Log(logger, level, "Processing {JobId}", jobId);                       // ~4 ns, 0 B
Nilogger.Log(logger, level, "{A} {B} {C} {D} {E} {F} {G} {H} {I}", a, b, c, d, e, f, g, h, i); // still 0 B when disabled

Bounded template cache

// Prevent unbounded memory growth from interpolated templates
Nilogger.MaxTemplateCacheEntries = 10_000;  // default; new entries parsed but not cached beyond limit

πŸ” Static analysis β€” catch the structured-logging footguns at compile time

Every optimization above depends on the message argument being a stable string literal whose placeholders match its arguments. A few mistakes undo it all silently:

logger.WriteInformation($"User {id} signed in");  // compiles fine, silently undoes everything
logger.WriteInformation("{A} {B}", a);            // 2 holes, 1 arg β†’ renders raw, loses props
logger.WriteInformation("User " + id + " in");    // concatenation β†’ never a stable template

Nilog.Analyzers is a separate, opt-in package (not referenced by Nilog.Core) that catches all three at build time across every Nilog call shape:

<PackageReference Include="Nilog.Analyzers" Version="1.0.4" PrivateAssets="all" />
Rule Severity Catches Auto-fix
NILOG001 Warning An interpolated string ($"...") used as the message template. βœ…
NILOG002 Warning A template whose {Placeholder} count β‰  the number of arguments supplied. β€”
NILOG003 Warning A template built with string concatenation (+) or string.Format(...). β€”
NILOG004 Warning The same named {Placeholder} used twice (duplicate structured-property key). β€”
NILOG005 Info Positional {0} placeholders instead of named {Name} ones. β€”
NILOG006 Warning An Exception passed as a template value instead of the exception parameter. β€”
NILOG007 Warning A malformed template β€” an unclosed { or an empty {} placeholder. β€”
NILOG008 Info A placeholder name that is not PascalCase ({userId} β†’ {UserId}). β€”
logger.WriteInformation($"User {id} signed in");        // ❌ NILOG001 (+ one-click fix)
logger.WriteInformation("{A} {B}", a);                  // ❌ NILOG002 (2 placeholders, 1 arg)
logger.WriteInformation("User " + id + " in");          // ❌ NILOG003
logger.WriteInformation("{Id} retried {Id}", a, b);     // ❌ NILOG004 (duplicate {Id})
logger.WriteInformation("{0} {1}", a, b);               // πŸ”΅ NILOG005 (prefer named)
logger.WriteInformation("Failed {Error}", ex);          // ❌ NILOG006 (use the exception parameter)
logger.WriteInformation("Unclosed {Brace");             // ❌ NILOG007 (malformed)
logger.WriteInformation("User {userId}", id);           // πŸ”΅ NILOG008 (PascalCase)

logger.WriteInformation("User {UserId} signed in", id); // βœ… no diagnostic

Promote the correctness rules to build-breaking errors in CI (NILOG005/008 are Info-only style):

<WarningsAsErrors>$(WarningsAsErrors);NILOG001;NILOG002;NILOG003;NILOG004;NILOG006;NILOG007</WarningsAsErrors>

Or via .editorconfig for the whole repo: dotnet_diagnostic.NILOG001.severity = error.

It's syntax/semantics-based β€” it catches the mistake at the call site. Full details: Static analysis.

FlushAsync β€” real flush for buffering sinks

// A batching/buffering sink registers how to drain itself…
Nilogger.RegisterFlush(ct => myBatchingSink.FlushAsync(ct));

// …and shutdown awaits every registered sink. With nothing registered this is a
// zero-allocation no-op (returns Task.CompletedTask synchronously).
await Nilogger.FlushAsync(cancellationToken);

// Typed multi-pair scopes β€” no dictionary allocation, no array copy (NEW in v1.0.4)
using (logger.WriteScope("OrderId", orderId, "CustomerId", customerId, "Currency", "GBP"))
{
    logger.WriteInformation("Order opened");   // all three KVPs in scope
}

🏭 Production readiness

Concern Nilog answer
Thread safety volatile, Interlocked, and ConcurrentDictionary throughout
Trimming / Native AOT IsAotCompatible=true β€” trim/AOT analyzers run every build (warnings-as-errors), and the Native AOT compiler emits native code from Nilog.dll with zero warnings. No reflection.
Memory growth MaxTemplateCacheEntries stops caching at the limit instead of growing unboundedly
Idle CPU cost No background timer β€” the UTC timestamp cache refreshes lazily, only when an exception is formatted
Process shutdown A final UTC refresh runs automatically on ProcessExit; ShutdownUtcTimer() for deterministic teardown
Logging never throws Bad template falls back to raw text β€” no FormatException escapes
Sink compatibility IReadOnlyList<KVP> + {OriginalFormat} β€” works with Console, Serilog, OTel, Seq, App Insights
Supported frameworks .NET 8, 9, 10

πŸ“– API at a glance

// Extension methods on ILogger β€” Write* for all six levels (typed 0–16, params 17+)
void WriteInformation(this ILogger logger, string message, params object[] args);
void WriteInformation<T0>(this ILogger logger, string message, T0 arg0);
void WriteInformation<T0,T1>(this ILogger logger, string message, T0 arg0, T1 arg1);
void WriteInformation<T0,T1,T2>(this ILogger logger, string message, T0 arg0, T1 arg1, T2 arg2);
void WriteInformation<T0,T1,T2,T3>(this ILogger logger, string message, T0 arg0, T1 arg1, T2 arg2, T3 arg3);
void WriteInformation<T0,T1,T2,T3,T4>(this ILogger logger, string message, T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4);
// …and 6–16-argument overloads source-generated by Nilog.SourceGenerators (extended to 16 in v1.0.4)
void WriteInformation<T0,…,T15>(this ILogger logger, string message, T0 arg0, …, T15 arg15);
// Identical shape for WriteTrace, WriteDebug, WriteWarning

// Error/Critical β€” without exception (typed, zero-array; 1–16 args)
void WriteError<T0,…,T15>(this ILogger logger, string message, T0 arg0, …, T15 arg15);
// With exception
void WriteError<T0,…,T15>(this ILogger logger, string message, Exception exception, T0 arg0, …, T15 arg15);
// Identical shape for WriteCritical

// Exception reports
void WriteErrorException(this ILogger logger, Exception ex,
    string title = "System Error", bool moreDetailsEnabled = false);  // basic: < 300 B (v1.0.4)

// Scopes β€” typed multi-pair overloads NEW in v1.0.4 (no dictionary, no array)
IDisposable WriteScope(this ILogger logger, string key, object value);
IDisposable WriteScope<T1,T2>       (this ILogger logger, string k1, T1 v1, string k2, T2 v2);
IDisposable WriteScope<T1,T2,T3>    (this ILogger logger, string k1, T1 v1, string k2, T2 v2, string k3, T3 v3);
IDisposable WriteScope<T1,T2,T3,T4> (this ILogger logger, string k1, T1 v1, string k2, T2 v2, string k3, T3 v3, string k4, T4 v4);
IDisposable WriteScope(this ILogger logger, IDictionary<string, object> context);

// Static runtime-level API (zero-array for 0–16 typed args)
void Nilogger.Log<T0,…,T15>(ILogger logger, LogLevel level, string message, T0 a, …, T15 p);

// Global settings
static int MaxTemplateCacheEntries { get; set; }            // default 10,000
static void ShutdownUtcTimer();

// Flush: real drain of registered buffering sinks (no-op when none registered)
static void RegisterFlush(Func<CancellationToken, Task> flush);
static bool UnregisterFlush(Func<CancellationToken, Task> flush);
static Task FlushAsync(CancellationToken token = default);

❓ FAQ

Is it really zero allocation? On the disabled path: yes β€” 0 bytes for 0–16 typed args (extended to 16 in v1.0.4; asserted by the test suite including DisabledPath_NineTypedArgs_AllocatesZeroBytes). On the enabled path Nilog still allocates the rendered string but avoids the object[] for all 1–16 typed args β€” 25–32% less than the framework for 1–8 args, 37% faster at 9 args.

What about 9+ arguments? Typed overloads now reach sixteen (1–5 hand-written, 6–16 source-generated). A 9-arg disabled call allocates 0 B and runs in 0.45 ns (469Γ— faster than Microsoft). Only at 17+ args does Nilog fall back to params object[] β€” the same as the framework. Prefer ≀ 16 named holes on hot paths, or move extra context into a typed WriteScope (2–4 pairs) or dictionary scope.

Does it work with my logging engine / sink / cloud platform? Yes. Nilog only produces standard Microsoft.Extensions.Logging state (IReadOnlyList<KVP> + {OriginalFormat}), so it flows through any MEL provider β€” Console, Serilog, NLog, OpenTelemetry, Seq, Application Insights, AWS/GCP exporters β€” and runs anywhere .NET runs (containers, Azure Functions, AWS Lambda, Kubernetes). It adds no transport of its own, so there is nothing platform-specific to configure. This is verified, not asserted: LoggingEngineInteropTests runs Nilog through the real LoggerFactory + ILoggerProvider pipeline (the exact contract every engine integrates through) and checks the rendered message, {OriginalFormat}, named properties, exceptions, and level-filtering all arrive intact.

Is it AOT / trimming safe? Yes β€” generics, pooling, string.Format, stack-allocated spans; no reflection. Native AOT friendly.

What does Nilog.Analyzers check? Eight rules, full parity with SerilogAnalyzer: NILOG001 (interpolated templates, with a one-click code fix), NILOG002 (placeholder/argument count mismatch), NILOG003 (concatenated or string.Format templates), NILOG004 (duplicate named placeholder), NILOG005 (positional placeholders, Info), NILOG006 (an exception passed as a template value), NILOG007 (malformed template), and NILOG008 (non-PascalCase placeholder name, Info) β€” across every Write*/Nilogger.Log call shape. It's a separate, opt-in package β€” not referenced by Nilog.Core β€” so installing Nilog never pulls it in automatically.


πŸ“„ License

MIT Β© Gehan Fernando. Full docs at github.com/gcfernando/Nilog.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.4 135 6/22/2026
1.0.3 116 6/19/2026
1.0.2 121 6/16/2026
1.0.1 123 6/15/2026
1.0.0 120 6/13/2026

### Added

- **Typed overloads extended to 16 arguments** β€” the source generator (`Nilog.SourceGenerators`)
 now emits zero-array `Write*`/`Nilogger.Log` overloads 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 as
 `WriteInformation("{A}…{I}", 1…9)` now binds to `WriteInformation<T0…T8>` instead of falling
 back to `params 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 `WriteScope` overloads that eliminate the
 dictionary allocation for the most common scope shapes:
 - `WriteScope<T1,T2>(key1, val1, key2, val2)` β€” backed by a stack-allocated `TwoScope` struct
 - `WriteScope<T1,T2,T3>(key1, val1, key2, val2, key3, val3)` β€” backed by `ThreeScope`
 - `WriteScope<T1,T2,T3,T4>(k1,v1, k2,v2, k3,v3, k4,v4)` β€” backed by `FourScope`

 All three surface a scope compatible with the standard MEL `ILoggerProvider` pipeline. The
 underlying readonly structs require no array copy β€” values are boxed only once each, the
 unavoidable minimum for `ILoggerFactory` interop.

- **Compact exception report (`moreDetailsEnabled: false`)** β€” `WriteErrorException` and
 `WriteCriticalException` with `moreDetailsEnabled: false` now 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_AllocatesBelow300Bytes` allocation gate** β€” a new test in
 `AllocationGateTests` asserts that a single `WriteErrorException(ex, …, moreDetailsEnabled:
 false)` call allocates fewer than 300 bytes (after JIT warmup), using a `CaptureLogger` inner
 class that does not allocate during `Log`. Runs in CI Release to catch any regression.

- **`DisabledPath_NineTypedArgs_AllocatesZeroBytes` test** β€” added to `AllocationGateTests` to
 assert that a 9-typed-arg disabled call allocates exactly `0L`, covering the newly typed range.

- **Typed scope unit tests** β€” `TypedTwoPairScope_HasExpectedEntries`,
 `TypedThreePairScope_HasExpectedEntries`, and `TypedFourPairScope_HasExpectedEntries` added to
 `ScopeTests`, verifying key/value ordering, counts, and correct enumeration.

- **Benchmark additions and improvements**:
 - `TwoArgBenchmarks` β€” new `Enabled (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 full `ConcurrentDictionary` miss 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.Demo` updated**:
 - 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 the `IReadOnlyDictionary` fallback.

- **`Nilog.Function` updated** β€” the per-request 3-entry dictionary scope in `OrdersFunction`
 replaced with `WriteScope("OrderId", orderId, "CustomerId", request.CustomerId, "Currency",
 request.Currency)` (typed `WriteScope<T1,T2,T3>`), eliminating the dictionary allocation for
 the correlation scope that wraps every checkout invocation.

- **`Nilog.Demo`, `Nilog.Function`, and `Nilog.Benchmark`** updated 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.