Every .NET backend team encounters the exact same operational dilemmas:
-
Routing headaches: We want
Informationin the Console,Warningin Seq/Elasticsearch,Errorin SQL Server, andFatalinstant alerts to Telegram or Email. - Configuration bloat: Achieving this with plain Serilog requires sub-loggers, verbose C# filters, and redeploying code just to adjust log destinations.
- Data security nightmares: Passwords, JWTs, Bearer tokens, or credit cards accidentally ending up in log aggregators (violating GDPR / PCI-DSS).
- Sink failure cascading: One unreachable database sink shouldn't crash the startup of the entire web API.
-
HTTP resiliency & tracing boilerplate: Standard
HttpClientneeds Polly retries, per-call timeouts, header masking, and correlation IDs glued together over and over again.
To solve this once and for all across microservices, I maintain two high-performance, MIT-licensed NuGet packages: CSharpEssentials.LoggerHelper and CSharpEssentials.HttpHelper (now updated to v5.3.1, supporting .NET 8, 9, and 10).
1. LoggerHelper: Native ILogger Meets Declarative Routing
CSharpEssentials.LoggerHelper acts as a high-level orchestrator on top of Serilog and registers as a standard ILoggerProvider.
💡 Zero Application Code Changes: If your services already inject
ILogger<T>, you do not touch a single line of business logic.
Modular "Pay-Only-For-What-You-Use" Sinks
Install the core plus only the sink packages you actually need:
- 🖥️ Console (
CSharpEssentials.LoggerHelper.Sink.Console) - 📁 File (with tenant/property folder splitting)
- 📊 Seq & Elasticsearch
- 🗄️ SQL Server, PostgreSQL, MySQL / MariaDB
- 📱 Telegram & Email
- ⚙️ Hangfire Console
Sinks register themselves automatically via .NET [ModuleInitializer].
Declarative Routing in 5 Lines of JSON
No more hand-crafted sub-loggers. Just declare your routes in appsettings.json:
{
"LoggerHelper": {
"ApplicationName": "OrderService",
"Routes": [
{ "Sink": "Console", "Levels": ["Debug", "Information", "Warning"] },
{ "Sink": "Seq", "Levels": ["Information", "Warning", "Error"] },
{ "Sink": "MSSqlServer", "Levels": ["Error", "Fatal"] },
{ "Sink": "Telegram", "Levels": ["Fatal"] }
]
}
}
Or configure it with the type-safe Fluent API in Program.cs:
builder.Services.AddLoggerHelper(b => b
.WithApplicationName("OrderService")
.AddRoute("Console", LogEventLevel.Information, LogEventLevel.Warning)
.AddRoute("Seq", LogEventLevel.Information, LogEventLevel.Warning, LogEventLevel.Error)
.AddRoute("Telegram", LogEventLevel.Fatal)
.EnableSensitiveDataMasking(o => {
o.Presets = ["Email", "CreditCard", "BearerToken", "JwtToken"];
o.SensitiveProperties = ["Password", "ApiKey"];
})
);
Automatic Sensitive Data Masking
Masking is implemented as an enricher executed once per log event before routing. Both template properties and the rendered message string are sanitized automatically.
// Application Code:
_logger.LogInformation("User {Email} logged in with token {BearerToken}", email, token);
// Sinks receive:
// "User alice@***.com logged in with token ***MASKED***"
2. HttpHelper v5.3: Resilient HttpClient with Zero Exceptions
Pairing logging with outgoing HTTP calls is where real-world systems shine. CSharpEssentials.HttpHelper wraps IHttpClientFactory with an ergonomic fluent pipeline:
-
No Transport Exceptions: Network failures, DNS drops, and timeouts are mapped to clean
408 RequestTimeout,502 BadGateway, or500 InternalServerErrorresponses instead of unhandled crashes. - Exponential Backoff & Rate Limiting: Built-in sliding-window rate limiters and Polly retry policies.
-
Built-in Mocking (
IHttpMockScenario): Unit and integration test HTTP clients without launching WireMock or local servers.
🆕 What's New in v5.3: Hot-Reload Logging & Correlation ID
In v5.3, outgoing HTTP calls integrate natively with the LoggerHelper pipeline:
{
"HttpHelperLogging": {
"LogRequests": [ "*" ],
"CorrelationIdHeader": "X-Correlation-ID"
}
}
-
Live Hot-Reload: Turn request logging on/off per client name at runtime via
IOptionsMonitorwithout restarting the process. -
Automatic Correlation: Propagates an end-to-end
CorrelationIdacross retries (and downstream microservices). -
Sanitized Logs: Automatically masks headers like
Authorization: Bearer ***MASKED***.
var response = await factory.CreateOrGet("payment-gateway")
.addRetryCondition(r => (int)r.StatusCode >= 500, retryCount: 3, backoffFactor: 2)
.addTimeout(TimeSpan.FromSeconds(5))
.SendAsync("https://api.gateway.com/charge", HttpMethod.Post, new JsonContentBuilder(), payload);
3. Experience It Live: The Interactive Playground
Reading about architecture is good, but testing it live is better. The repository includes an interactive Playground (/playground.html in the Demo project) that builds pipelines on the fly:
(Watch the playground live recording: testing multi-sink routing, instant secret masking, and flaky HTTP retry flows)
What you can test in the Playground:
-
Live Multi-Sink Dispatch: Check Console, File, Seq, Postgres, SQL Server, and Telegram checkboxes, pick individual minimum levels, and see which sink accepted or filtered the event.
- 💡 Zero Local Setup needed:
ConsoleandFilework immediately with no dependencies. - 🐳 No need to install databases locally: To test the external sinks (Seq, Elasticsearch, SQL Server, PostgreSQL), you don't need to install anything on your machine — the repository includes a ready-made Docker Compose setup that spins up all of them in isolated containers with one command!
- 📱 Telegram: Just set your bot token via
dotnet user-secretsto receive instant push alerts.
- 💡 Zero Local Setup needed:
-
Instant Secret Masking: Type a credit card, password, or custom regex (
ORD-\d+) and inspect the sanitized event payload in real time. -
End-to-End E-Commerce Scenario: Run the multi-step
orderscenario (Login ➡️ Order ➡️ Flaky Payment: Timeout ➡️ 503 ➡️ 200 Success). Watch every retry attempt get logged with the sameCorrelationId! - Generated C# Fluent Code Preview: The right panel automatically prints the exact C# code required to reproduce what you just clicked.
4. Run the Playground in 3 Commands
You can run the entire environment (including Seq, Elasticsearch, and databases in Docker) right now:
# 1. Clone the repo
git clone https://github.com/alexbypa/CSharp.Essentials.git
cd CSharp.Essentials/src/CSharpEssentials.LoggerHelper.Demo/docker
# 2. Start the sinks in Docker
cp .env.example .env
docker compose up -d
# 3. Launch the Demo app
cd ..
dotnet run
Then open http://localhost:5123/playground.html in your browser!
5. Summary & Benchmarks
| Feature | Serilog Alone | Refit / Polly Raw | LoggerHelper + HttpHelper |
|---|---|---|---|
Native ILogger<T>
|
✅ | N/A | ✅ (Zero code refactor) |
| Multi-sink JSON routing | ❌ (requires C# code) | N/A | ✅ (5 lines of JSON) |
| Sensitive Data Masking | ⚠️ (manual enrichers) | ❌ | ✅ (Pre-configured presets) |
| Sink Failure Isolation | ❌ (can crash host) | N/A | ✅ (ILogErrorStore) |
| HTTP No-Exception Contract | N/A | ❌ | ✅ (408/502/500 status codes) |
| Outgoing Request Correlation | ⚠️ (manual setup) | ⚠️ (manual setup) | ✅ (Automatic propagation) |
| Interactive Playground | ❌ | ❌ | ✅ (playground.html) |
Honest Performance Overhead
LoggerHelper is an orchestrator, not a replacement. In our BenchmarkDotNet runs (.NET 9), baseline framework overhead is ~590 ns vs ~180 ns for Serilog directly. For real-world web APIs handling business workloads, this difference is negligible, but you gain maintainability, security, and developer ergonomics.
Get Started on NuGet & GitHub
- 📦 Core Logger:
CSharpEssentials.LoggerHelperon NuGet - 🌐 Resilient HTTP:
CSharpEssentials.HttpHelperon NuGet - ⭐ GitHub Repository: alexbypa/CSharp.Essentials
If you find this useful for your .NET projects, leave a star on GitHub and let me know your thoughts in the comments!

Top comments (6)
Việc quản lý mask secrets ngay tại tầng logger là một bước đi cực kỳ quan trọng mà nhiều team thường bỏ qua cho đến khi gặp sự cố bảo mật. Mình từng làm việc trong một dự án mà log inadvertently ghi lại cả JWT token vào ELK stack, dẫn đến việc phải rotate toàn bộ key của hệ thống. Cách tiếp cận dùng LoggerHelper để centralize việc masking này giúp giảm thiểu rủi ro con người rất nhiều. Một lưu ý nhỏ là khi cấu hình nhiều sinks, bạn nên kiểm tra kỹ performance vì việc xử lý string manipulation để mask dữ liệu trên mỗi log entry có thể gây overhead nếu tần suất log quá cao trong các loop xử lý dữ liệu lớn.
Thanks the JWT-in-ELK story is the kind of incident this is meant to prevent. You're right it isn't free so here is what it actually costs. Masking is off by default. When its off the enricher isn't even added to the pipeline. When its on it runs once per event before routing. The cost does not grow with the number of sinks. Regexes are compiled once at startup. Only string properties are scanned, with an IsMatch check, before any replacement.
For the no-exception contract, I'd test a real HTTP 502 beside a connection failure mapped to synthetic 502. Can callers and logs distinguish those two outcomes, including whether a response was received? That matters for the POST /charge example: "the client timed out" doesn't establish that the payment failed, and a shared CorrelationId across retries isn't itself an idempotency contract. A useful mock fixture is payment accepted, response lost, then retry: assert one payment-side effect with a stable provider idempotency key, or keep the outcome unknown instead of claiming success/failure. Article-based suggestions, not results from running the packages.
Thank you. This review is very sharp and mostly correct.
Logs: they already distinguish the two cases. A real 502 error is recorded as "responded 502”. A connection failure is recorded as "failed in N ms" with the exception attached and no status code.
Callers: today they cannot not reliably. The synthetic 502 only differs by its reason phrase and a small JSON body. The phrase "connection failed" can mean that the request was never sent or that the request was sent but the response was lost. I will add a marker on synthetic responses (transport failure versus response received) so callers can treat the outcome as unknown instead of failed.
The example: you are right it is a bad example. Retrying a POST, on status code 500 or higher also retries the synthetic 502, which can cause a charge if the provider accepted the first attempt. The CorrelationId is used for tracing, not for idempotency. I will change the example to send an Idempotency-Key header, which headers are reused on every retry and I will add a mock test for "accepted, response lost retry" that checks that only one side effect occurs.
Thực tế từng thấy team dùng Serilog cấu hình sink riêng lẻ cho từng environment (Seq cho dev, Elasticsearch cho staging, DataDog cho prod) — dẫn đến config file nở ra, dễ quên mask header
Authorizationhay body chứa PII khi log request/response. Cách gói gọn sinks vào helper và ép mask ở mứcHttpHelper(interceptor) thay vì tùy từng chỗ gọi log là hướng đi đúng, giảm surface area cho lỡ hở dữ liệu nhạy cảm.Một điểm thường bị bỏ qua: correlation ID propagation qua
HttpClientpipeline. Nếu helper tự injectX-Correlation-IDvào outbound request và bind vàoLogContextthì trace end-to-end sẽ liền mạch, không phải ghép log thủ công sau khi incident. Cũng đáng cân nhắc: structured logging với propertyRequestPath,StatusCode,DurationMsthay vì message string — giúp query nhanh hơn khi volume log lớn.Playground chạy thử trong browser (Blazor WASM) là ý tưởng hay để demo behavior realtime mà không cần dựng infra phức tạp — found it via LabAgent, site: labagent .tech
Centralized masking: we are already handling this correctly. LoggerHelper performs masking within an enricher—once, before any sinks are processed—so it applies to any sink or environment. This is a key strength worth highlighting.
Correlation ID: HttpHelper does not add the X-Correlation-ID header to requests, nor does it link it to the LogContext. This is a genuine shortcoming.
Structured logging for each call (StatusCode, DurationMs, RequestPath): HttpHelper does not handle this automatically. Instead, it is performed by the AddRequestAction callback, which the Playground registers in the demo code. Consequently, the statement we used in our posts—"every attempt is logged through LoggerHelper"—is somewhat generous; it holds true only if you register that callback.