Introduction to Logging in .NET — Structured Logs, Levels, and Best Practices
Learn how to add effective logging to .NET applications using ILogger, structured logging with Serilog, log levels, and how to avoid common logging mistakes that slow down debugging.
Introduction to Logging in .NET
Logging is how you understand what your application did after something went wrong. Without good logs, debugging production issues means guessing. With good logs, you can reconstruct exactly what happened.
This article covers the basics: what to log, how to structure logs, and how to avoid the common mistakes that make logs useless when you need them most.
The .NET Logging Abstraction
ASP.NET Core ships with a built-in logging abstraction via Microsoft.Extensions.Logging. You inject ILogger<T> and the framework handles the rest.
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public async Task<Order> PlaceOrderAsync(PlaceOrderRequest request)
{
_logger.LogInformation("Placing order for customer {CustomerId}", request.CustomerId);
var order = await _repository.CreateAsync(request);
_logger.LogInformation("Order {OrderId} created successfully", order.Id);
return order;
}
}The T in ILogger<T> sets the category name — typically the class name. Log viewers use this to filter by component.
Log Levels
.NET defines six log levels. Use them consistently — they control which logs appear in which environment.
| Level | When to use |
|---|---|
| Trace | Very detailed diagnostic output — method entry/exit, loop counts |
| Debug | Developer diagnostic info — variable values, branch decisions |
| Information | Normal application events — order placed, user logged in |
| Warning | Unexpected but recoverable — retry attempt, deprecated method called |
| Error | Operation failed — exception caught, request could not be completed |
| Critical | Application-level failure — database down, out of memory |
// Trace: rarely used in production
_logger.LogTrace("Processing item {Index} of {Total}", i, items.Count);
// Debug: useful during development
_logger.LogDebug("Cache miss for key {CacheKey}, fetching from database", key);
// Information: normal business events
_logger.LogInformation("User {UserId} logged in from {IpAddress}", userId, ipAddress);
// Warning: something unexpected but handled
_logger.LogWarning("Payment retry {Attempt} of {MaxAttempts} for order {OrderId}", attempt, max, orderId);
// Error: something failed
_logger.LogError(ex, "Failed to process payment for order {OrderId}", orderId);
// Critical: application cannot continue
_logger.LogCritical("Database connection lost — application is shutting down");Production log levels: typically Information and above. Debug and Trace are too noisy for production and can be a performance problem.
Structured Logging: Message Templates
The {Placeholder} syntax in log messages is not just string formatting — it creates structured log events where each placeholder becomes a named property.
// Bad: string concatenation — loses structure
_logger.LogInformation("Order " + orderId + " placed by " + customerId);
// Good: structured template — creates searchable properties
_logger.LogInformation("Order {OrderId} placed by {CustomerId}", orderId, customerId);With structured logging, your log viewer can filter by OrderId = "ord_123" across millions of log lines in milliseconds. String concatenation means full-text search — slow and imprecise.
The property name (inside {}) is what gets indexed. The order of arguments after the template string must match the order of placeholders.
Adding Serilog
The built-in logging providers are fine for console output, but for production you want Serilog — it writes structured JSON logs that log aggregation tools can parse.
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.Seq # optional: local dev log viewer
dotnet add package Serilog.Sinks.ApplicationInsights # optional: Azure// Program.cs
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console(new JsonFormatter())
.WriteTo.Seq("http://localhost:5341") // local dev
.CreateLogger();
builder.Host.UseSerilog();The MinimumLevel.Override lines silence the noisy ASP.NET request/response logs at Information level — they're Warning or higher only, keeping your logs clean.
Log Context: Adding Properties to All Logs in a Scope
Use LogContext to add properties that should appear on every log line within a scope — like a request ID or tenant ID.
// Middleware: add correlation ID to all logs for this request
public class CorrelationMiddleware
{
private readonly RequestDelegate _next;
public CorrelationMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-Id"]
.FirstOrDefault() ?? Guid.NewGuid().ToString("N");
using (LogContext.PushProperty("CorrelationId", correlationId))
using (LogContext.PushProperty("UserId", context.User?.FindFirst("sub")?.Value))
{
context.Response.Headers["X-Correlation-Id"] = correlationId;
await _next(context);
}
}
}Every log line written inside this middleware's scope will include CorrelationId and UserId — even logs from deep inside your service layer that have no knowledge of the HTTP context.
Request Logging
Serilog has built-in request logging that replaces the verbose ASP.NET request logs with a single clean line per request:
// Program.cs — after UseSerilog()
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"]);
};
});Output:
{
"level": "Information",
"message": "HTTP GET /api/orders responded 200 in 45.2 ms",
"RequestMethod": "GET",
"RequestPath": "/api/orders",
"StatusCode": 200,
"Elapsed": 45.2,
"CorrelationId": "a3f9b12c..."
}What to Log
Log these:
- Every significant business event (order placed, payment processed, user registered)
- Every external call with outcome and duration (database query, HTTP request to third-party API)
- Every handled error with full context
- Startup configuration (which environment, which feature flags are on)
Don't log these:
- Passwords, tokens, credit card numbers, PII that isn't needed for debugging
- Success on every trivial operation (every database read at Information level)
- Exceptions twice (catch, log, rethrow → log again at middleware = duplicate)
// Bad: logs PII, double-logs, wrong level
try
{
_logger.LogInformation("Authenticating {Email} with password {Password}", email, password); // PII!
await AuthenticateAsync(email, password);
_logger.LogInformation("Authenticated {Email}", email);
}
catch (Exception ex)
{
_logger.LogError(ex, "Auth failed");
throw; // middleware will log this again
}
// Good: no PII, no double-logging, correct level
_logger.LogInformation("Authentication attempt for {Email}", email);
await AuthenticateAsync(email, password);
_logger.LogInformation("Authentication successful for {Email}", email);Logging Exceptions
Always pass the exception as the first argument when logging errors — this attaches the full stack trace as a structured property, not just the message.
// Bad: message only — no stack trace
_logger.LogError("Payment failed: " + ex.Message);
// Good: exception attached — full stack trace available in log viewer
_logger.LogError(ex, "Payment failed for order {OrderId}", orderId);appsettings.json Configuration
Control log levels per environment without changing code:
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
}
}
}// appsettings.Development.json
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug"
}
}
}Development runs at Debug (more detail). Production runs at Information (less noise, better performance).
Common Mistakes
Logging inside tight loops:
// Bad: 10,000 log lines for one operation
foreach (var item in items)
{
_logger.LogInformation("Processing item {Id}", item.Id);
Process(item);
}
// Good: log the batch summary
_logger.LogInformation("Processing {Count} items", items.Count);
foreach (var item in items) Process(item);
_logger.LogInformation("Processed {Count} items successfully", items.Count);Not logging the correlation ID: Without a correlation ID on every log line, you cannot trace a request across multiple log lines in production. Add it in middleware — once.
Logging in the data access layer: Log at the service layer where you know business context. Log calls to external systems (HTTP clients, database — use query duration). Don't log inside every repository method.
Summary
- Use
ILogger<T>— inject it, don't create it manually - Use structured templates (
{Placeholder}) not string concatenation - Use the right level:
Informationfor business events,Warningfor recoverable issues,Errorfor failures - Add Serilog for structured JSON output in production
- Push correlation ID to
LogContextin middleware so every log line is traceable - Pass exceptions as the first argument to
LogError/LogCritical
Enjoyed this article?
Explore the Observability & Reliability learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.