sl4n 1.0.4

dotnet add package sl4n --version 1.0.4
                    
NuGet\Install-Package sl4n -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="sl4n" Version="1.0.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="sl4n" Version="1.0.4" />
                    
Directory.Packages.props
<PackageReference Include="sl4n" />
                    
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 sl4n --version 1.0.4
                    
#r "nuget: sl4n, 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 sl4n@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=sl4n&version=1.0.4
                    
Install as a Cake Addin
#tool nuget:?package=sl4n&version=1.0.4
                    
Install as a Cake Tool

sl4n — Syntropy Log for .NET

The declarative observability framework for .NET — built on Microsoft.Extensions.Logging. Correlation IDs, PII masking, per-level field control and retention — declared once and enforced on every log, before it reaches any transport. Fail-safe by design (logging can never crash your app) and NativeAOT-compatible.

NuGet License: Apache 2.0 .NET 8+ NativeAOT compatible

sl4n is the .NET member of the SyntropyLog family — the reference implementation is SyntropyLog for Node.js / TypeScript, and the same model exists for Python as slpy. Same concepts everywhere — Logging Matrix, masking by field name, retention, durable delivery — adapted idiomatically to .NET: it rides on Microsoft.Extensions.Logging (you keep ILogger<T>) instead of a bespoke logger, is configured through DI (AddSl4n) instead of a global init(), and needs no native addon — .NET already compiles to native, so the whole pipeline is NativeAOT-safe.


Quick start

dotnet add package sl4n
dotnet add package sl4n.AspNetCore
// Program.cs — one line wires the whole pipeline into Microsoft.Extensions.Logging
builder.Services.AddSl4n(builder.Configuration.GetSection("sl4n"));
builder.Services.AddSl4nAspNetCore();
app.UseSl4n();
// Any service — you keep using the standard ILogger<T>. Nothing bespoke.
public class PaymentService(ILogger<PaymentService> logger)
{
    public void Charge(decimal amount, string email) =>
        logger.LogInformation("Card charged {Amount} for {Email}", amount, email);
}

What lands on the console (structured JSON, one line):

{"timestamp":"2026-06-20T13:11:48.0600000+00:00","level":"information","category":"PaymentService","message":"Card charged 299.9 for j**n@example.com","correlationId":"req-001","Amount":299.9,"Email":"j**n@example.com"}

correlationId was propagated automatically from the inbound HTTP header; Email was masked automatically — by configuration, not magic. Your business code is unchanged. From here you shape it: which fields each level emits, masking rules, where logs go, retention — each declared once. The sections below are how.

Masking is by field name. A field whose key matches a rule is masked; free text you concatenate into the message is not. Pass sensitive data as keyed structured fields ("… {Email}", email) — not baked into the message string.


What sl4n is

Not a logger — a log pipeline on top of MEL. With Serilog, NLog, or raw Microsoft.Extensions.Logging you wire correlation IDs, PII redaction, and per-level field control yourself, in every service. sl4n does it for you: declare it once in AddSl4n(...), and it runs on every ILogger call, in every async chain, across every service — before the entry reaches the console, a database, an HTTP sink, or wherever your ITransport sends it.

Every .NET team writes the same boilerplate: thread correlationId through every method signature, scrub password/email before logging, repeat the same using (_logger.BeginScope(...)) on every controller action. sl4n solves that declaratively — you declare the rules once at startup and the framework enforces them on every log call.

It is scoped on purpose: sl4n owns the log pipeline up to the moment of persistence — matrix filtering, context propagation, masking, sanitization, serialization, retention metadata. It does not manage any backend (no Redis/HTTP/DB clients in the core — its only dependencies are Microsoft.Extensions.*). Where the entry goes is an ITransport you write.

Five pillars:

  • Logging Matrix — a declarative whitelist of context fields per log level. If a field isn't in the matrix for that level, it never reaches a transport. Field control by config, not code review.
  • Masking — PII redacted by field name, before any transport, in a never-throwing pipeline with a ReDoS guard. Non-strings under a sensitive key are redacted whole.
  • Retention-aware taggingBeginRetentionScope("SOX_AUDIT_TRAIL") stamps compliance metadata onto every log in scope so a downstream store routes it by policy.
  • Silent-observer safety — sanitization, per-transport failure isolation, never-throw masking. Logging cannot crash your app; failures surface through hooks and Sl4nStats counters.
  • Durable bufferDurableFileTransport is a self-emptying disk spool that keeps audit entries safe across a backend outage — no rotation, no maintenance.

Built on Microsoft.Extensions.Logging (you keep ILogger<T>), DI-native, and NativeAOT-compatible (reflection-free pipeline).


How it compares

Serilog and NLog are excellent logging frameworks. sl4n is a different layer — a pipeline that does in the framework what a logger leaves to you, running on top of MEL so you keep the standard ILogger:

Serilog / NLog / raw MEL sl4n
Category logger log pipeline (matrix → masking → sanitization → serialization → routing)
PII masking DIY (enrichers / destructuring policies) built in, by field name, before any sink, never-throws
Correlation IDs you thread them / manual scopes automatic via AsyncLocal + drop-in middleware, declared once
Per-level field control manual declarative Logging Matrix
Retention / audit tagging DIY first-class BeginRetentionScope — travels with the entry
If logging throws can bubble into your code silent observer — logging never throws
Durability across outages sink-specific DurableFileTransport — self-emptying disk buffer
NativeAOT varies AOT-compatible — [GeneratedRegex], Utf8JsonWriter, no reflection

The declarative shift

With Serilog/NLog/MEL you write logging. With sl4n you declare observability.

Instead of… You declare… sl4n does automatically
Threading correlationId through every method app.UseSl4n() Extracts inbound headers, pushes context to every log in the request via AsyncLocal
Scrubbing sensitive fields before logging "masking": { "enableDefaultRules": true } Masks email, password, token, card, ssn, phone on every log
Repeating BeginScope(new { correlationId }) everywhere Configured once Scope opened by the middleware — available to every ILogger in the call chain
Deciding which fields each level logs loggingMatrix Whitelists context fields per level; the rest never reach a transport
Building outbound headers per downstream target context.outbound Sl4nDelegatingHandler injects the right wire names per destination
Routing compliance logs manually BeginRetentionScope('SOX…') Stamps retention/retentionClass/retentionDays on every log in scope

A fuller example

The same start, now with a logging matrix (field control per level) and masking — all declarative:

// appsettings.json
{
  "sl4n": {
    "masking": { "enableDefaultRules": true },
    "loggingMatrix": {
      "default":     ["correlationId"],
      "information": ["correlationId", "userId", "operation"],
      "error":       ["*"]
    },
    "context": {
      "source": "frontend",
      "autoGenerate": ["correlationId"],
      "inbound":  { "frontend": { "correlationId": "X-Correlation-ID", "traceId": "X-Trace-ID" } },
      "outbound": { "http":     { "correlationId": "X-Correlation-ID", "traceId": "X-Trace-ID" } }
    }
  }
}
builder.Services.AddSl4n(builder.Configuration.GetSection("sl4n"));
builder.Services.AddSl4nAspNetCore();
app.UseSl4n();
// No transport configured ⇒ structured JSON to the console by default.

Two pipelines, never mixed

HTTP request ──► Sl4nMiddleware
                   ├─► Sl4nContext.Push()   ──► propagation pipeline (AsyncLocal)
                   └─► logger.BeginScope() ──► log pipeline (MEL scope)
Pipeline Carrier Responsibility
Propagation Sl4nContext (AsyncLocal) Translate wire names between services
Log MEL scope (BeginScope) Attach context fields, mask sensitive values

Sl4nLogger has no knowledge of Sl4nContext. Sl4nDelegatingHandler has no knowledge of ILogger. The middleware is the only component that touches both — in two explicit, separate lines.


Logging Matrix — the differentiator

A declarative contract for context fields: a per-level whitelist deciding which of the auto-propagating context values surface at each level. A field not whitelisted for a level never reaches a transport — reviewable as one config object instead of by grepping every log call.

Matrix governs context, not per-call metadata. The fields you pass to logger.LogInformation("… {Amount}", amount) are always emitted (and masked) — if you don't want a field logged, don't pass it. The matrix exists for the auto-propagating context you can't trim at each call site.

"loggingMatrix": {
  "default":     ["correlationId"],
  "information": ["correlationId", "userId", "operation"],
  "error":       ["*"]
}
// AOT path — the typed builder keys off LogLevel, so a "not-a-level" typo is a compile error
cfg.LoggingMatrix = new MatrixBuilder()
    .Default("correlationId")
    .Level(LogLevel.Information, "correlationId", "userId", "operation")
    .All(LogLevel.Error)
    .Build();
Key Meaning
default Applied to any level not listed explicitly
TraceCritical Per-level whitelist — MEL level names, case-insensitive
["*"] Allow every context field at that level

Always define default. When no matrix is configured, every context field passes through (backward compatible).


Masking

Masking runs automatically on every entry before it reaches any transport, matched by field name.

Field key (examples) Strategy Result
email, mail Email — first + last char j**n@example.com
password, pass, pwd, secret Full mask **********
token, key, auth, jwt, bearer Full mask **********
credit_card, card_number Last four ************1234
ssn, social_security Last four *****6789
phone, mobile, tel Last four ******4567

Keep the defaults on and add your own rules on top — matched by field name. Reference MaskKeys instead of string literals to keep patterns out of secret scanners (Sonar S2068):

"masking": {
  "enableDefaultRules": true,
  "regexTimeoutMs": 100,
  "rules": [ { "pattern": "^(cvv|cvc|securityCode)$", "strategy": "FullMask" } ]
}
cfg.Masking.Rules.Add(new MaskingRuleConfig
{
    Pattern  = MaskKeys.Pattern(MaskKeys.Token),   // ^(token|key|auth|jwt|bearer)$
    Strategy = MaskingStrategy.FullMask,
});

Guarantees:

  • Non-string under a sensitive key → [REDACTED]. An object, array, or number under a sensitive-named field is redacted whole, never stringified — nested PII can't leak through.
  • Masking never throws. A custom-mask exception or a regex timeout redacts that one field fail-secure and fires OnMaskingError; logging keeps working.
  • ReDoS guard. Custom-rule regexes run under regexTimeoutMs (default 100 ms). The built-in defaults are linear, source-generated [GeneratedRegex].

Scope — by field name, never free text. A value is masked because its key matches a rule, not because the string looks sensitive. Masking does not deep-walk nested object graphs — that would move a mountain of data on every log and penalize latency. Structure sensitive data as keyed fields; a nested object under a sensitive key is still redacted whole. This mirrors SyntropyLog's by-key spirit, kept allocation-light and AOT-safe for .NET.


Context propagation

Conceptual field names (correlationId, traceId, tenantId) are internal keys. The name that travels on the wire is declared by you per source/target; the framework translates at the moment of sending. Application code only ever works with conceptual names.

inbound['frontend']   internal context    outbound['http']       outbound['kafka']
──────────────────    ────────────────    ─────────────────      ─────────────────
X-Correlation-ID  ->  correlationId   ->  X-Correlation-ID   /   correlationId
X-Trace-ID        ->  traceId         ->  X-Trace-ID         /   traceId

Inbound is handled by the ASP.NET Core middleware (app.UseSl4n()): it extracts the configured headers for context.source, auto-generates any field listed in context.autoGenerate (UUID), opens the propagation + log scopes for the request, and echoes response headers when context.responseTarget is set.

Outbound — inject the wire headers on an outgoing HttpClient via Sl4nDelegatingHandler:

builder.Services.AddHttpClient("downstream")
    .AddHttpMessageHandler(sp =>
        new Sl4nDelegatingHandler(sp.GetRequiredService<IOptions<Sl4nConfig>>(), target: "http"));

Manual context — push fields outside a request (background jobs, workers):

using Sl4nScope scope = Sl4nContext.Push(("correlationId", "req-001"), ("userId", "usr-42"));
Sl4nContext.Set("step", "payment");

Retention policies

Declare named retention policies (compliance metadata), then tag the logs that must obey them — the .NET analog of SyntropyLog's withRetention('SOX_AUDIT_TRAIL'). The emitted entry carries retention / retentionClass / retentionDays so a downstream store applies the right retention.

"retentionPolicies": {
  "SOX_AUDIT_TRAIL": { "days": 2555, "class": "SOX" },
  "GDPR_STANDARD":   { "days": 365,  "class": "GDPR" }
}
using (logger.BeginRetentionScope("SOX_AUDIT_TRAIL"))
{
    logger.LogInformation("Payment approved {Amount}", amount);
    // → { …, "retention": "SOX_AUDIT_TRAIL", "retentionClass": "SOX", "retentionDays": 2555 }
}

The tag rides a MEL scope, so every log in the call chain inherits it. Retention metadata bypasses the logging matrix — it's a structural compliance stamp, not user context.


Transports

Default output is structured JSON (no transport needed). Every entry carries an ISO-8601 timestamp captured at log time (not at async-worker time). Transports run on a background Channel worker, so logging never blocks the request.

Transport Output Use case
(default) ConsoleTransport Structured JSON, one line Production, log aggregators
ClassicConsoleTransport ts [INF] category: message key=value Local development
DurableFileTransport Wraps any inner transport, buffered Surviving backend outages
SpyTransport (sl4n.Testing) In-memory capture Tests
builder.Services.AddSl4n(/* … */);
builder.Services.UseClassicConsole();          // swap JSON console for the human-readable one
// or: services.UseTransport(myTransport);     // replace all transports with your own ITransport

Any ITransport you write receives the already-masked, matrix-filtered, sanitized entry — the Universal Adapter role: connect it to a DB, HTTP API, or queue.

Durable buffering

Wrap any transport in a DurableFileTransport to survive backend outages. It's a buffer, not an archive: in the happy path entries go straight to the inner transport and disk is untouched. When the inner transport throws, entries spool to a file and retry on later logs and on restart; once the backlog drains, the file is deleted. The spool only ever holds the undelivered backlog — no rotation, no rename, no cleanup to maintain.

ITransport sink = new MyHttpTransport(/* … */);        // your real sink; may fail transiently
services.UseTransport(new DurableFileTransport(sink, "buffer/spool.jsonl"));

A crash mid-outage is recovered on the next startup: the leftover spool is replayed when a fresh DurableFileTransport is constructed. Cost is O(1) per log while the sink is down, O(n) once on recovery.


Reliability & observability

Three safety properties hold on every log:

  • Sanitization. Control characters and ANSI escape sequences are stripped from the message and field values before any transport sees them — a value can't inject a fake log line. (The exception blob is left intact so stack-trace newlines survive.)
  • Transport isolation. If one transport throws, the others still receive the entry and the worker keeps running. The failure is counted and surfaced via OnLogFailure.
  • Masking never throws. A custom-mask error or a regex timeout redacts that field fail-secure and fires OnMaskingError.

Runtime counters are exposed via Sl4nStats — the getStats() equivalent — resolvable from DI:

Sl4nStatsSnapshot s = provider.GetRequiredService<Sl4nStats>().Snapshot();
// s.LogsProcessed · s.TransportFailures · s.DroppedEntries · s.MaskingFailures

services.AddSl4n(cfg =>
{
    cfg.OnLogFailure           = (ex, transport) => Console.Error.WriteLine($"{transport}: {ex.Message}");
    cfg.Masking.OnMaskingError = (ex, field)     => Console.Error.WriteLine($"mask {field}: {ex.Message}");
});

Testing

sl4n.Testing ships a SpyTransport that captures emitted entries — so you assert on exactly what would have shipped: levels, messages, context fields, and masked values.

var spy = new SpyTransport();
var services = new ServiceCollection();
services.AddSl4n(cfg => cfg.Masking.EnableDefaultRules = true);
services.UseSpyTransport(spy);           // capture entries instead of writing to the console

// … exercise the code under test, then assert on what was emitted:
spy.Entries[0]["Email"].Should().Be("j**n@example.com");   // masking really ran
spy.AtLevel("error").Should().BeEmpty();
spy.AnyMessageContains("information", "Order created").Should().BeTrue();

SpyTransport surface: Entries, Count, Clear(), AtLevel(level), WithField(key, value), AnyMessageContains(level, substring).


AOT compatibility

sl4n is IsAotCompatible=true. Every code path is reflection-free:

  • [GeneratedRegex] — compile-time regex for the default rules
  • Utf8JsonWriter / JsonDocument — no JsonSerializer reflection
  • Action<T> configuration overload — no IConfiguration binding reflection
// AOT-safe — the Action<Sl4nConfig> overload
services.AddSl4n(cfg =>
{
    cfg.Masking.EnableDefaultRules = true;
    cfg.Context.Source = "frontend";
});

// Reflection-based (annotated [RequiresUnreferencedCode]) — fine for non-AOT apps
services.AddSl4n(configuration.GetSection("sl4n"));

Performance

Allocations don't vary by machine and ratios travel well — read the story off those, not the raw nanoseconds. The ns below are one BenchmarkDotNet run on x86/Windows (.NET 8); an Apple M2, where much of the work was done, posts materially lower absolute times and a tighter gap to plain MEL. One LogInformation call, active scope (correlationId + traceId), masked Email field:

Method Allocated Mean (x86/Win — indicative) Ratio
MEL no-op (NullLogger) 72 B 39 ns 1.00
NLog (scope, NullTarget, via MEL) 496 B 328 ns 8.3
MEL working (scope + dict, no masking) 792 B 335 ns 8.5
sl4n (scope + masking, null transport) 494 B 490 ns 12.4
Serilog (scope, no sinks, via MEL) 712 B 1,434 ns 36.4
  • Allocation — the portable number: sl4n 494 B. Less than a plain MEL scope (792 B), about the same as NLog (496 B), ~30% below Serilog (712 B) — while doing PII masking that none of them do.
  • Comfortably faster than Serilog on every machine tested (~3× here).
  • Slower in raw ns than a no-masking MEL/NLog — and that gap narrows on ARM/M2. Either way it's the asynchronous handoff, not masking: LogInformation only snapshots scope + enqueues; masking, matrix filtering and sanitization run on a background worker, off the request's hot path. The number is caller-observed latency, not the cost of masking.

Under this tight loop the sl4n row is bimodal (fastest ~330 ns, on par with NLog): firing logs back-to-back saturates the async channel, so queued entries survive to Gen1 and add GC jitter. Real apps log sparsely — the channel drains between calls and this doesn't appear.


What sl4n is not

It is a structured-logging and context-propagation framework. It is not a log aggregation backend (use Elasticsearch / Loki / CloudWatch), a distributed-tracing system (use OpenTelemetry), or a metrics collector (use Prometheus / Datadog). It is the component that makes every log line correct, consistent, and safe before it reaches any of those systems.


Security

  • No network I/O at runtime. sl4n contacts no external URLs; the only output is what your transports produce.
  • No extra runtime dependencies. The core depends only on Microsoft.Extensions.* — already present in any ASP.NET Core app.
  • Hardened pipeline. Masking never throws, custom regexes run under a ReDoS timeout, control characters are sanitized out, and a failing transport is isolated from the rest.

What's in the box

Feature One-liner API
Logging Matrix Per-level whitelist of context fields; typed MatrixBuilder Sl4nConfig.LoggingMatrix
Masking Redact PII by field name before any transport; custom rules, ReDoS guard, never-throws MaskingConfig, MaskKeys, MaskingStrategy
Context propagation Conceptual↔wire header translation via AsyncLocal Sl4nContext, ContextConfig
ASP.NET Core middleware Extract inbound context + open MEL scope per request sl4n.AspNetCore, app.UseSl4n()
Outbound propagation Inject wire headers on outgoing HttpClient calls Sl4nDelegatingHandler
Retention Compliance tags stamped on entries, bypassing the matrix Sl4nRetention.BeginRetentionScope, RetentionPolicy
Transports JSON + classic console; write your own ITransport ConsoleTransport, ClassicConsoleTransport
Durable buffer Self-emptying disk spool that survives outages DurableFileTransport
Silent-observer safety Sanitization + transport isolation + never-throw masking (pipeline)
Self-observability Runtime counters — logs, transport/masking failures, drops Sl4nStats.Snapshot()
Testing toolkit Capture emitted entries and assert on them sl4n.Testing, SpyTransport
NativeAOT Reflection-free pipeline (GeneratedRegex, Utf8JsonWriter) IsAotCompatible=true

Running the tests & benchmarks

dotnet test tests/sl4n.Tests/sl4n.Tests.csproj
dotnet run --project benchmarks/sl4n.Benchmarks --configuration Release -- --filter '*ComparativeBenchmark*'

License

Apache-2.0 — see LICENSE.

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 was computed.  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 was computed.  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 (2)

Showing the top 2 NuGet packages that depend on sl4n:

Package Downloads
sl4n.AspNetCore

ASP.NET Core middleware for sl4n — extracts inbound context fields from HTTP headers, opens a propagation scope (AsyncLocal) and a MEL log scope in one step. NativeAOT compatible out-of-the-box.

sl4n.Testing

Testing helpers for sl4n — SpyTransport captures emitted log entries so you can assert on levels, messages, context fields and masking in unit tests.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.4 121 7/9/2026
1.0.3 118 7/3/2026
1.0.2 111 7/3/2026
1.0.1 118 7/3/2026
1.0.0 117 7/3/2026
1.0.0-beta.1 76 4/5/2026
1.0.0-alpha.2 65 3/30/2026
1.0.0-alpha.1 65 3/30/2026

1.0.3 — documentation fix only (remove the README logo; NuGet renders it either oversized or as raw HTML). No API or behavior changes. See CHANGELOG.md.