Nilog 1.0.4
dotnet add package Nilog --version 1.0.4
NuGet\Install-Package Nilog -Version 1.0.4
<PackageReference Include="Nilog" Version="1.0.4" />
<PackageVersion Include="Nilog" Version="1.0.4" />
<PackageReference Include="Nilog" />
paket add Nilog --version 1.0.4
#r "nuget: Nilog, 1.0.4"
#:package Nilog@1.0.4
#addin nuget:?package=Nilog&version=1.0.4
#tool nuget:?package=Nilog&version=1.0.4
β‘ Nilog
Zero-allocation, high-performance logging for Microsoft.Extensions.Logging
Same ILogger. Same {Named} templates. None of the garbage.
π 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.
ShortRunjob β 3 warmup + 3 measurement iterations, Server GC. The 9-arg disabled-path figures (v1.0.4) are fromNilog.Benchmark'sHighArityExtendedBenchmarks; reproduce withdotnet 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
0Lallocated bytes byAllocationGateTests(includingDisabledPath_NineTypedArgs_AllocatesZeroBytesadded in v1.0.4) and confirmed by BenchmarkDotNet'sMemoryDiagnoser.
π₯ 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 anyMicrosoft.Extensions.Loggingprovider 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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.ObjectPool (>= 10.0.9)
-
net8.0
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.3)
- Microsoft.Extensions.ObjectPool (>= 8.0.28)
-
net9.0
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.17)
- Microsoft.Extensions.ObjectPool (>= 9.0.17)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
### 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.