⚡ Nilog
Allocation-aware, 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
⚡ What Nilog is
Nilog is a set of zero-allocation, strongly-typed extension methods on ILogger. It is not a
new logging framework, provider, or transport — it sits on top of the Microsoft.Extensions.Logging
(MEL) pipeline you already use, and every call still flows through your existing ILoggerFactory
and provider(s) (Console, Serilog, NLog, OpenTelemetry, Seq, Application Insights, …). Swap
LogInformation(...) for WriteInformation(...) and the call site stops allocating a
params object[] on every call — nothing else about your logging setup changes.
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 about 2 ns (measured, v1.0.6). LoggerMessage.Define/[LoggerMessage] achieve the same disabled-path cost; Nilog needs no per-message declaration.
using Nilog;
// 0–16 args: zero allocation when the level is disabled. Lower allocation than the conventional extensions when 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). Conventional Microsoft extensions cost 17–43 ns and 56–272 B per filtered call (v1.0.6 measurement). |
| 🆕 6–16 arg typed overloads (v1.0.4) | Source-generated Write*/Nilogger.Log overloads now reach sixteen arguments — 0 bytes disabled; ≈2.8 ns vs 42.6 ns for the conventional extension at 9 args (v1.0.6 measurement). |
| 🆕 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. |
| 🔥 Leaner than MEL extensions when enabled | Less allocation than the conventional extensions at 1–6 args; equal at 9; not faster than LoggerMessage.Define/[LoggerMessage]. |
| 🆕 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. |
| 🆕 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. |
| 🧯 Hardened formatting | Template mismatches fall back to raw text; a throwing ToString() on a typed argument is rendered as [ToString failed: ...]. Validation and provider exceptions still propagate. |
| 🧵 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
Honest summary. Nilog’s clear advantage is the disabled path (0 B, ~2–3 ns versus 17–43 ns / 56–272 B for conventional Microsoft extension methods) and lower enabled-path allocation than conventional extensions at 1–6 typed args. Compared with Microsoft’s optimized
LoggerMessage.Define/[LoggerMessage], Nilog is equal on the disabled path and not faster when enabled (and allocates 3.6× more than the source generator at 9 args). Nilog’s value is convenience — no per-message declaration — at near-optimized cost.
Measured for v1.0.6 with BenchmarkDotNet 0.15.8 · .NET 10.0.12 · Intel Core Ultra 7 265H · Windows 11 · SDK 10.0.401,
3 launches × (5 warmup + 12 iterations), from an external harness that references the packed Nilog 1.0.6 package.
Single-digit-nanosecond differences (< ~1 ns) are timer/loop-overhead artifacts, not real differences.
Disabled path (level filtered off)
| Typed args | MEL extension | LoggerMessage.Define |
[LoggerMessage] |
Nilog |
|---|---|---|---|---|
| 1 | 17.2 ns / 56 B | 0.6 ns / 0 B | 0.1 ns / 0 B | 2.0 ns / 0 B |
| 3 | 22.7 ns / 104 B | 1.5 ns / 0 B | 0.9 ns / 0 B | 1.9 ns / 0 B |
| 6 | 38.3 ns / 200 B | 2.1 ns / 0 B | 1.7 ns / 0 B | 2.0 ns / 0 B |
| 9 | 42.6 ns / 272 B | n/a | 2.6 ns / 0 B | 2.8 ns / 0 B |
Enabled path (message rendered)
| Typed args | MEL extension | LoggerMessage.Define |
[LoggerMessage] |
Nilog |
|---|---|---|---|---|
| 1 | 53.0 ns / 88 B | 22.6 ns / 32 B | 22.7 ns / 32 B | 29.4 ns / 56 B |
| 3 | 106.1 ns / 152 B | 72.0 ns / 48 B | 77.3 ns / 48 B | 81.1 ns / 104 B |
| 6 | 240.6 ns / 280 B | 220.8 ns / 280 B | 231.1 ns / 280 B | 156.8 ns / 208 B |
| 9 | 380.7 ns / 376 B | n/a | 211.2 ns / 104 B | 360.0 ns / 376 B |
- Arguments statically typed as
objectbind to theparamsoverload — no improvement over Microsoft. - A single scope:
BeginScope21.9 ns / 240 B vsWriteScope("k", v)11.5 ns / 80 B (scope creation only). - Parallel logging (50,000 calls,
MediumRun): Microsoft 3.46 ms / 5.34 MB vs Nilog 3.54 ms / 3.82 MB — equal time, ~28% less allocation. The enabled path always allocates the rendered string; only the disabled path is allocation-free. - Real ASP.NET Core app (Kestrel, loopback): 1,696 B/request vs 1,833 B (−7.5%); throughput and latency not distinguishable from Microsoft in this run.
Do not extrapolate micro-benchmark ratios to application performance. Full methodology and limits: main README.
⚠️ 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. |
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.6" />
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 — ~4.1 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);
}
[!IMPORTANT] No Nilog-specific configuration or DI registration is required for normal use. There is no service to register, no logger factory to construct, and no config file to maintain — just
using Nilog;and the existingILoggeryou already have.
⚙️ Configuration
Nilog has nothing to configure for normal use. Log levels, categories, and providers remain
standard Microsoft.Extensions.Logging configuration and work exactly as they do today:
builder.Logging
.SetMinimumLevel(LogLevel.Information)
.AddFilter("Microsoft", LogLevel.Warning)
.AddConsole();
A few optional, process-wide static hooks exist on Nilogger for advanced scenarios —
ExceptionFormatter (custom exception rendering), MaxTemplateCacheEntries (template-cache
ceiling, default 10,000), and RegisterFlush/UnregisterFlush/FlushAsync (draining a custom
buffering sink). None of them are required; see the
full configuration reference
for details.
🧩 Structured logging
A single Nilog call carries the same structured contract as the framework’s own templated logging — no array, no extra allocation:
logger.WriteInformation("User {UserId} bought {Sku} x{Qty}", 42, "A-100", 3);
| What the sink receives | Value |
|---|---|
| Rendered message | User 42 bought A-100 x3 |
UserId / Sku / Qty |
42 / "A-100" / 3 |
{OriginalFormat} |
User {UserId} bought {Sku} x{Qty} |
| Exception (if any) | attached via the Exception-overload, not as a template value |
| Scope | whatever WriteScope(...) wraps the call in |
Any structured-aware sink — Serilog, Seq, OpenTelemetry, Application Insights — gets clean, queryable fields exactly as if you had used the framework’s own templated logging.
🔗 Provider compatibility
Nilog produces standard Microsoft.Extensions.Logging state (IReadOnlyList<KeyValuePair<string,
object>> + {OriginalFormat}) — it does not implement a provider or sink itself, so it works with
any MEL provider: Console, Debug, Serilog, NLog, OpenTelemetry, Seq, Application Insights, and
cloud-specific exporters (AWS/GCP). This is contract-compatibility through the real
LoggerFactory/ILoggerProvider pipeline (verified by LoggingEngineInteropTests and real
Serilog/OpenTelemetry integration tests) — Nilog does not ship direct integrations with any
specific database, log store, or cloud service (for example MongoDB, Elasticsearch, SQL, or
Oracle); those come from whichever MEL provider or sink you choose.
🆚 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.6" 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 |
| Formatting robustness | Bad template falls back to raw text; throwing ToString() on typed args is isolated; null renders as (null) |
| 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 typed args — less than the conventional
extensions at 1–6 args, equal at 9 args (376 B each), and more than LoggerMessage.Define/[LoggerMessage] at 1–3 args (v1.0.6 measurement).
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 ~2.8 ns (v1.0.6 measurement; the conventional extension takes ~43 ns / 272 B). 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: 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.
Documentation links
Read all Nilog guides online · Project overview · All developer projects