Logging in ASP.NET Core: ILogger, Log Levels, Structured Logging and Serilog
We write logs with ILogger<T> in ASP.NET Core: categories, log levels, filters via appsettings and environment variables, structured logging with message templates, scopes, the LoggerMessage source generator and the Serilog version that supports net6.0.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
We added three log lines to the GetById action in ProductsController:
[HttpGet("{id:int}")]
public ActionResult<Product> GetById(int id)
{
_logger.LogInformation("Getting product {ProductId}", id);
Product? product = _store.GetById(id);
if (product is null)
{
_logger.LogWarning("Product {ProductId} not found", id);
return NotFound();
}
_logger.LogDebug("Product {ProductId} found: {ProductName}", id, product.Name);
return product;
}
appsettings.json is exactly as the template created it:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
You send one request each to /api/products/1 and /api/products/99. How many log lines do you see in the console, and which one never shows up? And why was the {ProductId} in the message not written as $"...{id}"?
ILogger<T> and the Category
You ask for a logger through the constructor, just like everything else. The logging infrastructure is already set up by WebApplication.CreateBuilder; no extra registration is needed:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductStore _store;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IProductStore store, ILogger<ProductsController> logger)
{
_store = store;
_logger = logger;
}
}
After the two requests, this lands in the console:
info: ProductCatalog.Controllers.ProductsController[0]
Getting product 1
info: ProductCatalog.Controllers.ProductsController[0]
Getting product 99
warn: ProductCatalog.Controllers.ProductsController[0]
Product 99 not found
Three lines, and no sign of LogDebug. Each line starts with the level (info, warn), followed by the category and the event id in square brackets. The T in ILogger<T> determines the category: the full name of the type, here ProductCatalog.Controllers.ProductsController. The category is not just a label; it is what the filters you are about to see hold on to.
Log Levels
.NET has six levels, from the most detailed to the most serious: Trace (0), Debug (1), Information (2), Warning (3), Error (4), Critical (5). There is also None (6), which means write nothing. When a filter enables a level, that level and everything above it gets written.
When choosing a level, ask yourself: what would the person reading this line after a midnight alert want to see? Information describes the normal flow of the app: an order was created, a product was updated. Warning is for situations that are unexpected but that the app can cope with; a product that cannot be found is a good example. Error is where an operation actually failed. Critical is for situations that affect the whole application, such as the database being unreachable. Debug and Trace are for following the flow during development and are usually off in production.
That is the first answer to the guessing question. Because we said "Default": "Information", Information and above were written and LogDebug got caught by the filter.
Filters: Which Log Goes Where
Every key under Logging:LogLevel is a category prefix. For a given log, the longest matching prefix wins, and if none matches, Default applies. That is why the "Microsoft.AspNetCore": "Warning" line matters; set it to Information and a single GET request drops about ten framework lines into the console, such as Request starting, Executing endpoint and Route matched.
If we only want to turn on Debug logs for our own code, a shorter prefix is enough. Following the rule from the configuration post, we can also pass it as an environment variable:
Logging__LogLevel__ProductCatalog=Debug dotnet run
info: ProductCatalog.Controllers.ProductsController[0]
Getting product 1
dbug: ProductCatalog.Controllers.ProductsController[0]
Product 1 found: Mechanical Keyboard
info: ProductCatalog.Controllers.ProductsController[0]
Getting product 99
warn: ProductCatalog.Controllers.ProductsController[0]
Product 99 not found
The ProductCatalog prefix caught ProductCatalog.Controllers.ProductsController, while framework logs are still at Warning. You can also set filters per provider. In the settings below the general level is Debug, but the Console provider writes only Warning and above for the same category:
{
"Logging": {
"LogLevel": {
"ProductCatalog": "Debug"
},
"Console": {
"LogLevel": {
"ProductCatalog": "Warning"
}
}
}
}
This is useful when you want to keep the console quiet and send the detail to a file or a central logging system.
Structured Logging: The Message Template
Now for the second question. Let's add an action that writes the same log with string interpolation:
_logger.LogInformation($"Getting product {id}");
In the console the two lines look identical: Getting product 1. To see the difference, let's write the logs as JSON:
builder.Logging.AddJsonConsole(options =>
{
options.IncludeScopes = true;
options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = true };
});
The State of the log written with a message template:
"State": {
"Message": "Getting product 1",
"ProductId": 1,
"{OriginalFormat}": "Getting product {ProductId}"
}
And the one written with interpolation:
"State": {
"Message": "Getting product 1",
"{OriginalFormat}": "Getting product 1"
}
In the first one, ProductId stands as a separate field, and it is a number. In a system like Seq, Elasticsearch or Application Insights you can say "give me every log where ProductId = 1." In the second one all you have is text; to find anything you have to search inside it. On top of that, interpolation builds the string every time, even when the log level is turned off. The rule is simple: always give log methods a constant template and separate arguments. Names in the template are matched by position, not by variable name; {ProductId} takes the first argument.
Scopes: Tying Together All Logs of an Operation
When you want to attach shared information to every log written during an operation, you open a scope:
[HttpPost]
public ActionResult<Product> Create(Product product)
{
using (_logger.BeginScope("Creating product {ProductName}", product.Name))
{
_logger.LogInformation("Validating price {Price}", product.Price);
Product created = _store.Add(product);
_logger.LogInformation("Product saved with id {ProductId}", created.Id);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
}
Since IncludeScopes is on in the JSON console, every log inside the using block carries the scope information. Two entries from the Scopes list of the second log:
{
"Message": "RequestPath:/api/products RequestId:0HNPPU6FR8L8Q:00000002",
"RequestId": "0HNPPU6FR8L8Q:00000002",
"RequestPath": "/api/products"
},
{
"Message": "Creating product USB-C Hub",
"ProductName": "USB-C Hub",
"{OriginalFormat}": "Creating product {ProductName}"
}
Next to our scope, the list also contains scopes ASP.NET Core opens on its own: TraceId, ConnectionId, RequestId and the name of the running action. When hundreds of requests are being processed at once, these fields are how you pick out the lines that belong to one request. The default console format does not show scopes; the information is there, it is just not printed.
The LoggerMessage Source Generator
On hot paths every log call pays a small price: arguments are boxed into object and the template is parsed every time. The LoggerMessage source generator introduced in .NET 6 moves that work to compile time:
namespace ProductCatalog.Logging;
public static partial class LogMessages
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Warning, Message = "Product {ProductId} not found")]
public static partial void ProductNotFound(ILogger logger, int productId);
}
You do not write the method body, the compiler generates it. Using it is an ordinary method call:
LogMessages.ProductNotFound(_logger, id);
warn: ProductCatalog.Controllers.ProductsController[1001]
Product 99 not found
The event id in square brackets is no longer 0 but 1001. Because the parameter type is fixed as int, you cannot pass an argument of the wrong type, and if the template and parameter names do not match you get a compile error. You do not have to do this for every log; use it in code that runs thousands of times a second and for messages you want to standardize across the team.
Moving to Serilog
Built-in logging comes with basic providers such as console, debug and event source; to write to a file or ship logs to a system like Seq you need a library. The most common choice is Serilog. The version matters here: Serilog.AspNetCore 8.0.3 is the last version that supports the net6.0 target. 9.0.0 and 10.0.0 require net8.0 at minimum. 8.0.3 also works fine in a .NET 10 project; only in a project that targets .NET 10 alone can you pick the current 10.x version.
dotnet add package Serilog.AspNetCore --version 8.0.3
using ProductCatalog.Services;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File("logs/catalog-.log", rollingInterval: RollingInterval.Day));
builder.Services.AddControllers();
builder.Services.AddSingleton<IProductStore, InMemoryProductStore>();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.MapControllers();
app.Run();
Serilog reads its levels from its own section, so the Logging section in appsettings.json is replaced by this:
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning"
}
}
},
"AllowedHosts": "*"
}
[00:30:37 INF] Getting product 1
[00:30:37 INF] HTTP GET /api/products/1 responded 200 in 43.8778 ms
[00:30:37 INF] Getting product 99
[00:30:37 WRN] Product 99 not found
[00:30:37 INF] HTTP GET /api/products/99 responded 404 in 1.3335 ms
We did not touch the controller code; the same ILogger<T> calls now flow into Serilog. UseSerilogRequestLogging writes a single summary line per request; turning off the framework's ten lines of noise and keeping this one line is ideal for most APIs. The file sink opens daily files too: logs/catalog-20261104.log. .NET 6 also has a built-in HTTP logging middleware (AddHttpLogging/UseHttpLogging); look at it when you need to log headers and bodies in detail.
Common Pitfalls
First, interpolation. As you saw above, it works, but it throws away the whole benefit of structured logging. When you see Log...($" in a code review, stop.
Second, logging sensitive data. Once a password, token, card number or personal data gets into a log, everyone with access to the logging system has it. Think twice before logging an entire request body or user object.
Third, putting the exception in the wrong place. _logger.LogError(ex, "Failed to save product {ProductId}", id) is the right usage: the exception is the first parameter and is stored along with its stack trace. _logger.LogError("Failed: " + ex.Message) loses the stack trace.
Fourth, leaving Default at Debug in production. Log volume multiplies overnight, and so do your disk and logging system bills. Turn on detailed levels only for the category you need, and only temporarily.
Fifth, expensive arguments. In _logger.LogDebug("Cart: {Cart}", BuildCartSummary()), BuildCartSummary() runs even when Debug is off. In cases like that, guard it with if (_logger.IsEnabled(LogLevel.Debug)) or use LoggerMessage.
A Small Experiment
★☆☆ Add a LogInformation to the Delete action, then start the app with --Logging:LogLevel:ProductCatalog=Warning on the command line. Watch which lines disappear.
★★☆ Turn on AddJsonConsole and make the LogDebug in GetById visible. Confirm in the output that ProductName comes as a separate property. Then write the same log with interpolation and compare.
★★★ Add a method called ProductCreated to the LogMessages class, at Information level, taking ProductId and ProductName. Write {ProductName} in the template but name the parameter name, and read what the compiler says.
So far every example has worked with an in-memory list; every time the app restarted, the products we added were gone. In the next post we will build a real data layer with EF Core and SQLite: registering the DbContext, the connection string, the first migration and wiring CRUD into the controller. The log levels you learned to turn on today will also come in handy for seeing the SQL EF Core generates in the console.