Logging
By the end of this lesson
Produce structured logs with appropriate levels instead of console output.
A log is read at the worst possible moment: after something has already gone wrong, usually by someone who cannot reproduce it, in a system handling many operations at once. Everything useful about a log line follows from that.
Console.WriteLine produces a string. It has no level, so you cannot filter it. It has no source, so you cannot tell which class wrote it. It has no fields, so you cannot search for one employee's records among a million lines. And it goes to standard output and nowhere else, so it cannot be sent to a log service without changing code.
ILogger<T> fixes each of those. The type parameter becomes the category, so every line carries the class that wrote it. Every line has a level you can filter on, per category, from configuration. And the values in a line stay separate from the sentence around them, which is the part that changes how a log is used.
Six levels, with what each one means in the employees API. Choosing a level is choosing who should notice:
- Trace
- The finest detail, including values you would only want while debugging one specific thing. Off everywhere by default. Assume it may contain data you would not want retained, and do not enable it in production casually.
- Debug
- Information useful to a developer diagnosing behaviour: which branch was taken, how many rows came back. On locally, off in production.
- Information
- The normal record of what the application did. An employee was updated, a sync completed, the service started. This is the level that tells the story afterwards, and the one most likely to be overused.
- Warning
- Something unexpected that the application handled. A retry succeeded on the second attempt, a cache lookup failed and fell back to the database, a configuration value was missing and a default applied. Nobody is woken up, and somebody should look.
- Error
- An operation failed and could not be completed. One request, one message, one job. Include the exception. The application continues.
- Critical
- The application cannot continue, or something irrecoverable has happened: it cannot reach its database at start-up, it is out of disk. Reserve it, because an alert on Critical is only useful if it is rare.
public sealed class EmployeeService(
IEmployeeStore store,
ILogger<EmployeeService> logger)
{
public async Task<bool> PromoteAsync(int employeeId, string newTitle)
{
// Wrong: one interpolated string, no fields, nothing to search on
// logger.LogInformation($"Promoting employee {employeeId} to {newTitle}");
// Right: a template plus values the logging system keeps separate
logger.LogInformation(
"Promoting employee {EmployeeId} to {JobTitle}", employeeId, newTitle);
using (logger.BeginScope("Promotion for {EmployeeId}", employeeId))
{
Employee? employee = await store.GetAsync(employeeId);
if (employee is null)
{
logger.LogWarning("Employee {EmployeeId} not found", employeeId);
return false;
}
try
{
await store.UpdateTitleAsync(employeeId, newTitle);
return true;
}
catch (DbUpdateException ex)
{
logger.LogError(ex, "Failed to promote employee {EmployeeId}", employeeId);
return false;
}
}
}
}- The commented line and the line below it produce the same sentence on a console. They are not the same log entry. The interpolated version hands the logging system a finished string, and the employee id exists only as characters inside it.
- The template version keeps EmployeeId and JobTitle as named fields. In any log store that understands structure, you can query for one employee's entries, count promotions by title, or alert on a rate — none of which is possible against a sentence without resorting to substring matching.
- Values are matched to placeholders by position, not by name. The first argument fills the first placeholder whatever it is called, so a mismatched order produces a confidently wrong log entry.
- Placeholder names are part of the contract. Keep them stable, because dashboards and alerts are written against them. Renaming EmployeeId to Id later breaks queries silently.
- BeginScope attaches its values to every entry written inside the block, including entries from code further down the call chain. That is how several lines are tied back to one operation without every method taking an identifier for logging purposes.
- The exception goes in as the first argument to LogError, not into the message. That preserves the type, the message and the stack trace as structure. Pasting ex.Message into the template loses the stack trace, which is the part you wanted.
- Not found is a Warning here, and Information would also be defensible — a caller asking about a deleted employee is an ordinary outcome. It is not an Error: nothing failed, and an error-rate alert that counts it will cry wolf.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"Anvi.Employees.Core.Leave": "Debug"
}
}
}- Each key is a category prefix and each value is the minimum level that gets written for it. Categories come from the type parameter on ILogger<T>, which is why they read as namespaces.
- The longest matching prefix wins. A logger in Anvi.Employees.Core.Leave gets Debug; everything else under Anvi gets the Default of Information.
- Turning framework categories down to Warning is the usual first move on a noisy service. Framework components log a great deal at Information, and most of it is not about your application.
- Raising one namespace to Debug in production is a legitimate, temporary diagnostic tool. Set it, capture what you need, and put it back — Debug at volume is expensive and buries the lines you actually watch.
- Because this is configuration, none of it needs a code change or a rebuild. On a running service it can be changed by a deployment of settings alone.
The trade-off worth stating: logging is not free. Every entry costs processing time, network transfer to wherever logs are collected, storage for as long as you retain it, and indexing in whatever queries it. On a busy service, logging is a measurable share of the work.
That cost is the argument for levels rather than for logging less. Write Debug lines generously and leave them off in production, where you can turn one category up for an hour when you need it. Keep Information for the events that describe what the application did, and be strict about what qualifies.
One more thing worth knowing before you go looking for it. A message template with named placeholders is not specific to one log destination. The same code writes to the console locally and to a log service in production, and the fields survive into both, because the provider is configuration rather than a code decision. That is why the effort of writing templates pays off later rather than immediately.
Summary
- ILogger<T> gives every line a category, a level and named fields; Console.WriteLine gives you a string and nothing else
- Use a message template with named placeholders — an interpolated string discards the fields that make a log queryable
- Arguments fill placeholders by position, placeholder names are a contract dashboards depend on, and an exception belongs in the first argument to LogError rather than in the template
- Choose a level by asking who should notice, and filter by category from configuration rather than deleting log calls
- Never log secrets or personal data, and be careful with whole objects, full URLs and library exceptions that carry parameters
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Convert and compare
Take two log calls that use interpolated strings and rewrite them as templates with named placeholders. Run the application and look at the console output.
The two versions look nearly identical on screen. Write down, for each version, how you would answer this question against a million lines: which operations touched employee 4192?
Show solution
With templates the answer is a query on the EmployeeId field, and it is exact. With interpolated strings the only option is a substring search for 4192, which also matches employee 41920, a salary of 4192, a duration in milliseconds and a row count.
That is the entire argument. The console output is nearly the same, and the two are not the same data. One has fields; the other has characters that happen to look like fields.
Add a BeginScope around one operation and look again. Every line inside the block now carries the operation's identifier, so lines written three calls deep are still attributable to it.
// Before
logger.LogInformation($"Imported {count} employees from {fileName}");
// After
logger.LogInformation("Imported {EmployeeCount} employees from {FileName}", count, fileName);Think about it
Pick the level for each of these
Choose a level, and justify it: a request for an employee who does not exist; the database being unreachable at start-up; a call to the payroll service timing out and succeeding on retry; an employee record being updated; the exact SQL of every query.
Show solution
Employee not found: Information, or Warning if it is genuinely unusual in your system. Not an Error — the application worked correctly and gave a correct answer to a question about something that is not there. Logging it as an Error corrupts every error-rate alert you build.
Database unreachable at start-up: Critical. The application cannot do its job at all, and this is precisely the case an alert should exist for.
Timeout that succeeded on retry: Warning. It was handled, so nothing is broken, and a rising count of these is an early signal about the payroll service. Silence here throws away the warning sign.
Employee updated: Information. This is the business event that makes the log a record of what happened.
Every query's SQL: Debug at most, and off in production. It is high volume and it frequently contains parameter values, which means personal data in a log store.
The test that resolves most of these: who should notice. Nobody, for Debug. Someone reviewing later, for Information. Someone looking at trends, for Warning. Someone today, for Error. Someone now, for Critical.
Saved in this browser only.