Logging in .NET is one of those topics that every developer encounters on day one but few truly master. You've probably added a Console.WriteLine to track down a bug, graduated to Debug.WriteLine, and eventually discovered the Microsoft.Extensions.Logging framework -- only to wonder why your logs disappear in production or why structured log entries look like garbled JSON.
This guide covers everything you need to know about logging in .NET in 2026: the built-in ILogger abstraction, log levels, structured logging, source-generated high-performance logging, third-party providers like Serilog and NLog, and OpenTelemetry integration. Whether you're building a new ASP.NET Core API on .NET 10 or maintaining a legacy console app, you'll leave with a clear mental model and working code you can apply immediately.
Why Logging Matters More Than You Think
Good logging lets you answer three questions after an incident in production:
- What happened? -- the sequence of events that led to an error
- When did it happen? -- correlation between time and system behavior
- Why did it happen? -- the context (user, request ID, data state) that contributed to the failure
Logging in .NET isn't just for debugging. In cloud-native applications it feeds alerting systems, drives auto-scaling decisions, and provides audit trails for compliance. In .NET 9 and .NET 10, the platform ships with tooling that makes it possible to get all three answers with minimal overhead -- as long as you set things up correctly from the start.
The ILogger Abstraction: Your Foundation
The foundation of logging in .NET is the ILogger interface from Microsoft.Extensions.Logging. This abstraction decouples your application code from any specific logging implementation, which means you can swap providers without rewriting a single line of business logic.
using Microsoft.Extensions.Logging;
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public void ProcessOrder(int orderId, string userId)
{
_logger.LogInformation("Processing order {OrderId} for user {UserId}", orderId, userId);
// business logic...
_logger.LogInformation("Order {OrderId} processed successfully", orderId);
}
}
Why Use ILogger<T> Over ILogger?
Using ILogger<T> rather than the non-generic ILogger gives you three important benefits:
- Category name: The type parameter
Tbecomes the logging category. This shows up in log output and -- critically -- lets you configure minimum log levels per class. You can silence noisy framework libraries while keeping your own logs verbose. - Dependency injection:
ILogger<T>is automatically registered when you callbuilder.Logging.AddLogging()(called implicitly byCreateBuilder()). Constructor injection just works. - Testability: Because
ILogger<T>is an interface, you can inject a mock logger or useMicrosoft.Extensions.Logging.Testingto capture log entries in unit tests.
Log Levels: Getting the Granularity Right
Log levels in .NET follow a strict ordering from most verbose to most severe:
| Level | Value | When to Use |
|---|---|---|
| Trace | 0 | Extremely detailed diagnostic output (function entry/exit) |
| Debug | 1 | Useful during development; disable in production |
| Information | 2 | Normal application flow milestones |
| Warning | 3 | Unexpected situations that don't stop execution |
| Error | 4 | Failures that impact a specific operation |
| Critical | 5 | System-wide failures requiring immediate attention |
| None | 6 | Disables logging entirely |
The most common mistake developers make is logging everything at Information. This creates noise that drowns out real problems. A practical rule of thumb:
- Information: "Order 1234 placed by user abc" -- business events operations teams care about
- Warning: "Retry attempt 2 of 3 for external payment API" -- degraded but still functioning
- Error: "Failed to process payment for order 1234: connection timeout" -- actionable failures requiring investigation
Configure minimum levels per category in appsettings.json to avoid drowning in framework logs:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"MyApp.OrderService": "Debug"
}
}
}
Structured Logging: Logs as Queryable Data
Structured logging is the practice of capturing logs as key-value pairs rather than flat strings. This enables machine-readable log analysis, filtering, and alerting in tools like Seq, Elasticsearch, Azure Monitor, and Splunk.
Unstructured -- avoid this:
// Allocates a string, loses the structured data
_logger.LogInformation($"Order {orderId} placed by user {userId} for ${amount}");
Structured -- do this:
// Captures OrderId, UserId, Amount as separate queryable properties
_logger.LogInformation(
"Order {OrderId} placed by {UserId} for {Amount:C}",
orderId, userId, amount);
The curly brace syntax {PropertyName} is a message template -- not string interpolation. The framework captures OrderId, UserId, and Amount as distinct properties on the log event. When you later query your log aggregator for OrderId = 5678, you're filtering on structured data rather than doing a slow full-text search through millions of log lines.
This distinction matters at scale. A text search through 10 million log entries is slow and imprecise. A structured property query is fast and exact.
High-Performance Logging: Source-Generated LoggerMessage
Starting with .NET 6, the recommended high-performance approach uses the [LoggerMessage] source generator attribute. In .NET 9 and .NET 10, Microsoft's built-in Roslyn analyzer CA1848 will flag calls like LogInformation() and suggest migrating to source-generated equivalents in hot-path code.
Why source-generated logging in .NET?
- Zero boxing on value types -- parameters are typed at compile time
- No string allocation until the message actually needs to be written (and only if the level is enabled)
- Compile-time validation -- invalid template syntax is a build error, not a runtime surprise
- Measurably lower allocations in high-throughput scenarios (API controllers handling thousands of requests per second)
public partial class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public void ProcessOrder(int orderId, string userId)
{
LogProcessingOrder(_logger, orderId, userId);
}
public void CompleteOrder(int orderId)
{
LogOrderComplete(_logger, orderId);
}
[LoggerMessage(
Level = LogLevel.Information,
Message = "Processing order {OrderId} for user {UserId}")]
private static partial void LogProcessingOrder(
ILogger logger, int orderId, string userId);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Order {OrderId} processed successfully")]
private static partial void LogOrderComplete(
ILogger logger, int orderId);
}
The partial class and method declaration tells the source generator to emit the implementation at build time. The result is a strongly-typed, zero-allocation logging call.
For most application code the performance difference is negligible. But in high-throughput services -- or anywhere the CA1848 analyzer fires -- source-generated logging is the right choice.
Built-In Logging Providers
.NET ships with several built-in providers that write to different destinations:
- Console: Standard output. Essential for containerized apps -- Docker and Kubernetes log aggregators pick this up automatically.
- Debug: Visual Studio output window. Development-only.
- EventLog: Windows Event Log. On-premises enterprise scenarios.
- EventSource: ETW (Event Tracing for Windows) /
dotnet-trace. Production diagnostics and profiling.
For cloud-native deployments, JSON console output is typically the right choice:
builder.Logging.AddJsonConsole(options =>
{
options.JsonWriterOptions = new JsonWriterOptions { Indented = false };
options.IncludeScopes = true;
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
});
JSON console output means your log aggregator (Fluent Bit, Logstash, Azure Monitor) receives machine-parseable structured entries from the start, with no regex parsing required.
Third-Party Logging Providers
The built-in providers cover basic needs, but production .NET applications frequently use a third-party provider for richer sink support, enrichment, and async output:
Serilog
Serilog is the most widely adopted structured logging library in the .NET ecosystem. It bridges to ILogger via Serilog.Extensions.Logging, so your code continues to use ILogger<T> while Serilog handles output. Serilog 4.x is the current major version, with Serilog.AspNetCore 10.0.0 providing full .NET 9/10 support including AOT and trimming.
Key strengths: 100+ sink packages (Seq, Elasticsearch, Splunk, Azure Table Storage, email, and more), a rich enrichment API, excellent Seq integration for local development, and smarter async batching for high-throughput scenarios.
NLog
NLog is a mature, XML-configurable framework well-suited to enterprise environments that need complex routing rules -- for example, route Warning entries to a database and Error entries to both a file and an email alert. NLog 6.x is the current major version and supports .NET 6/7/8/9/10.
Microsoft.Extensions.Logging (MEL) Built-In
For apps deploying to Azure with Application Insights, MEL integrates natively via AddApplicationInsightsTelemetry(). For simple cloud-native apps, the JSON console provider is often all you need. The built-in provider is also what ASP.NET Core itself uses for routing, Kestrel, and middleware logs.
Quick comparison:
| Concern | MEL Built-In | Serilog | NLog |
|---|---|---|---|
| Sink variety | Console, Debug, EventLog | 100+ packages | 60+ targets |
| Structured logging | Via providers | Native (message templates) | Native |
| Enrichment API | Scopes only | Rich (per-property) | Rich |
| Configuration | appsettings.json | Code + appsettings | XML + code |
| AOT support (.NET 10) | Yes | Yes (4.x) | Partial |
| Performance (hot path) | Good (source-gen) | Excellent (async sinks) | Good |
Logging Scopes: Context Without Repetition
Logging scopes let you attach ambient context to all log entries within a code block -- without passing it manually on every call.
public async Task<IActionResult> PlaceOrder(OrderRequest request)
{
using var scope = _logger.BeginScope(new Dictionary<string, object>
{
["RequestId"] = HttpContext.TraceIdentifier,
["UserId"] = request.UserId,
["Operation"] = "PlaceOrder"
});
_logger.LogInformation("Validating order for {ItemCount} items", request.Items.Count);
// validation logic...
_logger.LogInformation("Committing order to database");
// persistence logic...
_logger.LogInformation("Order placement complete");
return Ok();
}
Every LogInformation call inside using var scope automatically carries RequestId, UserId, and Operation as properties. Serilog, NLog, and MEL's JSON console provider all propagate scope properties.
Scopes work particularly well in middleware where you want every log line for a given HTTP request to share a correlation ID.
OpenTelemetry: Logs, Traces, and Metrics in One Pipeline
OpenTelemetry (OTel) is the open standard for observability. In .NET 9 and .NET 10 it's deeply integrated and lets you emit logs, distributed traces, and metrics through a single pipeline -- with automatic correlation between them.
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation())
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation())
.WithLogging(logging => logging
.AddConsoleExporter())
.UseOtlpExporter();
The .WithLogging() call bridges ILogger into the OTel pipeline. Log entries captured through ILogger automatically include the active TraceId and SpanId if you're inside a distributed trace span. Given a trace ID from an error alert, you can pull up every log entry that occurred within that exact trace -- across multiple services.
Logging and Design Patterns
Logging in .NET touches several well-known software design patterns. Understanding these connections helps you reason about configuration choices and write effective extensions.
The logging pipeline itself follows the Chain of Responsibility pattern -- each registered provider receives the log entry and decides whether to process it based on its configured minimum level. Enrichers that add properties to every log entry follow a Decorator-like approach, wrapping the original event with additional context without modifying the core logging logic.
Logging events are also structurally similar to the Observer pattern: your application code publishes log events (state change notifications), and the various sinks are observers that react to those events by writing to files, databases, or cloud services. The Proxy pattern appears in logging when you wrap ILogger to add cross-cutting concerns like automatic PII scrubbing or rate limiting on error logs.
Common Logging Mistakes to Avoid
Logging sensitive data
Never log passwords, tokens, credit card numbers, or PII. This is a GDPR/PCI-DSS compliance issue and a security risk.
// WRONG -- logs the password
_logger.LogInformation("Login attempt: user={User} password={Password}", user, password);
// RIGHT
_logger.LogInformation("Login attempt for user {User}", user);
Using string interpolation instead of message templates
// WRONG -- allocates string, loses structured properties
_logger.LogInformation($"Order {orderId} placed");
// RIGHT -- captures OrderId as a structured property
_logger.LogInformation("Order {OrderId} placed", orderId);
Logging inside tight loops without level checks
// WRONG -- evaluates arguments even if Debug level is disabled
foreach (var item in items)
{
_logger.LogDebug("Processing item: " + item.Id); // string concat every iteration
}
// RIGHT -- use source-generated logging or IsEnabled check
[LoggerMessage(Level = LogLevel.Debug, Message = "Processing item {ItemId}")]
private static partial void LogProcessingItem(ILogger logger, int itemId);
Not setting category-specific minimum levels
Leaving every category at Information floods your logs with ASP.NET Core routing messages, EF Core SQL queries, and HTTP client traces alongside your application logs. Always configure category overrides in appsettings.json.
Choosing the Right Approach
For most .NET applications in 2026, here's a practical decision framework for logging in .NET:
- New .NET 10 cloud-native app: JSON console + OpenTelemetry. Add Serilog if you need multi-sink support or a Seq dashboard for local dev.
- ASP.NET Core API with Azure: MEL + Application Insights exporter covers most needs natively.
- High-traffic microservice: Source-generated
[LoggerMessage]+ async Serilog sinks to keep log I/O off the hot path. - Enterprise app with complex routing rules: NLog's XML configuration gives the most routing flexibility.
Frequently Asked Questions
What is ILogger in .NET?
ILogger is the standard logging interface from Microsoft.Extensions.Logging. It provides methods like LogInformation(), LogWarning(), and LogError() with message template support. Your code targets ILogger<T> (the generic variant that sets the category name), and the framework routes entries to registered providers like Console, Serilog, or Application Insights.
What is structured logging in .NET?
Structured logging captures log entries as key-value property pairs rather than plain text strings. Using message templates like "Order {OrderId} placed", the logging framework captures OrderId as a discrete property. Log aggregation tools can then filter and query on these properties directly, which is far more efficient and precise than text searches.
Should I use Serilog or the built-in Microsoft.Extensions.Logging?
They are complementary. You write your code against ILogger<T> (the MEL abstraction), then configure Serilog as the underlying provider. This gives you Serilog's rich sink and enrichment ecosystem while keeping your application code provider-agnostic. You can swap out Serilog for NLog or MEL's built-in providers without touching any business logic.
What is source-generated logging and why does it matter?
Source-generated logging uses the [LoggerMessage] attribute and a Roslyn source generator to produce strongly-typed, zero-allocation logging methods at build time. The CA1848 analyzer in .NET 9/10 warns when you use regular extension methods in performance-sensitive code. For high-throughput services, source-generated logging measurably reduces allocations.
What are log levels in .NET?
.NET defines six log levels (Trace, Debug, Information, Warning, Error, Critical) plus None. Each level has a numeric value -- providers filter entries below a configured minimum. Best practice is to use Information for normal business milestones, Warning for degraded-but-running conditions, and Error for actionable failures.
How does OpenTelemetry relate to .NET logging?
OpenTelemetry is the open observability standard. In .NET 9/10, it provides a unified pipeline that bridges ILogger entries, distributed traces, and metrics. Log entries emitted through ILogger automatically carry the current TraceId and SpanId, enabling you to correlate a log entry with the complete distributed trace that produced it.
What log level should I use for business events?
Use Information for business-significant milestones (order placed, payment processed, user registered). Use Debug for technical implementation details that help during local development (query row count, cache hit/miss). Reserve Warning for degraded-but-still-functional situations and Error for failures requiring investigation.
What's Next
You now have a complete picture of logging in .NET -- from ILogger<T> and log levels through structured message templates, source-generated performance optimizations, provider comparisons, and OpenTelemetry correlation.
The next step is going deep on Serilog, the most popular structured logging library in the .NET ecosystem. Serilog's sink ecosystem, enrichment API, and appsettings.json configuration make it the go-to choice for production .NET applications. Check out our complete Serilog guide for everything you need to set up and optimize Serilog in ASP.NET Core.

