Skip to content

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.

14-logging/Program.cs
cs
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:

14-logging/appsettings.Development.json
json
{
  "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:

bash
cd samples/14-logging
dotnet run

Create a Todo, retrieve it, then request one that does not exist:

bash
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/99

After the startup messages, the service terminal shows:

text
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 found

The 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 ​

14-logging/Program.cs
cs
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, InMemoryTodoStore declares ILogger<InMemoryTodoStore> in its constructor. The parentheses after the class name use a primary constructor, introduced in C# 12. Its logger parameter 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:

LevelMethodUse
TraceLogTraceThe most detailed tracing information, usually enabled only to investigate a specific problem
DebugLogDebugUseful information during development and debugging
InformationLogInformationImportant events during normal operation, such as “a Todo was created”
WarningLogWarningAn unusual situation that does not stop the application, such as “the requested resource was not found”
ErrorLogErrorThe current operation failed, for example because of an unhandled exception
CriticalLogCriticalThe 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:

KeyValueMeaning
DefaultInformationFor categories without a specific setting, output Information and above
Microsoft.AspNetCoreWarningOutput only Warning and above for framework logs, avoiding excessive noise
InMemoryTodoStoreDebugAdded 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:

text
info: InMemoryTodoStore[0]
      Created Todo 1 with title: Buy milk
warn: Program[0]
      Todo 99 not found

To 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 ​

14-logging/Program.cs
cs
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:

bash
dotnet run -- --Logging:Console:FormatterName=json --Logging:Console:FormatterOptions:JsonWriterOptions:Indented=true

After requesting /todos/99, the Warning log looks like this:

json
{
  "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. T determines 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:LogLevel sets 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.

Built with .NET 10 and Minimal APIs · Runnable examples in every chapter