Logging Architecture
Reviewed & published by Brayan K
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.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
💡 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.
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($"[{level,-11}] {message}");
}
}
class Program
{
static void Main()
{
var log = new Logger();
log.Log(LogLevel.Information, "App started"); // [Information] App started
log.Log(LogLevel.Warning, "Disk almost full"); // [Warning ] Disk almost full
log.Log(LogLevel.Error, "Save failed"); // [Error ] Save failed
// Every line goes through ONE method — change the format in one place
// and the whole app updates. That single choke point IS the lesson.
}
}
// ✅ Expected output:
// [Information] App started
// [Warning ] Disk almost full
// [Error ] Save failedYour turn. The Log method is empty and two calls are missing pieces — fill in the three ___ blanks using the hints, then run it.
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 Logger();
// 2) Log an Information message that says: User signed in
log.Log(LogLevel.Information, ___); // 👉 the message in "double quotes"
// 3) Log an Error message that says: Payment declined
log.Log(LogLevel.___, "Payment declined"); // 👉 the level name: Error
// ✅ Expected output:
// [Information] User signed in
// [Error] Payment declined
}
}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.
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($"[{level,-11}] {message}");
}
}
class Program
{
static void Main()
{
var log = new Logger { MinimumLevel = LogLevel.Warning }; // production-style
log.Log(LogLevel.Trace, "Entering Calculate()"); // dropped (Trace < Warning)
log.Log(LogLevel.Information, "Order processed"); // dropped (Info < Warning)
log.Log(LogLevel.Warning, "Retrying payment 2/3"); // [Warning ] Retrying payment 2/3
log.Log(LogLevel.Error, "Gateway timeout"); // [Error ] Gateway timeout
// Same code, one knob: in dev set MinimumLevel = Debug to see everything.
}
}
// ✅ Expected output:
// [Warning ] Retrying payment 2/3
// [Error ] Gateway timeoutNow you try. Add the filtering condition and set the minimum level so only Warning and above get through. Fill in the two ___ blanks:
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 < MinimumLevel
Console.WriteLine($"[{level}] {message}");
}
}
class Program
{
static void Main()
{
// 2) Set the minimum level to Warning so only Warning+ get through.
var log = new Logger { MinimumLevel = LogLevel.___ }; // 👉 Warning
log.Log(LogLevel.Debug, "Cache miss"); // should be skipped
log.Log(LogLevel.Warning, "Low memory"); // should print
log.Log(LogLevel.Error, "Out of memory"); // should print
// ✅ Expected output:
// [Warning] Low memory
// [Error] Out of memory
}
}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.
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: {OrderId} and {Amount} are named placeholders.
// The values are captured as PROPERTIES, not glued into a flat string —
// so a log tool can later query "all logs where OrderId = ORD-001".
_logger.LogInformation("Processing order {OrderId} for {Amount:C}", orderId, amount);
try
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive");
_logger.LogInformation("Order {OrderId} completed", orderId);
}
catch (Exception ex)
{
// Pass the exception FIRST so the full stack trace is recorded.
_logger.LogError(ex, "Failed to process order {OrderId}", orderId);
throw;
}
}
}
class Program
{
static void Main()
{
var services = new ServiceCollection();
services.AddLogging(b => { b.SetMinimumLevel(LogLevel.Information); b.AddConsole(); });
services.AddTransient<OrderProcessor>();
var provider = services.BuildServiceProvider();
var processor = provider.GetRequiredService<OrderProcessor>();
processor.ProcessOrder("ORD-001", 99.99m);
// ✅ Expected output (console provider; exact format varies by version):
// info: OrderProcessor[0]
// Processing order ORD-001 for £99.99
// info: OrderProcessor[0]
// Order ORD-001 completed
}
}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.
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 order " + orderId);
logger.LogInformation($"User {email} placed order {orderId}"); // same problem
// ✅ GOOD — a message TEMPLATE with named placeholders. Email, OrderId and
// Amount are stored as searchable properties alongside the text.
logger.LogInformation("User {Email} placed order {OrderId} for {Amount:C}",
email, orderId, amount);
// ✅ Expected: identical text in the console, BUT only the GOOD line lets
// you later run queries like: Email = "[email protected]" or Amount > 50.
}
}🔎 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.
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.Day)
// .WriteTo.Seq("http://localhost:5341") // sink 3: a queryable server
.CreateLogger();
// Same structured-template style as ILogger — {User} is a property.
Log.Information("User {User} logged in from {Ip}", "alice", "10.0.0.4");
Log.Warning("Disk usage at {Percent}%", 92);
Log.CloseAndFlush(); // ALWAYS flush on shutdown or you lose buffered logs
// ✅ Expected (console sink, format varies):
// [12:00:00 INF] User alice logged in from 10.0.0.4
// [12:00:00 WRN] Disk usage at 92%
// ...and the same lines appended to logs/app-2026-06-14.txt
}
}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.
- • 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, "..."))
- • 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.
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;
private readonly Func<DateTime> _clock;
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
// Taking the clock as a dependency rather than calling DateTime.Now inside
// Log() is what makes a logger testable: a test can pin the time and assert
// on the whole line. It is also why the output below is a fixed 12:00:00
// instead of whatever o'clock it happens to be when you run this.
public AppLogger(string category, Func<DateTime> clock = null)
{
_category = category;
_clock = clock ?? (() => DateTime.Now);
}
public void Log(LogLevel level, string message)
{
if (level < MinimumLevel) return; // filter first
var time = _clock().ToString("HH:mm:ss"); // timestamp
Console.WriteLine($"{time} [{level,-11}] {_category}: {message}");
}
// Convenience wrappers so callers write log.Info("...") not log.Log(...).
public void Info(string m) => Log(LogLevel.Information, m);
public void Warn(string m) => Log(LogLevel.Warning, m);
public void Error(string m) => Log(LogLevel.Error, m);
}
class Program
{
static void Main()
{
// A fixed clock, so this page can show you an exact line. In production
// you would leave the argument off and get DateTime.Now.
var noon = new DateTime(2026, 6, 1, 12, 0, 0);
var log = new AppLogger("Checkout", () => noon) { MinimumLevel = LogLevel.Debug };
log.Info("Cart loaded with 3 items");
log.Log(LogLevel.Debug, "Coupon SAVE10 applied");
log.Warn("Stock low for SKU-42");
log.Error("Payment gateway timed out");
// ✅ Expected output:
// 12:00:00 [Information] Checkout: Cart loaded with 3 items
// 12:00:00 [Debug ] Checkout: Coupon SAVE10 applied
// 12:00:00 [Warning ] Checkout: Stock low for SKU-42
// 12:00:00 [Error ] Checkout: Payment gateway timed out
}
}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") or LogInformation($"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/Critical for genuine failures and demote chatter to Debug/Trace.
- Expensive work without a level check: LogDebug("State: {S}", BuildHugeReport()) calls BuildHugeReport() even when Debug is filtered out. Wrap it in if (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.
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.Now.ToString("HH:mm:ss"))
// 3. In Main: make a Logger with category "Auth" and MinimumLevel = Warning,
// then log a Debug, a Warning and an Error message.
//
// ✅ Expected: ONLY the Warning and Error lines print (Debug is below Warning),
// e.g. 12:00:00 [Warning] Auth: ...
// 12:00:00 [Error] Auth: ...
enum LogLevel { Trace, Debug, Information, Warning, Error, Critical }
class Logger
{
// your fields, constructor and Log method here
}
class Program
{
static void Main()
{
// your code here
}
}🎉 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
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.
Practice quiz
Which is the lowest-severity log level?
- Critical
- Information
- Trace
- Warning
Answer: Trace. The six levels run Trace (0, lowest) → Debug → Information → Warning → Error → Critical (5, highest).
How does a minimum-level filter decide what to drop?
- Anything below the minimum level is dropped before being written
- It randomly samples messages
- It keeps only Error and above always
- It drops the newest messages
Answer: Anything below the minimum level is dropped before being written. A minimum level acts like a volume knob: because an enum compares by its number, the check is one line — if (level < MinimumLevel) return;.
Why is structured logging better than string concatenation?
- It produces shorter text
- It is faster to type
- It encrypts the message
- Named placeholder values are captured as searchable properties, not glued into flat text
Answer: Named placeholder values are captured as searchable properties, not glued into flat text. A message template like LogInformation("Order {OrderId}", id) stores OrderId as a queryable property; interpolation flattens it into unsearchable text.
What does the <T> in ILogger<T> provide?
- The minimum log level
- It tags every line with that class name as its category
- The output file path
- The number of providers
Answer: It tags every line with that class name as its category. ILogger<OrderProcessor> tags each log line with the class name as its 'category' automatically.
When logging an exception, where should the exception be passed?
- As the first argument: LogError(ex, "...")
- As the last argument
- Inside the message string
- It cannot be logged
Answer: As the first argument: LogError(ex, "..."). Pass the exception first — LogError(ex, "Failed {Id}", id) — so the full stack trace is recorded alongside the message.
In Microsoft.Extensions.Logging, what is a 'provider'?
- A class that creates loggers only
- The minimum level setting
- An adapter that knows how to write a log event to one destination
- A message template
Answer: An adapter that knows how to write a log event to one destination. A provider is a small adapter that writes to one destination (console, debug, event log). One log call fans out to every registered provider.
In Serilog terminology, what is a 'sink'?
- A log level
- A destination for log events (console, file, server)
- A message template
- A way to drop logs
Answer: A destination for log events (console, file, server). A sink is a Serilog destination for log events. It is the same idea that Microsoft.Extensions.Logging calls a provider.
Which of these must you NEVER log?
- Order IDs and request IDs
- The outcome of an operation
- Exception stack traces
- Passwords, API keys, tokens, and connection strings
Answer: Passwords, API keys, tokens, and connection strings. Never log secrets or PII. Logs are plain text copied to many sinks; log identifiers and masked values (e.g. 'card ending 4242') instead.
Why guard an expensive log argument with if (logger.IsEnabled(LogLevel.Debug))?
- To change the log level
- To avoid building a costly string the filter would only discard
- To force a flush
- To encrypt the argument
Answer: To avoid building a costly string the filter would only discard. Without the guard, an expensive argument (e.g. BuildHugeReport()) is computed even when Debug is filtered out. IsEnabled skips that work.
Why call Log.CloseAndFlush() on shutdown when using Serilog?
- To change the minimum level
- To open a new sink
- Otherwise buffered log lines can be lost when the process exits
- To reset the timestamp
Answer: Otherwise buffered log lines can be lost when the process exits. Serilog buffers events; calling CloseAndFlush() on shutdown ensures buffered lines are written instead of lost when the process ends.
Continue this course
- Previous: Dependency Injection Internals in .NET
- Next: Working with Files: Streams, Buffers, Pipelines — High-performance I/O with StreamReader, BufferedStream, and System.IO.Pipelines
- Quick reference: C# cheat sheet