Lesson 28 • Advanced Track
Logging Architecture
By the end of this lesson you'll be able to build production-grade logging in C#: choose the right log level, write structured logs that tools can query, wire up ILogger and providers through dependency injection, send logs to multiple destinations with Serilog — and know exactly what you must never log.
What You'll Learn
- Use the six log levels (Trace through Critical) and filter by a minimum level
- Tell structured logging from string-concatenated logs — and why it matters
- Inject and use ILogger<T> with the .NET logging providers
- Configure Serilog and route logs to multiple sinks (console, file, server)
- Decide what to log — and what you must never log (secrets, PII)
- Build a small, level-aware logger from scratch to cement the ideas
ILogger<T> is injected exactly the way you injected services in that lesson, so being comfortable with DI makes this one click into place.💡 Real-World Analogy
A log is the flight recorder — the black box — of your application. A plane's recorder runs continuously, capturing what happened at each moment so that after an incident investigators can reconstruct events without putting the plane back in the air. Your logs do the same: a production bug usually can't be reproduced on demand, so the only evidence you have is what the running system wrote down at the time. Log levels are like the recorder's channels — routine cockpit chatter versus a stall warning — and you tune how much detail you keep. And structured logging is the difference between a searchable digital recorder and a scribbled paper note: both capture the event, but only one lets you instantly find every flight where the same fault appeared.
📊 The Six Log Levels
| Level | Severity | Use it for | Example |
|---|---|---|---|
| Trace | 0 (lowest) | Step-by-step diagnostics | Entering Calculate(x=5) |
| Debug | 1 | Development-time detail | Cache miss for user:123 |
| Information | 2 | Normal, expected flow | Order ORD-001 processed |
| Warning | 3 | Unexpected but handled | Retry 2/3 for gateway |
| Error | 4 | An operation failed | DB connection timed out |
| Critical | 5 (highest) | App/data is at risk | Out of memory — exiting |
A typical minimum level: Debug in development (see everything), Information or Warning in production (signal over noise), and Trace only temporarily while chasing a specific bug. Each level's number is what makes filtering a simple >= comparison.
1. One Method, One Choke Point
Before any framework, understand what a logger fundamentally is: a single method every message passes through, so the format and destination live in one place. You model the levels with an enum — a named set of constants — and route everything through one Log(level, message) method. Read this worked example and run it, then you'll write the method body yourself.
Worked example: a level-aware Console logger
Read every comment, run it, and notice every line goes through one method.
using System;
// A LogLevel enum, lowest severity to highest. The NUMBER behind each name
// (Trace = 0 ... Critical = 5) is what lets us compare and filter later.
enum LogLevel { Trace, Debug, Information, Warning, Error, Critical }
class Logger
{
// The heart of any logger: ONE place that decides how a line is written.
public void Log(LogLevel level, string message)
{
// {level} prints the enum name (e.g. "Information"); -11 left-pads it.
Console.WriteLine($"[{lev
...Your turn. The Log method is empty and two calls are missing pieces — fill in the three ___ blanks using the hints, then run it.
🎯 Your turn: write the Log method
Fill in the ___ blanks, then check the two lines match the expected output.
using System;
// 🎯 YOUR TURN — finish the Log method, then press "Try it Yourself".
enum LogLevel { Trace, Debug, Information, Warning, Error, Critical }
class Logger
{
public void Log(LogLevel level, string message)
{
// 1) Print one line in the form: [LEVEL] message
// Use string interpolation. {level} prints the enum name.
___; // 👉 Console.WriteLine($"[{level}] {message}");
}
}
class Program
{
static void Main()
{
var log = new L
...2. Filtering by Level (the Volume Knob)
You almost never want every message. A minimum level acts like a volume knob: anything below it is dropped before it's ever written. Because an enum compares by its underlying number, the filter is one line — if (level < MinimumLevel) return;. Set the knob to Debug in development and Warning in production, with no other code changes. Study the worked example, then implement the check yourself.
Worked example: drop messages below a minimum level
Run it and see Trace and Information silently filtered out.
using System;
enum LogLevel { Trace, Debug, Information, Warning, Error, Critical }
class Logger
{
// The "volume knob": anything BELOW this level is silently dropped.
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
public void Log(LogLevel level, string message)
{
// Enums compare by their underlying number, so Warning (3) >= Information (2).
if (level < MinimumLevel) return; // too quiet to care about — skip it
Console.WriteLine($
...Now you try. Add the filtering condition and set the minimum level so only Warning and above get through. Fill in the two ___ blanks:
🎯 Your turn: add level filtering
Implement the skip condition; only Warning and Error should print.
using System;
// 🎯 YOUR TURN — add level filtering so quiet messages are skipped.
enum LogLevel { Trace, Debug, Information, Warning, Error, Critical }
class Logger
{
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
public void Log(LogLevel level, string message)
{
// 1) If this message is LESS important than MinimumLevel, stop here.
// Enums compare like numbers: Trace(0) < Debug(1) < ... < Critical(5).
if (___) return; // 👉 level
...3. ILogger<T> and Structured Logging
In real .NET apps you don't build the logger — you receive one. ILogger<T> is the standard interface; you inject it through the constructor (just like in the DI lesson), and the <T> tags every line with that class's name as a category. The crucial habit is structured logging: instead of gluing values into a string, you pass a message template with named {Placeholders} and the values after it. Each value is captured as a searchable property, so later you can query "every log where OrderId = ORD-001".
The next three examples use Microsoft.Extensions.Logging and Serilog. The in-browser runner doesn't ship those packages, so read these as worked examples and run them in a real project — the expected output is shown in the comments.
Worked example: ILogger<T> with DI and structured templates
Study how {OrderId} and {Amount} become queryable properties, not flat text.
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
class OrderProcessor
{
// ILogger<T> is the .NET logging interface. The <OrderProcessor> tags every
// line with this class name as its "category", for free.
private readonly ILogger<OrderProcessor> _logger;
public OrderProcessor(ILogger<OrderProcessor> logger) => _logger = logger;
public void ProcessOrder(string orderId, decimal amount)
{
// STRUCTURED logging: {O
...Here's the single most important habit in this lesson side by side: the same event logged the wrong way and the right way. They produce nearly identical console text, but only the template version is searchable in a log aggregator.
Worked example: structured template vs string concatenation
Compare the searchable template with flat, glued-together strings.
using Microsoft.Extensions.Logging;
// Two ways to log the same event. They look almost identical — but only one
// is useful at 3am when you're searching millions of log lines.
class Demo
{
public void Show(ILogger logger, string email, string orderId, decimal amount)
{
// ❌ BAD — string concatenation/interpolation. The values are baked into
// a flat string. A log tool sees ONE blob of text it can't query.
logger.LogInformation("User " + email + " placed ord
...🔎 Deep Dive: providers — how a log line reaches a destination
When you call _logger.LogInformation(...), the message doesn't go anywhere by itself. It's handed to every registered provider — a small adapter that knows how to write to one destination. AddConsole() registers the console provider; there are also providers for Debug output, the Windows Event Log, Azure App Service, and more. One log call, fanned out to all of them.
This is why ILogger code stays the same no matter where logs end up: your class depends on the interface, and configuration at startup decides the providers. Swapping console for a file, or adding a second destination, never touches your business logic.
services.AddLogging(b =>
{
b.SetMinimumLevel(LogLevel.Information); // the volume knob
b.AddConsole(); // provider 1: terminal
b.AddDebug(); // provider 2: IDE debug window
});Serilog plugs into this same pipeline but adds its own richer model of destinations, which it calls sinks — that's the next example.
4. Serilog and Sinks
Serilog is the most popular third-party logging library, built around structured logging from the ground up. Its key idea is the sink: a destination for log events. You configure a list of sinks once at startup — console, a rolling file, a queryable server like Seq, cloud services — and every log event is written to all of them. The logging calls themselves use the same {Placeholder} template style as ILogger, so what you learned above carries straight over.
Worked example: Serilog writing to multiple sinks
Study how one configuration fans logs out to the console and a rolling file.
using Serilog;
// Serilog is the most popular third-party logger. A "sink" is a DESTINATION —
// where the logs go. You can attach as many as you like at once.
class Program
{
static void Main()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console() // sink 1: the terminal
.WriteTo.File("logs/app-.txt", // sink 2: a rolling file
rollingInterval: RollingInterval.Da
...What to Log — and What You Must Never Log
Logs are written in plain text, copied to multiple sinks, and often shipped to third-party services and read by many people. Treat every log line as if it could end up in a data breach — because it can.
✅ Do log:
- • Identifiers you can safely correlate on: order IDs, request IDs, user IDs (not the user's password)
- • What happened and the outcome: "Order processed in 45ms", "Retry 2/3 failed"
- • Exceptions with their stack trace (
LogError(ex, "..."))
❌ Never log:
- • Passwords, API keys, tokens, connection strings, or other secrets
- • Personal data (PII): full card numbers, national IDs, health data, raw email/address where the law restricts it
- • Whole request/response bodies that may contain any of the above
If you must reference sensitive data, log a masked or hashed form — e.g. card ending 4242, not the full number.
Putting It Together: a Category + Timestamp Logger
Here's a small but realistic logger that combines everything runnable from this lesson — a level enum, a minimum-level filter, a category, a timestamp, and friendly Info/Warn/Error helpers. It mirrors what ILogger does for you in production, built from parts you now understand line by line.
Worked example: an AppLogger with category & timestamp
Run it; change MinimumLevel and watch which lines survive the filter.
using System;
enum LogLevel { Trace, Debug, Information, Warning, Error, Critical }
// A small but realistic logger: it filters by level, stamps each line with the
// time and a category, and exposes friendly helper methods. Everything you'd
// reach for ILogger to do — built from the parts you now understand.
class AppLogger
{
private readonly string _category;
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
public AppLogger(string category) => _category = cate
...The helper methods (Info, Warn, Error) are exactly why ILogger gives you LogInformation, LogWarning, and LogError — they're thin wrappers over the one core Log method.
Pro Tips
- 💡 Always use message templates, never
$"..."in real logger calls:LogInformation("User {Id}", id)keeps the value queryable; interpolation throws that away. - 💡 Guard expensive log arguments with
if (logger.IsEnabled(LogLevel.Debug))so you don't build a costly string the filter will only discard. - 💡 Use the
[LoggerMessage]source generator for hot paths — it generates allocation-free logging methods at compile time. - 💡 Add a
scope(using logger.BeginScope(...)) to attach a request ID to every log inside it, so you can trace one request end to end. - 💡 Call
Log.CloseAndFlush()on shutdown with Serilog, or buffered log lines can be lost when the process exits.
Common Errors (and the fix)
- Logging secrets or PII:
LogInformation("Login {Pwd}", password)leaks a credential into every sink. Never log secrets or personal data — log an ID or a masked value (card ending 4242) instead. - String-concatenated logs:
LogInformation("User " + id + " paid")orLogInformation($"User {id} paid")produces a flat, unsearchable string. Use a template:LogInformation("User {Id} paid", id). - Wrong level — everything at Information: if routine flow and real failures share a level, you can't filter signal from noise. Reserve
Error/Criticalfor genuine failures and demote chatter toDebug/Trace. - Expensive work without a level check:
LogDebug("State: {S}", BuildHugeReport())callsBuildHugeReport()even when Debug is filtered out. Wrap it inif (logger.IsEnabled(LogLevel.Debug)). - Lost logs on exit (Serilog): the process ends before buffered events are written. Call
Log.CloseAndFlush()before the app shuts down.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Inject a logger | ILogger<Order> logger | Category = class name |
| Info (structured) | LogInformation("Id {Id}", id) | Template, not $"..." |
| Log an exception | LogError(ex, "Failed {Id}", id) | Exception goes first |
| Set minimum level | b.SetMinimumLevel(LogLevel.Warning) | The volume knob |
| Add a provider | b.AddConsole() | One destination |
| Guard expensive log | if (logger.IsEnabled(...)) | Skip costly args |
| Serilog console sink | .WriteTo.Console() | A destination |
| Serilog flush | Log.CloseAndFlush() | On shutdown |
Frequently Asked Questions
Q: Why is LogInformation("Id {Id}", id) better than $"Id {id}"?
The template keeps Id as a named, searchable property in the log store; interpolation flattens it into plain text. Both look the same in the console, but only the template lets you later query or alert on that value.
Q: What's the difference between a provider and a sink?
They're the same idea under two names. Microsoft.Extensions.Logging calls a destination a provider; Serilog calls it a sink. Both are adapters that write a log event somewhere — console, file, server.
Q: ILogger is built in — why would I add Serilog?
Use the built-in ILogger abstraction in your code regardless. Serilog plugs in behind it to provide richer sinks, easy file rolling, and powerful structured output. Your classes still depend only on ILogger<T>.
Q: Should I ever log a password to debug a login issue?
No — never, not even temporarily. Logs are persisted and copied widely, so a secret in a log is a leaked secret. Log the username or a request ID, and a masked or hashed value if you truly need to correlate.
Q: What level should production run at?
Usually Information or Warning as the minimum — enough to see normal flow and every problem, without drowning in Trace/Debug noise. Drop to Debug or Trace only while actively investigating.
Mini-Challenge: Build a Small Logger
No blanks this time — just a brief and an outline. Build a Logger with a category, a minimum-level filter, and a timestamped output line, then exercise it from Main. Run it and confirm only the Warning and Error lines survive the filter, as shown in the comments.
🎯 Mini-Challenge: a category + timestamp logger
Write the class and use it; only Warning and Error should print.
using System;
// 🎯 MINI-CHALLENGE: Build a small logger
// 1. Define an enum LogLevel { Trace, Debug, Information, Warning, Error, Critical }.
// 2. Make a class Logger with:
// - a string Category set in the constructor
// - a LogLevel MinimumLevel property (default Information)
// - Log(LogLevel level, string message):
// * skip the message if level < MinimumLevel
// * otherwise print: HH:mm:ss [LEVEL] Category: message
// (timestamp with DateTime
...🎉 Lesson Complete
- ✅ Six levels, lowest to highest:
Trace,Debug,Information,Warning,Error,Critical - ✅ A minimum level filters out anything quieter — one knob, dev vs production
- ✅ Structured logging uses message templates so values stay queryable; never
$"..." - ✅
ILogger<T>is injected via DI and fans out to providers - ✅ Serilog writes to one or more sinks (console, file, server) — flush on shutdown
- ✅ Never log secrets or PII; log IDs and masked values instead
- ✅ Next lesson: Streams, Buffers & Pipelines — high-performance file and data I/O
Where This Goes Next
Logging is one half of observability; the other halves are metrics and distributed tracing. Once you're comfortable here, look at ILogger.BeginScope for per-request context, and the OpenTelemetry libraries for correlating logs, traces, and metrics across services.
Sign up for free to track which lessons you've completed and get learning reminders.