Logging
In the previous chapter, we used Console.WriteLine to observe execution order. As logs grow, we may want to see output from only one class or temporarily enable debug details. ILogger can handle this for us.
This chapter also keeps the Todo identifier as a separate field, so logging tools can search directly by that identifier. This is called structured logging.
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<ITodoStore, InMemoryTodoStore>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
var todosApi = app.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id, ITodoStore store, ILogger<Program> logger) =>
{
var todo = store.Find(id);
if (todo is null)
{
logger.LogWarning("Todo {TodoId} not found", id);
return TypedResults.NotFound();
}
return TypedResults.Ok(todo);
});
todosApi.MapPost("/", Created<Todo> (CreateTodo input, ITodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);
interface ITodoStore
{
Todo? Find(int id);
Todo Add(string title);
}
class InMemoryTodoStore(ILogger<InMemoryTodoStore> logger) : ITodoStore
{
private readonly List<Todo> _todos = [];
private readonly Lock _lock = new();
private int _nextId = 1;
public Todo? Find(int id)
{
lock (_lock)
{
logger.LogDebug("Looking up Todo {TodoId}; {Count} item(s) currently exist", id, _todos.Count);
return _todos.Find(t => t.Id == id);
}
}
public Todo Add(string title)
{
Todo todo;
lock (_lock)
{
todo = new Todo(_nextId++, title, Done: false);
_todos.Add(todo);
}
logger.LogInformation("Created Todo {TodoId} with title: {Title}", todo.Id, todo.Title);
return todo;
}
}The example reuses ITodoStore from “Dependency Injection” and writes logs in both the handler and the storage service. The development configuration file adds one line:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"InMemoryTodoStore": "Debug"
}
}
}Run and verify
Stop the service from the previous chapter, then run this from the repository root:
cd samples/14-logging
dotnet runCreate a Todo, retrieve it, then request one that does not exist:
curl -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"title":"Buy milk"}'
curl http://localhost:5080/todos/1
curl http://localhost:5080/todos/99After the startup messages, the service terminal shows:
info: InMemoryTodoStore[0]
Created Todo 1 with title: Buy milk
dbug: InMemoryTodoStore[0]
Looking up Todo 1; 1 item(s) currently exist
dbug: InMemoryTodoStore[0]
Looking up Todo 99; 1 item(s) currently exist
warn: Program[0]
Todo 99 not foundThe first line of each log has three parts: info / dbug / warn is the level, InMemoryTodoStore or Program is the category, and the [0] in brackets is the event ID (unused in this chapter). The second line is the log message.
Get an ILogger
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<ITodoStore, InMemoryTodoStore>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
var todosApi = app.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id, ITodoStore store, ILogger<Program> logger) =>
{
var todo = store.Find(id);
if (todo is null)
{
logger.LogWarning("Todo {TodoId} not found", id);
return TypedResults.NotFound();
}
return TypedResults.Ok(todo);
});
todosApi.MapPost("/", Created<Todo> (CreateTodo input, ITodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);
interface ITodoStore
{
Todo? Find(int id);
Todo Add(string title);
}
class InMemoryTodoStore(ILogger<InMemoryTodoStore> logger) : ITodoStore
{
private readonly List<Todo> _todos = [];
private readonly Lock _lock = new();
private int _nextId = 1;
public Todo? Find(int id)
{
lock (_lock)
{
logger.LogDebug("Looking up Todo {TodoId}; {Count} item(s) currently exist", id, _todos.Count);
return _todos.Find(t => t.Id == id);
}
}
public Todo Add(string title)
{
Todo todo;
lock (_lock)
{
todo = new Todo(_nextId++, title, Done: false);
_todos.Add(todo);
}
logger.LogInformation("Created Todo {TodoId} with title: {Title}", todo.Id, todo.Title);
return todo;
}
}A logger is a service, and we get it in exactly the same way as in “Dependency Injection”:
- On line 19, the handler declares an
ILogger<Program>parameter. - On line 48,
InMemoryTodoStoredeclaresILogger<InMemoryTodoStore>in its constructor. The parentheses after the class name use a primary constructor, introduced in C# 12. Itsloggerparameter is available throughout the class.
The framework has already registered the logging services; you do not need to call an AddXxx() method.
The type in angle brackets determines the log category. In this example, the categories are Program and InMemoryTodoStore; we can use them to tell where a message came from and adjust their output levels separately. Program is the class name the compiler generates for top-level statements.
Log levels
.NET logging has six levels, from lowest to highest:
| Level | Method | Use |
|---|---|---|
| Trace | LogTrace | The most detailed tracing information, usually enabled only to investigate a specific problem |
| Debug | LogDebug | Useful information during development and debugging |
| Information | LogInformation | Important events during normal operation, such as “a Todo was created” |
| Warning | LogWarning | An unusual situation that does not stop the application, such as “the requested resource was not found” |
| Error | LogError | The current operation failed, for example because of an unhandled exception |
| Critical | LogCritical | The application as a whole is at risk of failing, for example because the disk is full |
This example logs creation at Information, lookups at Debug, and missing items at Warning so that we can compare the output. A missing resource may not deserve a warning in a typical application; you can choose a lower level. Production systems often need Information logs to observe business activity, so it is not always necessary to keep only Warning and above.
Control output by category
Logging:LogLevel in appsettings.json sets the minimum level for each category. Logs below that level are discarded:
| Key | Value | Meaning |
|---|---|---|
Default | Information | For categories without a specific setting, output Information and above |
Microsoft.AspNetCore | Warning | Output only Warning and above for framework logs, avoiding excessive noise |
InMemoryTodoStore | Debug | Added to the development configuration in this chapter so the store outputs Debug logs |
Categories are matched by prefix. When more than one prefix matches, the more specific one takes precedence. Microsoft.AspNetCore can set a default level for categories such as Routing beneath it, with a more specific category overriding that value.
That is why Debug logs appear only in the development environment. When running with the production environment (which does not load appsettings.Development.json), the same three requests produce only:
info: InMemoryTodoStore[0]
Created Todo 1 with title: Buy milk
warn: Program[0]
Todo 99 not foundTo investigate a problem, you can override the level with an environment variable before starting the application, without changing code. For example, Logging__LogLevel__Default=Debug changes the default rule but does not override a more specific category rule. Restart the process after changing an environment variable so the new value is read.
Message templates
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<ITodoStore, InMemoryTodoStore>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
var todosApi = app.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id, ITodoStore store, ILogger<Program> logger) =>
{
var todo = store.Find(id);
if (todo is null)
{
logger.LogWarning("Todo {TodoId} not found", id);
return TypedResults.NotFound();
}
return TypedResults.Ok(todo);
});
todosApi.MapPost("/", Created<Todo> (CreateTodo input, ITodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);
interface ITodoStore
{
Todo? Find(int id);
Todo Add(string title);
}
class InMemoryTodoStore(ILogger<InMemoryTodoStore> logger) : ITodoStore
{
private readonly List<Todo> _todos = [];
private readonly Lock _lock = new();
private int _nextId = 1;
public Todo? Find(int id)
{
lock (_lock)
{
logger.LogDebug("Looking up Todo {TodoId}; {Count} item(s) currently exist", id, _todos.Count);
return _todos.Find(t => t.Id == id);
}
}
public Todo Add(string title)
{
Todo todo;
lock (_lock)
{
todo = new Todo(_nextId++, title, Done: false);
_todos.Add(todo);
}
logger.LogInformation("Created Todo {TodoId} with title: {Title}", todo.Id, todo.Title);
return todo;
}
}Notice the log message format: "Todo {TodoId} not found", followed by the id argument. This is not string interpolation (there is no $ prefix). It is a message template: the braces contain a placeholder name, and the argument values fill the placeholders in order.
Ordinary console output does not show whether the fields were preserved. Stop the current service, start it with JSON formatting, and inspect the same log:
dotnet run -- --Logging:Console:FormatterName=json --Logging:Console:FormatterOptions:JsonWriterOptions:Indented=trueAfter requesting /todos/99, the Warning log looks like this:
{
"EventId": 0,
"LogLevel": "Warning",
"Category": "Program",
"Message": "Todo 99 not found",
"State": {
"TodoId": 99,
"{OriginalFormat}": "Todo {TodoId} not found"
}
}State.TodoId is the number 99, which a logging platform can search as a field. If we first build a string with $"Todo {id} not found", the logging system receives only the complete sentence; it would need to parse the sentence to extract the identifier.
Note
Pass id as a separate argument to the logging method so the TodoId field is preserved. String interpolation builds the text in advance, even if that log is ultimately filtered out.
Tip
Use PascalCase for placeholder names and keep them consistent across the application. For example, use {TodoId} for every Todo identifier so logging tools can search all related entries under one field name.
FastAPI comparison
Python's logging module also supports deferred formatting with logger.warning("Todo %s not found", id), but does not preserve structured fields by default. ASP.NET Core's ILogger is structured from the start and needs no additional library.
Technical detail
For very frequent log calls, the [LoggerMessage] attribute and source generator can generate high-performance logging methods at compile time, further avoiding boxing and template parsing costs. For a tutorial of this size, calling methods such as LogInformation directly is sufficient.
Summary
- Get
ILogger<T>through dependency injection.Tdetermines the log category, and the framework registers the logging service for you. - The six levels run from Trace to Critical and cover detailed tracing, debugging, normal events, and errors of increasing severity.
Logging:LogLevelsets the minimum output level by category and matches prefixes. Configuration files for different environments let development output be more detailed and production output more concise.- Use message templates (
"… {TodoId}", id) instead of string interpolation so placeholders become searchable structured fields.
This is the final chapter in the “Application Structure” stage. Next: EF Core Basics—replace in-memory storage with a SQLite database so data survives a restart. Previous: Middleware.
