Skip to main content
SEMastery
DevOpsbeginner

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.

12 min readUpdated March 6, 2026

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.

Figure 1: Your app writes logs as it works. Those logs flow to where you can read and search them.

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:

LevelWhen to use itExample
TraceTiny step-by-step detail, dev only"Entered method X"
DebugHelpful detail while fixing bugs"Cache miss for key abc"
InformationNormal events worth keeping"Order 5571 created"
WarningOdd but not broken"Retry 2 of 3 for payment"
ErrorAn operation failed"Could not save order"
CriticalApp 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

Call LogX
Check level
Pass to providers
Write out

Steps

1

Call LogX

Your code logs

2

Check level

Below minimum? drop it

3

Pass to providers

Console, file, Seq

4

Write out

Stored for you

Every log call is checked against the minimum level before anything is written.

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.

Figure 2: A structured log keeps the template and the values, so search tools can filter by any field.

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.

ApproachSpeedEffortUse when
LogInformation(...)GoodLowestMost code, normal paths
LoggerMessage attributeBestLowHot paths, high volume
LoggerMessage.DefineBestHighLegacy 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

Browser
API
Payment service
All logs share ID

Steps

1

Browser

Sends request

2

API

Creates / reads ID

3

Payment service

Reuses same ID

4

All logs share ID

Easy to trace

The same id flows through every service, so all related logs share one key.
Figure 3: A request and its scope. Every log inside the scope inherits the OrderId field.

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.

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.
Figure 4: A simple decision flow for choosing a log level honestly.

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 LoggerMessage for 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 ILogger code stays the same.
  • Always pass the exception object to LogError so you keep the stack trace.

References and further reading

Related Posts