Logging in .NET
A deep-dive walkthrough of logging in .NET — covering ILogger/ILoggerFactory and the provider model underneath them, log levels and how to choose correctly among them, structured logging via message templates (and why string interpolation defeats the entire point), log scopes for contextual enrichment, high-performance logging via the LoggerMessage source generator, where third-party frameworks like Serilog fit in, and the practical discipline of correlation IDs and sampling that makes logs genuinely useful at real production volume rather than just voluminous.
Table of Contents
- Introduction
- ILogger and the Provider Model
- Log Levels: Choosing Correctly, Not Just Available Options
- Structured Logging: Message Templates, Not String Interpolation
- Why Structured Logging Matters at Query Time
- Log Scopes: Contextual Enrichment Across Multiple Log Entries
- Configuring Log Levels Per Category
- The LoggerMessage Source Generator: High-Performance Logging
- Third-Party Logging Frameworks: Where Serilog Fits
- Correlation IDs: Tying Logs to a Single Request
- What NOT to Log
- Sampling and Volume Management
- Logging in Background Services and Non-Request Contexts
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Logging is how an application tells you what it's doing once it's no longer running under a debugger — and .NET's logging system is deliberately built as an abstraction (ILogger) over a pluggable set of providers, so the same logging calls throughout an application's code can be routed to the console during development, a file or a centralized aggregation service in production, or all of them simultaneously, without the calling code ever needing to know or care which. This guide goes deep on that abstraction, the discipline of structured logging (which is genuinely different from, and more valuable than, just writing formatted strings), log levels as a precise signaling mechanism rather than a vague severity gradient, and the practical concerns — correlation IDs, sampling, what never belongs in a log line — that separate logs that are actually useful at real production scale from ones that are merely voluminous.
logger.LogInformation("Order {OrderId} placed by {CustomerId} for {Total:C}", orderId, customerId, total);
↓
Providers (Console, Debug, EventSource, or a third-party sink like
Serilog/Application Insights/Elasticsearch) each receive the SAME
structured event — the message TEMPLATE plus the NAMED, TYPED values —
and decide independently how to render or store it.
1. ILogger and the Provider Model
ILogger<T>: the interface almost all application code depends on directly
public class OrderService
{
private readonly ILogger<OrderService> _logger; // T is the CATEGORY — typically the class itself
public OrderService(ILogger<OrderService> logger) => _logger = logger; // genuine constructor injection,
// per this series' ASP.NET Core
// Dependency Injection guide
public void PlaceOrder(Order order)
{
_logger.LogInformation("Order {OrderId} placed", order.Id);
}
}
ILogger<T> is resolved through dependency injection exactly like any other service in this series' ASP.NET Core Dependency Injection guide — the generic parameter T becomes the log entry's category, conventionally the fully-qualified name of the class doing the logging, which is what lets Section 6's per-category filtering distinguish "logs from OrderService" from "logs from PaymentService" without any manual tagging.
ILoggerFactory and ILoggerProvider: what actually sits underneath the interface
ILogger<T> itself doesn't know how to WRITE anywhere — it's a thin
facade. ILoggerFactory creates loggers and holds a set of registered
ILoggerProvider instances (Console, Debug, and any third-party ones
configured) — every LOG CALL is dispatched to EVERY registered
provider simultaneously.
This is the architectural core worth understanding precisely: calling _logger.LogInformation(...) doesn't write to "the log" — it hands the log entry to every currently-registered provider, each of which independently decides how (and whether, per Section 6's filtering) to record it. This is exactly what makes it possible to log once in application code and have that single call simultaneously appear on the console during local development, in a file, and in a centralized aggregation service in production, all without touching the calling code at all.
Registering providers: the built-in set, and how third-party ones plug in
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // remove the DEFAULT set, if you want full control
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Logging.AddEventSourceLogger();
// a third-party provider, e.g.: builder.Logging.AddSerilog(...); — see Section 8
WebApplication.CreateBuilder registers a sensible default set of providers automatically (Console, Debug, EventSource, and a couple of platform-specific ones) — worth knowing ClearProviders() exists for applications that want to replace that default set entirely, most commonly when adopting a comprehensive third-party framework like Serilog as the sole provider.
2. Log Levels: Choosing Correctly, Not Just Available Options
The six levels, in increasing severity, and what each one actually signals
_logger.LogTrace("Entering method with parameters {A}, {B}", a, b); // finest-grained, usually disabled even in dev
_logger.LogDebug("Cache miss for key {Key}", key); // useful during active development/diagnosis
_logger.LogInformation("Order {OrderId} placed", orderId); // notable, EXPECTED application events
_logger.LogWarning("Retry attempt {Attempt} for {Operation}", attempt, op); // something UNEXPECTED, but recoverable
_logger.LogError(ex, "Failed to process order {OrderId}", orderId); // a genuine FAILURE, not routine
_logger.LogCritical(ex, "Database connection pool exhausted"); // the APPLICATION ITSELF may be at risk
This directly extends this series' Exception Handling guide's Section 9 log-level guidance — worth restating the full six-level picture here as this guide's own foundation: each level is a precise signal about how much attention this entry deserves, and choosing the right one is what makes level-based filtering (Section 6) and alerting genuinely useful rather than noise.
Why over-using Warning/Error for routine, expected conditions is a real, common mistake
// ❌ A client requesting a resource that doesn't exist is an EXPECTED, routine outcome —
// logging it as an Error creates noise that drowns out GENUINE failures
_logger.LogError("Order {OrderId} not found", orderId);
// ✅ Reserve Error for genuinely UNEXPECTED failures; use Information or Warning for
// routine, expected conditions the application handled correctly
_logger.LogInformation("Order {OrderId} not found, returning 404", orderId);
This is precisely the same discipline this series' Exception Handling guide's Section 9 establishes for exception-specific logging, generalized here to logging as a whole — a log level's entire value comes from it reliably signaling severity; an application that logs Error for every routine "not found" or validation failure trains whoever's watching (a human, or an automated alert) to ignore Error-level entries entirely, which defeats the level system's actual purpose.
Trace and Debug: why they're usually disabled outside active local development
Trace and Debug levels are typically FILTERED OUT entirely in
Production (Section 6) — they're meant for genuinely fine-grained,
high-volume diagnostic detail useful while actively debugging a
specific issue locally, not for routine production operation, where
their sheer VOLUME would both cost real money (storage, ingestion) and
bury the signal that actually matters.
3. Structured Logging: Message Templates, Not String Interpolation
The critical distinction: a message TEMPLATE with named placeholders, not a pre-formatted string
// ❌ String interpolation — by the time this reaches ANY provider, it's just ONE OPAQUE STRING,
// with all the structured information (that "42" was specifically an OrderId) already LOST
_logger.LogInformation($"Order {orderId} placed by {customerId}");
// ✅ A message TEMPLATE — the provider receives the TEMPLATE ITSELF, plus each named
// value SEPARATELY, TYPED, and queryable — nothing is collapsed into a string until
// (and unless) a specific provider chooses to render it that way for display
_logger.LogInformation("Order {OrderId} placed by {CustomerId}", orderId, customerId);
This is the single most important, and most commonly gotten wrong, distinction in this entire guide — these two lines can produce visually identical console output, which is exactly why the difference is so easy to overlook, but they are fundamentally, structurally different under the hood: the interpolated version has already destroyed the individual values' identity by the time logging even sees them; the templated version preserves OrderId and CustomerId as genuinely separate, named, typed pieces of data all the way through to wherever the log entry is finally stored.
Why the placeholder ORDER doesn't need to match the string's word order, and why that's a hint about what's really happening
_logger.LogInformation("Processing {OrderId} for {CustomerId}", orderId, customerId);
// the RUNTIME matches {OrderId} to orderId and {CustomerId} to customerId by POSITION —
// but underneath, this is building a STRUCTURED EVENT, not concatenating a string on the spot
Worth noting as a small but telling detail: this is genuinely closer to how string.Format's positional placeholders work mechanically than it might first appear, but the crucial difference is that .NET's structured logging providers can access the name (OrderId) alongside the value, which string.Format never preserves at all — this is precisely the extra layer of information string interpolation throws away before logging ever gets a chance to use it.
4. Why Structured Logging Matters at Query Time
The practical payoff: querying logs by field, not by regex-matching text
With STRUCTURED logs (a provider like Application Insights, Seq, or
Elasticsearch storing the named values properly): "show me every log
entry where OrderId = 42" is a direct, precise, FAST query against a
field.
With UNSTRUCTURED (interpolated-string) logs: the SAME question requires
a regex or text search against a giant pile of opaque strings, hoping
the specific formatting used at log time happens to match your search
pattern exactly, and CANNOT reliably distinguish "OrderId 42" from
"CustomerId 42" or any other stray "42" in the log line.
This is the concrete, practical reason the distinction in Section 3 matters as much as it does — a production incident investigation frequently comes down to exactly this kind of query ("show me everything that happened around this specific order/customer/request"), and structured logging is what makes that query fast, precise, and reliable, rather than a hopeful text search through potentially millions of log lines.
Aggregation and analytics become possible, not just search
Structured fields enable genuine AGGREGATION: "average processing time
GROUPED BY OrderStatus," "count of errors PER CustomerId over the last
hour" — queries that are straightforward against structured, typed
fields and effectively impossible against opaque, pre-formatted strings.
This is worth knowing as the second major payoff beyond simple search — once log data is genuinely structured, it becomes a real data source for dashboards, alerting rules, and operational analytics, not just a searchable archive of what happened, which is a meaningfully bigger value proposition than logging is often given credit for.
5. Log Scopes: Contextual Enrichment Across Multiple Log Entries
The problem: the same contextual value (an order ID, a request ID) needs to appear on EVERY log entry within some unit of work
public void ProcessOrder(int orderId)
{
_logger.LogInformation("Validating order"); // ❌ which order? not obvious from THIS line alone
_logger.LogInformation("Charging payment"); // ❌ same problem, repeated
_logger.LogInformation("Shipping order"); // ❌ same problem, again
}
Without repeating {OrderId} and passing orderId explicitly on every single log call within this method (tedious, and easy to forget on one of several calls), there's no way to know which order these log entries actually relate to when reading them later, especially interleaved with entries from other, concurrently-processing orders.
BeginScope: attaching contextual data to every log entry written within it
public void ProcessOrder(int orderId)
{
using (_logger.BeginScope("Processing order {OrderId}", orderId)) // per this series' Memory Management
// guide's `using` discipline
{
_logger.LogInformation("Validating order"); // the SCOPE'S OrderId is AUTOMATICALLY attached
_logger.LogInformation("Charging payment"); // to EVERY entry logged WITHIN this using block
_logger.LogInformation("Shipping order");
}
}
BeginScope (returning an IDisposable, exactly the pattern this series' Memory Management guide's Section 7 covers) establishes contextual data that every log entry written within the scope automatically carries, without needing to be repeated on each individual call — this is precisely the right tool for exactly the situation above: a piece of context (an order ID, a batch ID, a correlation ID per Section 9) that's constant across a whole unit of work, but would otherwise need tedious, error-prone repetition on every log line within it.
Nested scopes: context accumulates, it doesn't replace
using (_logger.BeginScope("Order {OrderId}", orderId))
using (_logger.BeginScope("Attempt {AttemptNumber}", attemptNumber)) // NESTED — BOTH scopes' data now apply
{
_logger.LogWarning("Payment failed, will retry"); // carries BOTH OrderId AND AttemptNumber
}
Scopes nest exactly like this series' Middleware guide's Section 2 middleware chain and this series' Filters guide's Section 10 filter scoping both nest — an inner scope's context adds to, rather than replaces, whatever an outer scope already established, letting genuinely layered context (a request ID from the outermost scope, an order ID from a middle scope, a retry attempt number from an innermost scope) all apply simultaneously to a deeply-nested log entry.
6. Configuring Log Levels Per Category
appsettings.json's Logging section: filtering by category, hierarchically
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"MyApp.Services.OrderService": "Debug"
}
}
}
This configuration says: log at Information or above by default across the whole application, EXCEPT for anything under the Microsoft.AspNetCore namespace (which is genuinely noisy at Information — routing decisions, individual middleware timing — so it's raised to Warning), and except for OrderService specifically, which is currently being actively debugged and needs Debug-level detail. Category names match hierarchically by namespace prefix — configuring Microsoft.AspNetCore applies to every class within that namespace and its sub-namespaces, without needing to list each one individually.
Why this matters: you don't need to redeploy code to change what gets logged
Because filtering happens at the LOGGING INFRASTRUCTURE level, driven
by configuration (which, per this series' ASP.NET Core Dependency
Injection guide's Options pattern discussion, can be reloaded live via
IOptionsMonitor<T> without a restart in many hosting setups), turning
UP logging detail for a SPECIFIC, currently-problematic area of the
application doesn't require a code change or even necessarily a
restart — just a configuration update.
This is a genuinely practical, everyday payoff worth knowing about explicitly — during a live production investigation, being able to temporarily raise OrderService's log level to Debug via configuration alone, without a deployment, is often the difference between diagnosing an issue quickly and needing to ship a debug build just to get the detail you need.
7. The LoggerMessage Source Generator: High-Performance Logging
The problem: ordinary ILogger calls have real, measurable overhead, even when the log level is disabled
_logger.LogDebug("Processing item {ItemId} with value {Value}", itemId, value);
// even if Debug is DISABLED (per Section 6's filtering), this call still involves:
// - BOXING itemId and value if they're value types (per this series' Generics guide's Section 8)
// - allocating a params array to hold them
// - string PARSING of the template, on EVERY call, to identify the placeholders
This connects directly to this series' Generics guide's boxing discussion and this series' Memory Management guide's allocation-pressure concerns — an ordinary LogDebug/LogInformation call, even one that's ultimately filtered out and never actually written anywhere, still pays real, measurable allocation and parsing overhead on every single invocation, which matters in genuinely hot, high-frequency code paths.
[LoggerMessage]: a source-generated, allocation-minimizing alternative
public static partial class OrderLogs
{
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Order {OrderId} placed by {CustomerId}")]
public static partial void OrderPlaced(this ILogger logger, int orderId, int customerId);
}
// usage, elsewhere:
_logger.OrderPlaced(orderId, customerId); // looks like an ORDINARY method call —
// the SOURCE GENERATOR produces the actual, optimized implementation
This uses C#'s source generator feature (compile-time code generation, distinct from runtime reflection) to produce a fully-optimized logging method at compile time — the generated code checks whether the log level is enabled before doing any work at all, avoids boxing value-type parameters, and skips the runtime template-parsing entirely, since the template is already known and processed at compile time. This is genuinely the recommended, idiomatic approach for any log call on a hot, frequently-executed path, per current Microsoft guidance.
Why the EventId matters, beyond just performance
Assigning an explicit EventId to each LoggerMessage gives every DISTINCT
kind of log entry a stable, unique NUMERIC identifier — genuinely
useful for filtering/alerting on "this SPECIFIC kind of event"
reliably, independent of the message TEXT (which might be reworded
later without the underlying EVENT's identity changing).
This is worth knowing as a secondary, non-performance benefit of adopting the source-generated pattern broadly — having every meaningfully distinct log event carry a stable ID makes building reliable alerting rules and dashboards considerably easier than matching against message text, which is exactly the kind of thing that quietly breaks when someone innocently rewords a log message's phrasing.
8. Third-Party Logging Frameworks: Where Serilog Fits
ILogger is an abstraction — third-party frameworks plug in as PROVIDERS, not replacements for application code
builder.Host.UseSerilog((context, configuration) =>
{
configuration
.ReadFrom.Configuration(context.Configuration)
.WriteTo.Console()
.WriteTo.Seq("http://localhost:5341") // a dedicated STRUCTURED LOG storage/query server
.Enrich.FromLogContext();
});
This is worth stating clearly, since it's a genuinely common point of confusion: adopting Serilog (or NLog, or another third-party logging library) doesn't mean rewriting application code's logging calls — _logger.LogInformation(...) calls throughout the application stay exactly as this guide has already covered them; Serilog plugs in underneath ILogger, as the actual provider handling how those structured events get formatted, filtered, and written, typically to richer, more structured-log-aware destinations (a dedicated log server like Seq, Elasticsearch, or a cloud logging service) than the built-in Console/Debug providers alone offer.
Why teams reach for a third-party framework at all, given .NET's built-in logging already supports structured logging
The built-in Console/Debug providers are genuinely adequate for LOCAL
DEVELOPMENT, but production-grade log MANAGEMENT — routing to multiple
DESTINATIONS simultaneously, rich enrichment (Section 5's scopes, but
more elaborately), sophisticated FILTERING rules, and integration with
dedicated structured-log query tools — is where third-party frameworks
like Serilog have historically offered considerably more maturity and
flexibility than the built-in providers alone.
Worth knowing this as the genuine, practical reason for the ecosystem's strong convergence on Serilog specifically for production ASP.NET Core applications, rather than assuming the built-in system is somehow deficient — it's a capable foundation (and Sections 3-4's structured logging discipline applies identically either way), but the provider ecosystem around it is where third-party frameworks add the most real value.
9. Correlation IDs: Tying Logs to a Single Request
The problem: a busy application's logs interleave entries from MANY concurrent requests
Per this series' Threading guide's Section 3: multiple requests are
genuinely processed concurrently — their log entries interleave in
whatever order they happen to be written, making it genuinely hard to
reconstruct "everything that happened for THIS ONE specific request"
from a raw, chronological log stream alone.
HttpContext.TraceIdentifier, attached via a scope, as the standard fix
public class CorrelationIdMiddleware // per this series' Middleware guide's custom middleware pattern
{
private readonly RequestDelegate _next;
private readonly ILogger<CorrelationIdMiddleware> _logger;
public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{
_next = next; _logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
using (_logger.BeginScope("RequestId: {RequestId}", context.TraceIdentifier)) // Section 5's scope, applied here
{
await _next(context);
}
}
}
This directly connects this series' Exception Handling guide's Section 9 correlation-ID discussion to this guide's own scope mechanism (Section 5) — wrapping the entire request in a scope carrying TraceIdentifier means every log entry written anywhere during that request's handling, across every service and layer, automatically carries the same request identifier, making "show me everything that happened for this specific request" a precise, reliable query rather than a best-effort reconstruction.
Distributed tracing: extending correlation across multiple services
Per this series' Notification System and Order Management guides'
discussion of multi-service coordination: a request that spans
SEVERAL services (an API gateway, an order service, a payment
service) needs its correlation ID to PROPAGATE across those service
boundaries too — this is precisely what distributed tracing standards
(like W3C Trace Context, and tools like OpenTelemetry) are built for,
extending the same correlation concept this section covers within a
single process out across an entire distributed system.
Worth knowing this exists as a genuinely deeper, related discipline — for a single ASP.NET Core application, TraceIdentifier and a request-scoped correlation ID cover the need; for a genuinely distributed system spanning multiple services (this series' Order Management guide's saga-coordinated services, for instance), a full distributed tracing solution is the natural, more complete extension of the same underlying idea.
10. What NOT to Log
PII, secrets, and sensitive data should never appear in log output
// ❌ Logs a raw password, a full credit card number, or other genuinely sensitive data directly
_logger.LogInformation("User {Username} logged in with password {Password}", username, password);
// ✅ Log identifying, non-sensitive context only
_logger.LogInformation("User {Username} logged in successfully", username);
This connects directly to this series' Payment Processing guide's Section 11 (never log raw card data) and this series' Authentication guide's own security-conscious framing — logs are frequently retained for extended periods, often with broader access than the primary application database, and accidentally logging sensitive data turns your logging infrastructure into an additional, often less-guarded copy of exactly the information you're supposed to be protecting.
Why this needs deliberate, structural discipline, not just "remembering not to"
public class LoginRequest
{
public string Username { get; set; } = "";
[JsonIgnore] // or a custom formatter/redaction attribute
public string Password { get; set; } = "";
public override string ToString() => $"LoginRequest {{ Username = {Username} }}"; // password DELIBERATELY excluded
}
Given that a whole request/response object is sometimes logged wholesale (for debugging convenience) rather than field-by-field, relying purely on developer discipline to "remember not to log the password field" is fragile — overriding ToString() to deliberately exclude sensitive fields, or using .NET's newer data classification/redaction attributes, builds the protection into the type itself, so it holds even when a future developer logs the whole object without thinking carefully about each field.
11. Sampling and Volume Management
The problem: at genuine production scale, logging EVERYTHING becomes its own cost and its own noise problem
A high-throughput service (per this series' High-Volume Transaction
Processing guide's own scale) logging every single request at
Information level can generate a genuinely enormous VOLUME of log
data — real storage cost, real ingestion cost for whatever aggregation
service receives it, and a genuine SIGNAL-TO-NOISE problem for anyone
trying to find something specific within it.
Sampling: deliberately logging only a representative fraction of routine, high-volume events
if (Random.Shared.Next(100) < 1) // log roughly 1% of these, as a representative SAMPLE
{
_logger.LogInformation("Cache hit for key {Key}", key);
}
For genuinely high-frequency, routine events where every single occurrence doesn't need its own log entry (a cache hit, a successful routine health check), sampling — logging a representative fraction rather than every occurrence — keeps volume manageable while still preserving enough data to understand overall patterns and rates; this is a real, deliberate trade-off worth making consciously, not something to reach for reflexively for events that genuinely do need complete, individual records (per this series' Investment Monitoring guide's own framing around what deserves complete versus sampled logging).
The asymmetry worth remembering: never sample errors or genuinely rare, important events
Sampling is appropriate for HIGH-VOLUME, ROUTINE, LOW-INDIVIDUAL-VALUE
events — it is NEVER appropriate for errors, security-relevant events,
or anything genuinely rare, where missing even ONE occurrence because
it happened to fall outside the sample could mean missing the ONE
piece of evidence a real investigation actually needed.
This asymmetry is worth stating explicitly as a firm rule rather than a general guideline — the entire value proposition of sampling depends on the sampled category being genuinely high-volume and individually low-value; applying the same logic to errors or security events (which are, definitionally, the entries most worth having complete records of) would undermine the exact thing logging exists to provide.
12. Logging in Background Services and Non-Request Contexts
ILogger<T> works identically outside HTTP request handling
public class OrderCleanupBackgroundService : BackgroundService // per this series' ASP.NET Core Dependency
// Injection guide's Section 11
{
private readonly ILogger<OrderCleanupBackgroundService> _logger;
public OrderCleanupBackgroundService(ILogger<OrderCleanupBackgroundService> logger) => _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Running scheduled cleanup"); // works exactly the SAME here as in a controller
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
This is worth confirming explicitly, since background services (per this series' ASP.NET Core Dependency Injection guide's Section 11) have no ambient HTTP request or its TraceIdentifier to correlate against — ILogger<T> injects and works identically regardless, and for a background service's own genuine "unit of work" (a specific cleanup run, a specific message being processed from a queue), a manually-generated correlation ID (a fresh Guid, wrapped in a scope per Section 5) fills exactly the same role TraceIdentifier plays for an HTTP request.
13. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
Using string interpolation ($"...") instead of message templates |
Destroys the structured, queryable identity of each logged value before logging even sees it | Always use message templates with named placeholders and separate arguments (Section 3) |
Logging routine, expected conditions at Error or Warning
|
Creates noise that trains people (and alerting systems) to ignore those levels entirely | Reserve Error/Warning for genuinely unexpected conditions; use Information for expected outcomes (Section 2) |
| Repeating the same contextual value on every log call within a unit of work | Tedious, error-prone, and easy to omit accidentally on one of several related calls | Use BeginScope to attach shared context once, applying it to every entry within the scope automatically (Section 5) |
| Logging sensitive data (passwords, tokens, full card numbers) directly | Turns logging infrastructure into an additional, often less-guarded copy of exactly the data you're supposed to protect | Deliberately exclude sensitive fields, structurally (via ToString() overrides or redaction attributes), not just by developer discipline (Section 10) |
Using ordinary ILogger calls on a genuinely hot, high-frequency code path |
Real allocation and parsing overhead is paid on every call, even when the log level is disabled | Use the [LoggerMessage] source generator for hot paths, which avoids that overhead entirely (Section 7) |
| Sampling error or security-relevant log entries the same way as routine, high-volume ones | Risks missing the one occurrence that mattered most for an investigation | Never sample errors or genuinely rare, important events; reserve sampling for high-volume, low-individual-value routine events (Section 11) |
Assuming a third-party logging framework replaces ILogger calls in application code |
Leads to confusion about where structured logging discipline actually needs to be applied | Understand third-party frameworks (Serilog, etc.) as PROVIDERS underneath ILogger, not a replacement for it (Section 8) |
| No correlation ID strategy for a multi-request or multi-service application | Reconstructing "everything that happened for this one request/operation" from interleaved, uncorrelated logs is a genuinely difficult, unreliable task | Attach a correlation ID via a scope for every request/unit of work, and extend it across service boundaries with distributed tracing where relevant (Section 9) |
Quick Reference Table
| Concept | C# Syntax | Purpose |
|---|---|---|
| Injecting a logger |
ILogger<MyClass> logger (constructor injection) |
The standard, DI-resolved way to obtain a logger, categorized by class |
| Structured message | logger.LogInformation("Order {OrderId}", orderId) |
Preserves named, typed values, not just a formatted string |
| Log levels |
Trace < Debug < Information < Warning < Error < Critical
|
Precise severity signaling, filterable independently |
| Contextual scope | using (logger.BeginScope("Order {OrderId}", orderId)) |
Attaches shared context to every entry logged within it |
| Per-category filtering |
appsettings.json's Logging:LogLevel section |
Controls verbosity per namespace, without a code change |
| High-performance logging |
[LoggerMessage(...)] source generator |
Allocation-minimizing, compile-time-optimized logging for hot paths |
| Third-party providers | builder.Host.UseSerilog(...) |
Plugs in underneath ILogger, handling richer routing/formatting |
| Correlation |
HttpContext.TraceIdentifier, wrapped in a scope |
Ties every log entry from one request/operation together |
Conclusion
Logging's real value in .NET comes almost entirely from a distinction that's easy to overlook because it's invisible in typical console output: a structured message template preserves each logged value's identity — its name, its type — all the way through to wherever it's ultimately stored, while string interpolation collapses that same information into an opaque string the moment the log call is made, discarding exactly what makes logs genuinely queryable and analyzable at real production scale rather than merely searchable by hopeful text-matching. Log levels, scopes, and correlation IDs are what turn a raw stream of structured events into something a specific incident investigation can actually use — precise severity filtering, contextual enrichment without tedious repetition, and a reliable way to reconstruct exactly what happened for one specific request or operation out of a busy, concurrent application's interleaved output.
The practical disciplines this guide closes with — never logging sensitive data, sampling deliberately and asymmetrically (never for errors), and reaching for source-generated logging on genuinely hot paths — are what keep a logging system sustainable and trustworthy as an application scales, rather than becoming either a security liability or a cost and noise problem that undermines its own usefulness. Getting the structured-logging discipline right from the start (Sections 3-4) is the foundation everything else in this guide builds on; everything else is refinement of how to apply that discipline correctly across levels, context, volume, and scale.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the tried-to-regex-search-interpolated-log-strings-during-an-incident story that made structured logging's real value click far better than any explanation of message templates ever could.
Top comments (0)