Logs are how a running program tells you what it did. Without them, debugging a production bug means guessing, and tracing a customer's failed order across three services becomes archaeology. This chapter covers the modern .NET logging stack: ILogger<T>, log levels, structured logging with placeholders, scopes for correlation, source-generated logging for performance, and what to keep out of your logs entirely. The running example is an e-commerce order-processing flow, and every code sample runs on .NET 8 or later.
A log line written badly is worse than no log line at all. It either floods the output with noise, hides the real problem inside a wall of strings, or quietly leaks a customer's credit card number into a file your support team can read.
Good logging serves three jobs. The first is debugging: when something breaks at 2 AM, the logs are the only thing you have. The second is auditing: who placed an order, when, and what state did the system transition through. The third is observability: aggregating logs across many machines to spot trends, error spikes, and slow operations.
The same line of code can satisfy all three jobs or none of them, depending on how it's written. A log message like "Failed to process order" tells you something happened. A message like "Order {OrderId} failed during {Stage} for customer {CustomerId}" tells you which order, which customer, and which step blew up, and a log aggregator can find every failure for a specific stage by querying a single field.
That difference, between a string that humans can read and a structured event a machine can query, is what most of this chapter is about.
The diagram shows the path of a log entry. Your code calls ILogger<T>, the framework checks whether the message passes the configured filters (more on these later), and surviving entries fan out to every registered provider. Each provider decides where the entry lands: a console, a file, a cloud service. The same LogInformation call can produce a colored line in your terminal during development and a structured JSON record in Azure during production, with zero code changes.
Microsoft.Extensions.Logging and ILogger<T>The standard logging abstraction in .NET is the Microsoft.Extensions.Logging package. It's already referenced by Microsoft.NET.Sdk.Web, so ASP.NET Core projects have it available without adding the package. For console apps and libraries, add it explicitly:
The central interface is ILogger<TCategory>. The generic parameter is a category, almost always the class that owns the logger. The framework uses the type's full name as a tag on every entry, which lets you filter by component later.
A minimal end-to-end example with dependency injection:
Host.CreateApplicationBuilder wires up a default logging pipeline that writes to the console. The category OrderService comes from the generic argument. The [0] is the event ID, zero by default. The placeholder {OrderId} is filled in from the first argument after the message template.
Notice what's missing from the constructor: the service doesn't new up a logger or read a static Log instance. It asks for one through its constructor, and the DI container hands it the right ILogger<OrderService>. That's the pattern. Every class that needs to log gets its logger injected, and you never construct one by hand in production code.
For tests or scripts that don't use a host, you can build a logger from a factory:
The factory is what the DI container uses internally. Disposing it flushes any buffered output, which matters for file and network providers.
LogLevel is an enum that ranks every entry by severity. Each level has its own method on ILogger, and configuration decides which levels actually reach a provider.
| Level | Numeric | Use it for | Production default |
|---|---|---|---|
Trace | 0 | Extremely chatty diagnostics, individual variable values, loop iterations | Off |
Debug | 1 | Diagnostic info useful during development, not normally interesting in prod | Off |
Information | 2 | Normal application progress: requests, business events, state transitions | On |
Warning | 3 | Unexpected events that didn't break the operation: retries, fallbacks, deprecated paths | On |
Error | 4 | A specific operation failed; the app keeps running | On |
Critical | 5 | The app or a major subsystem is unusable: data loss, host down, out-of-memory | On |
None | 6 | Disables logging entirely for that category | n/a |
Getting these right is the difference between a useful log and a noisy one. A few rules of thumb that hold up in real systems:
Information.Error. The database being down for ten minutes is a Critical.Critical more than a few times a week, the level is being abused.Here's the same OrderService using levels deliberately:
The validation step is Debug, useful during a local repro and silenced in production. The bad-total case is a Warning: the request was malformed, but the system handled it cleanly. The success path is Information. The expected-failure path (a known gateway exception) is Error. The catch-all for genuinely unexpected exceptions is Critical, and it rethrows so the caller knows something went very wrong.
Calling _logger.LogDebug("...", arg) when Debug is disabled still allocates the params object?[] array for the arguments. Source-generated logging (covered later) sidesteps this by checking IsEnabled first and skipping the call entirely. For very hot code paths, use the source generator or wrap the call in if (_logger.IsEnabled(LogLevel.Debug)).
This is the single most important habit in modern .NET logging, and the easiest one to get wrong.
Don't do this:
Do this:
Both produce the same text on a console. They produce wildly different output to anything that stores logs as structured data.
With string interpolation, the framework gets one input: the already-formatted string "Order 1042 placed for $89.97". The fact that 1042 is an order ID and $89.97 is a total is gone. A query like "show me all log entries for order 1042" has to do substring matching across millions of strings.
With placeholders, the framework gets a message template plus the original argument values. A structured sink like Application Insights or Seq stores the entry as:
Now the query is OrderId == 1042, runs in milliseconds against an index, and works the same whether the message wording changes tomorrow or whether the order has been mentioned in fifty different log lines.
The placeholders are positional, not named. The first {...} corresponds to the first argument, the second to the second, and so on. The name inside the braces is for the structured payload and for human readability. Switching the order without switching the arguments produces wrong data with no compiler error:
Use PascalCase for placeholder names by convention ({OrderId}, not {orderId} or {order_id}). Sinks index on those names, so consistency matters across the codebase.
Format specifiers inside the placeholder work the same way they do in string.Format:
The structured payload still stores Placed as a DateTime and Total as a decimal. The format specifier only affects how the rendered message looks.
This is the boring section that prevents data breaches.
Logs are usually stored in plain text, replicated to multiple machines, indexed by services your support team can search, and kept for months. Anything you write into a log is effectively a permanent, broadly readable record. That makes some categories of data dangerous to log.
Never log:
Log with care:
A redaction helper is the standard way to enforce this. Keep it in one place, use it everywhere:
Used at the call site:
The full email and card never enter the log pipeline. If a sink is later compromised or a log file gets shipped to the wrong place, the damage radius is small.
A common mistake to avoid: catching an exception and logging ex.Message from a request handler often dumps user input into the log, because frameworks sometimes echo the offending value into the exception text. Either log the structured exception (the next section covers this) or be intentional about which exception fields you write.
ILogger has overloads that take an Exception as the first argument. Use them. Don't log ex.Message and call it done.
Don't do this:
The string concatenation loses the stack trace, the inner exception chain, the exception type, and any custom data. You'll know something failed and not much else.
Do this:
The first argument is the exception. The second is the message template. The renderer attaches the full exception (type, message, stack trace, inner exceptions, custom properties) to the entry. Console output gets a multi-line dump:
A structured sink stores the exception as its own field, queryable by type, message, and stack-trace fingerprint.
A few exception-logging rules that hold up under pressure:
catch (Exception ex) followed by a generic "Something failed" entry is barely better than no log at all. Catch the specific exception types you expect and log a message that names the failure mode.throw; already does for unhandled exceptions in middleware or the host's unhandled-exception handler. Catching, logging, and rethrowing pollutes the logs and obscures the real handler.throw; preserves the original stack trace. throw ex; resets it, which makes the eventual log entry point at your rethrow site instead of the original failure.IsEnabled, Lazy Evaluation, and LoggerMessageLogging isn't free. A LogDebug call inside a tight loop, with disabled Debug level, still does work. The argument array gets allocated. Any boxing of value types happens. The template gets parsed (cached, but still). For 99% of code, none of this matters. For hot paths and high-throughput services, it adds up.
The cheapest fix is the IsEnabled guard:
The IsEnabled check is a single integer compare. If Debug is off, the expensive string.Join never runs. Use this anywhere the argument computation itself is expensive: collection serialization, dictionary lookups, anything that allocates.
The better fix, especially for messages called millions of times, is source-generated logging. Annotate a partial method with [LoggerMessage] and the C# compiler generates a fast, allocation-free implementation at build time:
The generator produces a method that:
IsEnabled before doing anything.params object?[] allocation entirely.The trade-off is a small amount of ceremony per message: one partial method declaration per log statement. For 90% of code, the convenience of LogInformation wins. For the 10% that shows up in profilers, source-generated logging is the answer. The analyzer CA1848 will flag candidates for you in projects with analyzers enabled.
A single LogInformation call with three value-type arguments costs roughly 200-400 nanoseconds when the level is enabled, and roughly 30-50 nanoseconds when it isn't, mostly from boxing. A source-generated equivalent drops the disabled cost to a couple of nanoseconds.
A practical rule: write normal Log* calls everywhere, and convert to source-generated only after profiling shows a hot spot.
A single business operation usually produces many log lines. An order goes through validation, inventory reservation, payment, and confirmation, each step emitting its own entry. Without correlation, finding all entries for one order means a global text search.
Scopes solve this. A scope is a block of state that's attached to every log entry emitted while the scope is active, including entries from methods further down the call stack.
Every entry emitted inside the using block, including entries from _inventory.Reserve and _payments.Charge and anything they call, is tagged with OrderId and Customer. The PaymentService doesn't need to know the order ID exists, and its own log lines still get the correlation tag.
Console providers by default don't show scopes. Enable them with configuration (covered in the next section) or, for a quick demo, in code:
Output (with scopes enabled):
Four entries from three different services, all tagged with the same order ID. A structured sink stores the scope as a property on each entry, so a query like OrderId == 1042 returns every line, regardless of which class wrote it.
The diagram shows how scope state flows through nested calls. Every LogInformation between BeginScope and the end of the using block carries the OrderId and Customer tags, even when it's emitted by a different class three frames deep.
Scopes nest. If PaymentService opens its own scope inside Charge, both scopes are attached to entries inside the inner one:
The resulting entry carries OrderId, Customer (from the outer scope) and AttemptId (from the inner scope). This is exactly the structure you want for distributed tracing: each level adds its own context without erasing what the layer above set up.
For systems that span more than one service, an OrderId-level scope isn't enough. You need an identifier that follows a single user request across HTTP boundaries, so the logs from the API, the order service, and the warehouse service can all be tied together.
Two related concepts:
Activity.Current?.TraceId.Modern .NET prefers trace IDs because they require no manual plumbing. Inside a request, Activity.Current is set by the framework, and structured logging providers like Application Insights pick up the trace ID automatically.
For an app where you still want an explicit correlation header (a common requirement when integrating with external systems), open a scope at the request entry point:
Every entry inside the scope (and every entry from any method it calls) is tagged with the correlation ID. Pass the same ID as a header on any outbound HTTP call, and the receiving service can open its own scope with the same value. The two systems' logs are now joinable on CorrelationId.
For ASP.NET Core specifically, the built-in TraceIdentifier and Activity.Current.TraceId already do most of this. The hand-rolled correlation ID is for when you control both ends of an integration and want a stable identifier that doesn't depend on framework specifics.
A sink (also called a provider) is the destination where log entries are written. The standard Microsoft.Extensions.Logging pipeline lets you register any number of providers, and every accepted log entry goes to all of them.
| Provider | Package | Use it for |
|---|---|---|
| Console | Microsoft.Extensions.Logging.Console | Local development, containers (stdout) |
| Debug | Microsoft.Extensions.Logging.Debug | Output to attached debugger (Visual Studio, VS Code) |
| EventSource | Microsoft.Extensions.Logging.EventSource | dotnet-trace, ETW on Windows |
| EventLog | Microsoft.Extensions.Logging.EventLog | Windows Event Log |
The console provider is what Host.CreateApplicationBuilder registers by default. The other built-ins are added explicitly:
There's no built-in file provider in the box. Microsoft's stance is that file logging is better handled by a dedicated library (Serilog or NLog) or by writing logs to stdout and letting the container or host capture them.
For Azure-hosted apps, Application Insights is the standard destination. It accepts the full structured payload (template, named properties, scopes, exception object) and provides query, dashboards, and alerting on top.
That single call adds an ILoggerProvider that ships every entry to App Insights, plus auto-collection of HTTP requests, dependencies, and exceptions. Trace IDs are wired up automatically, so logs and request telemetry join on the same key.
Serilog is the most widely used third-party logging library in .NET. It plugs into the standard ILogger<T> pipeline, so application code doesn't change, but its rendering and sink ecosystem is broader than the built-ins.
Calling _logger.LogInformation("Order {OrderId} placed", orderId) from application code now writes to both the console and a daily-rotated file under logs/. Serilog has dozens of sinks: Seq, Elasticsearch, Datadog, Splunk, MongoDB. The application code never knows or cares.
NLog is older and also widely used. Configuration is XML-based by default, which some teams prefer for ops control without code changes.
The setup is configuration-heavy compared to Serilog's fluent API. For new projects, Serilog has more momentum, but NLog is a solid choice and remains common in older or operations-driven codebases.
A single application can register multiple providers, and every entry goes to all of them:
One LogInformation call, four destinations. Local developers see the console output. Operations sees the file. The cloud aggregator sees App Insights. The dev team sees Seq for ad-hoc queries. None of that fan-out is visible to the code calling _logger.
appsettings.json and Filter RulesHardcoding log levels in code is fine for a demo and bad for production. Real apps put log configuration in appsettings.json, which can be edited without a rebuild and overridden per environment.
The standard layout:
The Logging:LogLevel section sets levels by category prefix. Rules are matched longest-prefix-first. The example reads as:
Information.Microsoft namespace is filtered to Warning or higher, except Microsoft.Hosting.Lifetime which keeps Information.OrderService is set to Debug, so debug-level entries from OrderService pass through.Provider-specific overrides go in their own section (Console, Debug, ApplicationInsights, etc.). The example also turns on IncludeScopes for the console provider, so scope properties appear in the rendered output.
For environment-specific overrides, use appsettings.Development.json, appsettings.Production.json, etc. The host merges them by environment name. A typical pattern:
appsettings.json (base):
appsettings.Development.json (overrides for local dev):
In development, the merged config raises Default to Debug and lets OrderService go all the way to Trace. In production, the base file applies and the noisy levels stay off.
The host wires this all up automatically when you use Host.CreateApplicationBuilder:
If you want to add a category-specific filter from code (rare, but useful for libraries):
A worked end-to-end example. The scenario is an order being placed: validate, reserve inventory, charge payment, send confirmation. The code uses every idea in this chapter.
appsettings.json:
Program.cs:
OrderService.cs (with source-generated logging):
InventoryService.cs:
PaymentService.cs:
NotificationService.cs:
Output (happy path):
What every line shares: the CorrelationId (from the outer scope) and the OrderId (also from the outer scope). The payment entry adds an inner scope tagged PaymentAttempt, which is local to that one call. Every entry carries enough context to find every other entry from the same request without a text search.
What every line is missing: the customer's raw email, the card number, the request body, the session token. Redaction happens at the call site, and the source-generated log methods have a fixed shape that makes "accidentally pass a password" much harder to do.
If the payment gateway returns an error, the output becomes:
Output (failure path):
One entry per failure, the exception attached to the entry that handled it, the correlation ID preserved so a downstream tool can find the inventory reservation that now needs to be released. That's the shape of production-ready logging.
10 quizzes