A line such as “order failed” is difficult to investigate when thousands of orders run concurrently. A useful diagnostic event identifies the operation, its outcome and safe context. Structured logging keeps named values separate from the message template so a provider can support queries such as all failed imports for one batch. It also needs a deliberate privacy policy: a diagnostic system should not become a second uncontrolled store of credentials and customer data.
This guide uses .NET 8 and C# 12 with Microsoft.Extensions.Logging. The console examples reference the ASP.NET Core shared framework. You will record a named order value, understand filtering and scopes, and implement a tiny test provider that makes the output deterministic without relying on a particular hosted logging service.
Message templates preserve named values
Use a stable message template such as "Order {OrderId} completed in {ElapsedMs} ms" and pass the values as arguments. The logger receives the template and structured state. String interpolation creates a finished string before the logger sees it, losing the same named-field structure and often doing work even when the level is disabled.
The placeholder names describe data, not positional formatting tricks. Keep them consistent across events when they represent the same concept. Avoid building a new template from each identifier or user message because that creates many unrelated event shapes. A provider may render text, retain fields, or both. Do not assume every provider stores all scope and template details identically; configure and test the chosen provider's behavior.
Levels express operational significance
Trace and Debug help detailed diagnosis. Information records useful normal progress. Warning describes an unexpected condition the operation can handle. Error describes a failed operation. Critical describes a serious condition requiring immediate attention under the application's policy. The right level depends on impact, not the presence of a catch block.
Filtering can apply by category and level. A logger category often corresponds to a class, making it possible to increase detail for one component without flooding the entire application. Logging too much can cost storage and obscure meaningful events. Logging too little can make failure investigation impossible. Define which transitions matter: started, completed, rejected or failed. Avoid reporting a successful completion after swallowing an exception that actually prevented the operation.
Exceptions and context have separate channels
Pass the exception object to the logging overload intended for exceptions instead of concatenating only its message into text. That preserves diagnostic information for providers that support it. Keep the accompanying message stable and add safe identifiers, such as an internal batch ID, rather than dumping the complete request.
A correlation scope can attach context to related events in one operation. BeginScope returns a disposable scope, so end it at the operation boundary. Whether a provider captures and renders scopes depends on its support and configuration. Do not place authorization tokens, full connection strings or raw payment data in scope values. A helpful diagnostic identifier should let authorized operators locate relevant records without duplicating the records' sensitive contents.
Scopes should match one operation
A scope around an import can contain a safe batch identifier and component name. Every event within that flow can then carry context under a supporting provider. Scope lifetime should not outlive the operation, and the values should be small enough to retain economically. A new scope per individual row is often unnecessary when a batch-level scope already provides useful context.
Do not confuse a logging scope with a dependency-injection scope. They serve different purposes: one enriches diagnostics, while the other owns service lifetimes. Creating one does not automatically create the other. A request framework may establish both kinds of context, but your service should still understand what it receives and what it owns.
Treat diagnostics as a bounded data product
Structured fields should be useful, safe and bounded. Very large payloads, uncontrolled exception text or user-controlled identifiers can create excessive storage and sensitive disclosures. A provider's structured format does not automatically redact values. Review what enters the event before relying on downstream filtering.
For hot paths, source-generated logging can reduce repetitive work under supported framework versions. Optimization follows a useful event design; it does not rescue an incoherent template vocabulary. Test that the expected level and named values are emitted for meaningful outcomes, and that sensitive fields are absent. Keep a clear retention and access policy for the actual production sink.
Official references
C# Structured Logging with ILogger Examples
Each example shows the code, the output it produces and what each part does.
Capture a structured event with a test provider
Use this project file and complete program. The small provider records the formatted message and its named state, then the program prints the captured result. It is a teaching and testing fixture, not a production persistence provider.
The template produces readable text while preserving a named value. The fixture deliberately omits scope capture and concurrency support, so it should remain an isolated test helper.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>using Microsoft.Extensions.Logging;
var capture = new CaptureProvider();
using var factory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Information);
builder.AddProvider(capture);
});
ILogger logger = factory.CreateLogger("Shop.Orders");
logger.LogInformation("Order {OrderId} completed", 17);
Console.WriteLine(capture.Message);
Console.WriteLine($"OrderId: {capture.OrderId}");
public sealed class CaptureProvider : ILoggerProvider
{
public string Message { get; private set; } = "";
public object? OrderId { get; private set; }
public ILogger CreateLogger(string categoryName) => new CaptureLogger(this);
public void Dispose() { }
private sealed class CaptureLogger : ILogger
{
private readonly CaptureProvider owner;
public CaptureLogger(CaptureProvider owner) => this.owner = owner;
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
public bool IsEnabled(LogLevel level) => level != LogLevel.None;
public void Log<TState>(LogLevel level, EventId eventId,
TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
owner.Message = formatter(state, exception);
if (state is IEnumerable<KeyValuePair<string, object?>> fields)
foreach (var field in fields)
if (field.Key == "OrderId")
owner.OrderId = field.Value;
}
}
}Order 17 completed
OrderId: 17C# Structured Logging with ILogger in Real Projects
Worked exercise: report a rejected batch safely
The task is to log a rejected batch with a Warning level, a stable template and a safe numeric count. Use a standard simple-console provider and the same project reference. No raw input rows or credentials belong in this event. The provider output includes its category and level marker.
using Microsoft.Extensions.Logging;
using var factory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Information);
builder.AddSimpleConsole(options =>
{
options.SingleLine = true;
options.TimestampFormat = null;
options.ColorBehavior =
Microsoft.Extensions.Logging.Console.LoggerColorBehavior.Disabled;
});
});
ILogger logger = factory.CreateLogger("Shop.Import");
int suppliedRows = 700;
const int maximumRows = 500;
if (suppliedRows > maximumRows)
{
logger.LogWarning(
"Batch rejected: {RowCount} rows exceeds {MaximumRows}",
suppliedRows, maximumRows);
}warn: Shop.Import[0] Batch rejected: 700 rows exceeds 500Console whitespace and sink rendering can vary, but the event's level, category, template and two numeric values are the contract. Disposing the factory lets this provider finish its owned output lifecycle.
C# Structured Logging with ILogger FAQ
Why avoid interpolation in a structured log message?
Interpolation passes a completed string rather than the same stable template and named argument values. That makes field-based analysis harder and can do formatting work even when the event is filtered. Use named placeholders and arguments. If a value is expensive to compute, consider a level check or supported generated logging pattern rather than calculating it unconditionally.
Is every caught exception an Error event?
No. A normal validation rejection or expected missing optional record may not be an operational error. Choose the level from the effect on the operation and the response required. Unexpected failures should preserve the exception object through the appropriate overload. Avoid logging the same failure repeatedly at several layers unless each event adds a distinct useful responsibility.
Do scopes automatically appear in every sink?
No. Providers vary in whether they support, store and render scopes, and some require configuration to include them. Test the actual sink. A scope is still useful only when its values are safe and its lifetime is bounded. Do not assume a scoped credential will disappear from output merely because the default console renderer omits it.
Can I log an entire request for debugging?
That can expose credentials, personal information and large attacker-controlled values. Prefer a stable event with selected safe fields and a correlation identifier. If a specialized diagnostic workflow requires payload capture, it needs explicit redaction, access and retention controls. A Debug level alone is not a privacy boundary, and production settings can change which levels are retained.
C# Structured Logging with ILogger Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
An import exceeds its supported batch limit. Which event is most useful and safe?
C# Structured Logging with ILogger Quiz
2 questions to check what you learned.
Which call preserves a named OrderId field?
- LogInformation($"Order {id} completed") exclusively.
- LogInformation(id.ToString()) exclusively.
- LogInformation("Order {OrderId} completed", id).
- Console.WriteLine("Order completed") exclusively.
What is a logging scope responsible for?
- Disposing every scoped DI service automatically.
- Encrypting all values passed to the logger.
- Choosing the application's authentication policy.
- Attaching contextual diagnostic values under a supporting provider.