Logging Best Practices in ASP.NET Core: A Beginner's Guide
Learn logging best practices in ASP.NET Core: log levels, structured logging, source-generated LoggerMessage, scopes, correlation IDs, and keeping secrets out.
A diary for your app
Think about a delivery driver who writes a small diary during the day. "9:10 — picked up parcel for Ramesh. 9:40 — traffic near the market. 10:05 — delivered, customer happy." If a parcel goes missing, the manager reads the diary and quickly sees what happened and when.
Logging is exactly this diary, but for your web app. While your app runs, it writes short notes: "a user logged in," "this order was saved," "the payment service was slow," "this request failed." Later, when something breaks at 2 a.m. and a customer is angry, you read these notes to find out what went wrong — without guessing.
Good logging is the difference between "I have no idea why it failed" and "I can see the exact request, the exact order, and the exact error." This guide teaches you how to log well in ASP.NET Core, step by step, in plain language.
Why good logging matters
Imagine your online store works fine for most people, but one customer says their payment failed three times. You have no logs. Now you must guess. Was it their card? Your code? The bank? You cannot tell.
Now imagine you did log well. You search for their email, find the request, and see: "Payment gateway returned timeout after 30 seconds, OrderId 5571." In ten seconds you know the bank's gateway was slow. That is the power of good logging.
The built-in logger: ILogger
ASP.NET Core has logging built in. You do not need any extra package to start. The main tool is an interface called ILogger<T>. You ask for it in your constructor, and .NET gives it to you.
public class OrderController : ControllerBase
{
private readonly ILogger<OrderController> _logger;
public OrderController(ILogger<OrderController> logger)
{
_logger = logger;
}
[HttpPost("/orders")]
public IActionResult Create(OrderRequest request)
{
_logger.LogInformation("Creating order for customer {CustomerId}", request.CustomerId);
// ... save the order ...
_logger.LogInformation("Order {OrderId} created", 5571);
return Ok();
}
}Notice the {CustomerId} and {OrderId} parts. These are placeholders, not normal string formatting. The values you pass are kept as separate named fields. This is the heart of structured logging, and we will return to it soon.
The <T> in ILogger<T> becomes the category of the log. So ILogger<OrderController> tags every log with the full class name. Later you can turn logs on or off by category, which is very handy.
Log levels: how loud should this note be?
Not every note is equally important. "I drank water" is not as urgent as "the building is on fire." Logging has the same idea, called log levels. From quietest to loudest:
| Level | When to use it | Example |
|---|---|---|
| Trace | Tiny step-by-step detail, dev only | "Entered method X" |
| Debug | Helpful detail while fixing bugs | "Cache miss for key abc" |
| Information | Normal events worth keeping | "Order 5571 created" |
| Warning | Odd but not broken | "Retry 2 of 3 for payment" |
| Error | An operation failed | "Could not save order" |
| Critical | App or key part is down | "Database unreachable" |
Each level has a method: LogTrace, LogDebug, LogInformation, LogWarning, LogError, and LogCritical. Pick the level that matches how much someone should care.
You also set a minimum level. Anything below it is dropped. In development you might allow Debug. In production you usually start at Information so logs stay small and cheap. You set this in appsettings.json.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
}Here the default is Information, but the noisy Microsoft.AspNetCore category is raised to Warning so framework chatter does not bury your own messages. This per-category control is one of the most useful logging skills to learn early.
How a log message is decided
Steps
Call LogX
Your code logs
Check level
Below minimum? drop it
Pass to providers
Console, file, Seq
Write out
Stored for you
Structured logging: log data, not sentences
This is the single most important habit. Look at these two lines:
// Bad: one glued-together sentence
_logger.LogInformation("Order " + orderId + " shipped to " + city);
// Good: named fields kept separate
_logger.LogInformation("Order {OrderId} shipped to {City}", orderId, city);Both print a similar message. But the second one keeps OrderId and City as separate searchable fields. With a structured log store like Seq or Elastic, you can run a query like "all logs where City equals Mumbai" and get exact results. With the glued sentence, you are stuck searching raw text, which is slow and error-prone.
One rule to remember: the placeholder names matter, and the order of values must match the placeholders. ASP.NET Core matches them by position, left to right, not by name.
The @ operator: handy but risky
If you want to log a whole object as fields, you can prefix the placeholder with @, like {@Order}. This captures every public property of the object. It is convenient, but it is also how secrets leak. If your User object has a PasswordHash or Email, {@User} will dump all of it into your logs.
Safe rule: log an id, not the whole object. Write {UserId} instead of {@User}. Use @ only for small, safe objects you fully control.
High-performance logging with LoggerMessage
When an app logs millions of lines, the cost of each log call starts to matter. Every time you call LogInformation with a template, .NET has to parse that template and box the values. For most apps this is fine. For hot paths, .NET gives you a faster way: source-generated logging with the LoggerMessage attribute.
You write a partial method, mark it with the attribute, and the compiler generates the fast logging code for you. The template is parsed once, at compile time, and values are strongly typed, so there is no boxing.
public partial class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger) => _logger = logger;
[LoggerMessage(
EventId = 100,
Level = LogLevel.Information,
Message = "Order {OrderId} created for customer {CustomerId}")]
public partial void LogOrderCreated(int orderId, int customerId);
public void Create(int customerId)
{
// ... save ...
LogOrderCreated(5571, customerId);
}
}This is the modern recommended approach in .NET 6 and later, including .NET 10. The older way was LoggerMessage.Define, which created cached delegates by hand. New code should prefer the attribute, because the compiler does the boring work and you get the best performance with the least code.
| Approach | Speed | Effort | Use when |
|---|---|---|---|
LogInformation(...) | Good | Lowest | Most code, normal paths |
LoggerMessage attribute | Best | Low | Hot paths, high volume |
LoggerMessage.Define | Best | High | Legacy code only |
A small but important point: logging should be so fast that you never make it asynchronous. If your log store is slow, write to a fast place first (console or memory) and ship logs to the slow store in the background. Never block a user's request waiting for a log to be written.
Scopes and correlation IDs: follow one request
When a hundred requests run at the same time, their log lines mix together like a crowded room. A scope is a way to tag every log inside a block with the same fields, so you can follow one request through the noise.
public IActionResult Process(int orderId)
{
using (_logger.BeginScope("OrderId:{OrderId}", orderId))
{
_logger.LogInformation("Validating order");
_logger.LogInformation("Charging payment");
_logger.LogInformation("Order done");
// All three lines carry OrderId automatically
}
return Ok();
}A close cousin is the correlation ID: one id that travels with a request across services. When the browser calls your API, and your API calls a payment service, the same correlation id is attached everywhere. Now you can trace a single user's journey across many apps. With Serilog this is often done using LogContext to push the id onto every log.
Following one request with a correlation ID
Steps
Browser
Sends request
API
Creates / reads ID
Payment service
Reuses same ID
All logs share ID
Easy to trace
Request logging out of the box
ASP.NET Core can log each incoming HTTP request for you. The built-in option is app.UseHttpLogging(), which records method, path, status, and timing. If you use Serilog, app.UseSerilogRequestLogging() gives a clean one-line summary per request instead of the framework's noisier multi-line output.
Be careful what you turn on. Logging full request and response bodies is great for debugging but dangerous in production, because bodies often contain passwords, tokens, or personal data. Log the method, path, status, and duration — not the secret-filled body.
Adding Serilog (optional but popular)
The built-in logger is enough for many apps. But Serilog is a widely used add-on that makes JSON output, enrichment, and sinks very easy. The best part: your code still uses plain ILogger, so you can adopt Serilog without rewriting anything.
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.Seq("http://localhost:5341"));
var app = builder.Build();
app.UseSerilogRequestLogging();
app.Run();Reading config from appsettings.json means you can change log levels and sinks without redeploying your app. Enrichers like FromLogContext let you attach shared fields (such as a correlation id) to every log. A sink is just a destination — Console, File, Seq, Elastic, or even OpenTelemetry for sending logs and traces together.
Note that some popular .NET libraries have changed their licenses recently — for example MediatR and MassTransit now use a commercial license. Serilog itself remains free and open source, but it is always worth checking the license of any package before you depend on it in a production app.
Common mistakes to avoid
Logging looks simple, but a few habits cause real pain. Here are the ones that bite beginners most often.
- String concatenation instead of templates. Always use
{Placeholder}so fields stay structured. - Logging secrets. No passwords, tokens, card numbers, or full request bodies.
- Too much noise. If everything is
LogInformation, nothing stands out. Use levels honestly. - Swallowing exceptions silently. Pass the exception as the first argument:
_logger.LogError(ex, "Failed to save order {OrderId}", orderId). - Logging in tight loops. A log inside a loop that runs a million times will flood your store and slow your app.
- No correlation id. Without one, tracing a request across services is nearly impossible.
A sensible production setup
Putting it together, a healthy logging setup for a real app usually looks like this. Keep the minimum level at Information in production and Debug in development. Raise noisy framework categories to Warning. Use structured templates everywhere. Add a correlation id with a scope or middleware. Send logs to a searchable store like Seq, Elastic, or Application Insights, and keep a console sink for local runs.
For exceptions, always log the exception object itself, not just ex.Message, so you keep the stack trace. And remember the privacy rule: if a field could identify a person, think twice before storing it, mask it if you must, and check the rules that apply where your users live.
Quick recap
- Logging is your app's diary. It tells you what happened when something breaks.
- Use
ILogger<T>. The<T>sets the category, which controls filtering. - Pick the right log level: Information for normal events, Warning for odd things, Error and Critical for failures.
- Use structured logging with
{Placeholders}, never string concatenation, so fields stay searchable. - For hot paths, use source-generated
LoggerMessagefor the best speed with the least code. - Use scopes and a correlation id to follow one request through many logs and services.
- Never log secrets. Be careful with the
@destructuring operator; log ids, not whole objects. - Serilog is an easy, free upgrade for JSON output and sinks, and your
ILoggercode stays the same. - Always pass the exception object to
LogErrorso you keep the stack trace.
References and further reading
- Logging in .NET and ASP.NET Core — Microsoft Learn
- High-performance logging in .NET — Microsoft Learn
- Compile-time logging source generation (LoggerMessage) — Microsoft Learn
- Logging overview in C# — Microsoft Learn
- Structured Logging in ASP.NET Core with Serilog — Milan Jovanović
- High-Performance Logging in .NET Core — Steve Gordon
Related Posts
5 Serilog Best Practices for Better Structured Logging in .NET
Learn 5 simple Serilog best practices for structured logging in .NET: message templates, enrichers, correlation IDs, hiding secrets, and async sinks.
Structured Logging and Distributed Tracing for Microservices with Seq
Learn to add structured logging with Serilog and distributed tracing with OpenTelemetry to .NET microservices, then view it all in Seq with one trace ID.
Health Checks in ASP.NET Core: A Beginner's Guide
Learn health checks in ASP.NET Core: add liveness and readiness endpoints, check your database and Redis, write custom checks, and wire up Kubernetes probes.
Structured Logging in ASP.NET Core with Serilog: A Beginner's Guide
A friendly, step-by-step guide to structured logging in ASP.NET Core with Serilog: setup, sinks, request logging, and viewing logs on .NET 10.
Monitoring .NET Applications With OpenTelemetry and Grafana
A beginner-friendly guide to monitoring .NET apps with OpenTelemetry and Grafana. Send metrics, traces, and logs to Prometheus, Tempo, and Loki step by step.
Logging Requests and Responses for APIs and HttpClient in ASP.NET Core
Learn to log incoming API requests and outgoing HttpClient calls in ASP.NET Core using built-in HTTP logging and a custom DelegatingHandler, step by step.