Logging
When a patient in Jaipur says "my booking failed at 10:42", you cannot attach a debugger to the production server. What you can do is read the logs: time-stamped messages your app wrote while it worked. Good logs tell you what happened, to which request, and why. Bad logs are either silent or so noisy that the one useful line is buried.
ASP.NET Core has logging built in. In this lesson you will write log messages with ILogger<T>, choose the right level, filter noise through appsettings.json, use structured message templates, log exceptions properly, and switch on request logging when you need it.
Writing your first log messages
CreateBuilder already registers logging with the Console, Debug and EventSource providers (plus the Windows Event Log on Windows). You only need to ask for an ILogger<T>, where T is the class that is logging:
namespace Clinic.Services;
public class BookingService(ILogger<BookingService> logger)
{
public int Book(string patient, int doctorId, string time)
{
int bookingId = 42; // the database would create this
logger.LogInformation("Booking {BookingId} confirmed for {Patient} with doctor {DoctorId} at {Time}",
bookingId, patient, doctorId, time);
return bookingId;
}
}When Book("Meera", 2, "10:30") runs, the console shows:
info: Clinic.Services.BookingService[0]
Booking 42 confirmed for Meera with doctor 2 at 10:30Each console entry has three parts: the level (info), the category (the full name of T, here Clinic.Services.BookingService) and the event ID in square brackets (0 when you do not set one), followed by the message on the next line.
Log levels
Every message has a level. Configuration then decides which levels are actually written.
| Level | Method | Console label | Use it for |
|---|---|---|---|
| Trace | LogTrace | trce | Very detailed steps; never in production |
| Debug | LogDebug | dbug | Information useful while developing |
| Information | LogInformation | info | Normal events: booking created, user signed in |
| Warning | LogWarning | warn | Unusual but handled: slot already taken, retrying |
| Error | LogError | fail | An operation failed: SMS could not be sent |
| Critical | LogCritical | crit | The app cannot continue: database unreachable |
A good habit: if a person must act on it, it is at least a Warning. If it happens on every request, it is Information or lower.
Structured logging with message templates
Notice that the message above uses placeholders such as {BookingId}, not string interpolation. This is called a message template. The console shows the filled-in text, but providers that store logs as data (Seq, Elasticsearch, Application Insights, OpenTelemetry back ends) also keep BookingId = 42 and Patient = Meera as separate, searchable fields. You can then ask "show every log for booking 42" instead of searching text.
// Good: structured, and formatting is skipped if the level is disabled
logger.LogWarning("Slot {Time} for doctor {DoctorId} is already booked", time, doctorId);
// Avoid: interpolation builds the string even when Warning is filtered out,
// and the values are lost as separate fields
logger.LogWarning($"Slot {time} for doctor {doctorId} is already booked");Placeholders are matched to arguments by position, not by name, so keep them in the same order.
Filtering with appsettings.json
The Logging:LogLevel section sets the minimum level per category. A category matches by prefix, and the longest matching prefix wins:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"Clinic.Services": "Debug"
}
}
}With this file, your own Clinic.Services classes log Debug and above, ASP.NET Core's internal messages are limited to Warning and above, and EF Core prints every SQL command it runs, which is very useful while learning. Put noisy settings like the EF Core one in appsettings.Development.json so production stays quiet. Because appsettings.json reloads automatically, changing levels takes effect without restarting the app.
Logging exceptions and scopes
Pass the exception object as the first argument. The provider then records the type, message and stack trace:
try
{
await smsGateway.SendAsync(phone, text);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "SMS reminder for booking {BookingId} could not be sent", bookingId);
}The console prints the message, then the exception type and message, then the stack trace, all indented under the fail: line.
A scope attaches values to every message logged inside a block, so you do not repeat them:
using (logger.BeginScope("Booking {BookingId}", bookingId))
{
logger.LogInformation("Checking slot");
logger.LogInformation("Saving booking");
}Structured providers record BookingId on both messages. The console provider shows scopes only when you enable them with "Console": { "FormatterOptions": { "IncludeScopes": true } } inside the Logging section.
Faster logging with LoggerMessage
For messages logged very often, the LoggerMessage source generator creates optimised logging methods at compile time. You declare a partial method with an attribute, and the compiler writes the body:
namespace Clinic.Services;
public static partial class BookingLog
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Warning,
Message = "Slot {Time} for doctor {DoctorId} is already booked")]
public static partial void SlotTaken(ILogger logger, string time, int doctorId);
}
// inside BookingService:
// BookingLog.SlotTaken(logger, "10:30", 2);warn: Clinic.Services.BookingService[1001]
Slot 10:30 for doctor 2 is already bookedThe category still comes from the logger you pass in, and the event ID 1001 now appears in the brackets, which makes alerts easy to set up ("notify me when event 1001 happens 50 times in an hour").
Logging HTTP requests
To see every request and response while debugging, add HTTP logging:
builder.Services.AddHttpLogging(options =>
{
options.LoggingFields = HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath
| HttpLoggingFields.ResponseStatusCode | HttpLoggingFields.Duration;
});
var app = builder.Build();
app.UseHttpLogging();It logs at Information under the category Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware, so add that category with "Information" to Logging:LogLevel, otherwise the template's "Microsoft.AspNetCore": "Warning" rule hides it. HttpLoggingFields lives in the Microsoft.AspNetCore.HttpLogging namespace.
Beyond the console
The built-in providers are enough to start. Larger systems usually send logs to a central store: OpenTelemetry (an open standard that also covers metrics and traces) or libraries such as Serilog and NLog that plug into the same ILogger interface. Your code does not change; only the providers registered in Program.cs do.
Common mistakes
- String interpolation in log calls. Use message templates so values stay structured and work is skipped for disabled levels.
- Logging everything at Information. Production logs become huge and the important lines are lost.
- Catching an exception and logging only
ex.Message. Passexitself so the stack trace is kept. - Logging secrets or personal data. Log identifiers, not the sensitive values.
- Editing levels in code. Keep them in
appsettings.jsonso they can change per environment without a rebuild.
Key points
- Inject
ILogger<T>; the category is the full name ofT, shown with the level and event ID. - Levels from lowest to highest: Trace, Debug, Information, Warning, Error, Critical.
- Use message templates with placeholders, not interpolation, for structured, efficient logs.
- Filter by category prefix in
Logging:LogLevel; put noisy development settings inappsettings.Development.json. - Pass exceptions as the first argument, use scopes for shared context, and
[LoggerMessage]for hot paths.
Exercise
Write a TicketService class in the namespace Railway.Services for a train booking app. It should log Information "Ticket {Pnr} booked for {Passenger} on train {TrainNo}" when booking succeeds, Warning "Waitlist {Position} given to {Passenger}" when the train is full, and Error with the exception when payment fails. Then write the Logging section that shows Debug messages for Railway.Services but only Warning for everything from Microsoft.
Show solution
namespace Railway.Services;
public class TicketService(ILogger<TicketService> logger)
{
public void Confirmed(string pnr, string passenger, int trainNo) =>
logger.LogInformation("Ticket {Pnr} booked for {Passenger} on train {TrainNo}", pnr, passenger, trainNo);
public void Waitlisted(int position, string passenger) =>
logger.LogWarning("Waitlist {Position} given to {Passenger}", position, passenger);
public void PaymentFailed(Exception ex, string passenger) =>
logger.LogError(ex, "Payment failed for {Passenger}", passenger);
}{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Railway.Services": "Debug"
}
}
}A call such as Confirmed("2456789012", "Ananya", 12955) prints info: Railway.Services.TicketService[0] followed by Ticket 2456789012 booked for Ananya on train 12955. The Microsoft prefix covers both Microsoft.AspNetCore and Microsoft.EntityFrameworkCore categories.
Quick Quiz
1. What is the category of a logger injected as ILogger<BookingService> in namespace Clinic.Services?
The category is the full type name of T, including the namespace.
2. Why prefer LogInformation("Booked {Id}", id) over LogInformation($"Booked {id}")?
Message templates enable structured logging and skip formatting for disabled levels.
3. Which console label means Error level?
The console shows fail for Error and crit for Critical.
4. With "Microsoft.AspNetCore": "Warning", why do you not see HTTP logging output by default?
Add the Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware category at Information to see it.
Learn ASP.NET Core with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
