Logging
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 examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 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.
Your turn. The Log method is empty and two calls are missing pieces — fill in the three ___ blanks using the hints, then run it.
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.
Now you try. Add the filtering condition and set the minimum level so only Warning and above get through. Fill in the two ___ blanks:
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.
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.
🔎 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.
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.
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.
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.
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.
🎉 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.
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
- Next: Streams, Buffers & Pipelines