DEV Community

Cover image for Logging in .NET
Rhuturaj Takle
Rhuturaj Takle

Posted on

Logging in .NET

Logging in .NET

A deep-dive walkthrough of logging in .NET — covering ILogger/ILoggerFactory and the provider model underneath them, log levels and how to choose correctly among them, structured logging via message templates (and why string interpolation defeats the entire point), log scopes for contextual enrichment, high-performance logging via the LoggerMessage source generator, where third-party frameworks like Serilog fit in, and the practical discipline of correlation IDs and sampling that makes logs genuinely useful at real production volume rather than just voluminous.


Table of Contents

  1. Introduction
  2. ILogger and the Provider Model
  3. Log Levels: Choosing Correctly, Not Just Available Options
  4. Structured Logging: Message Templates, Not String Interpolation
  5. Why Structured Logging Matters at Query Time
  6. Log Scopes: Contextual Enrichment Across Multiple Log Entries
  7. Configuring Log Levels Per Category
  8. The LoggerMessage Source Generator: High-Performance Logging
  9. Third-Party Logging Frameworks: Where Serilog Fits
  10. Correlation IDs: Tying Logs to a Single Request
  11. What NOT to Log
  12. Sampling and Volume Management
  13. Logging in Background Services and Non-Request Contexts
  14. Common Pitfalls
  15. Quick Reference Table
  16. Conclusion

Introduction

Logging is how an application tells you what it's doing once it's no longer running under a debugger — and .NET's logging system is deliberately built as an abstraction (ILogger) over a pluggable set of providers, so the same logging calls throughout an application's code can be routed to the console during development, a file or a centralized aggregation service in production, or all of them simultaneously, without the calling code ever needing to know or care which. This guide goes deep on that abstraction, the discipline of structured logging (which is genuinely different from, and more valuable than, just writing formatted strings), log levels as a precise signaling mechanism rather than a vague severity gradient, and the practical concerns — correlation IDs, sampling, what never belongs in a log line — that separate logs that are actually useful at real production scale from ones that are merely voluminous.

logger.LogInformation("Order {OrderId} placed by {CustomerId} for {Total:C}", orderId, customerId, total);
    ↓
Providers (Console, Debug, EventSource, or a third-party sink like
  Serilog/Application Insights/Elasticsearch) each receive the SAME
  structured event — the message TEMPLATE plus the NAMED, TYPED values —
  and decide independently how to render or store it.
Enter fullscreen mode Exit fullscreen mode

1. ILogger and the Provider Model

ILogger<T>: the interface almost all application code depends on directly

public class OrderService
{
    private readonly ILogger<OrderService> _logger; // T is the CATEGORY — typically the class itself
    public OrderService(ILogger<OrderService> logger) => _logger = logger; // genuine constructor injection,
                                                                              //  per this series' ASP.NET Core
                                                                              //  Dependency Injection guide

    public void PlaceOrder(Order order)
    {
        _logger.LogInformation("Order {OrderId} placed", order.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode

ILogger<T> is resolved through dependency injection exactly like any other service in this series' ASP.NET Core Dependency Injection guide — the generic parameter T becomes the log entry's category, conventionally the fully-qualified name of the class doing the logging, which is what lets Section 6's per-category filtering distinguish "logs from OrderService" from "logs from PaymentService" without any manual tagging.

ILoggerFactory and ILoggerProvider: what actually sits underneath the interface

ILogger<T> itself doesn't know how to WRITE anywhere — it's a thin
  facade. ILoggerFactory creates loggers and holds a set of registered
  ILoggerProvider instances (Console, Debug, and any third-party ones
  configured) — every LOG CALL is dispatched to EVERY registered
  provider simultaneously.
Enter fullscreen mode Exit fullscreen mode

This is the architectural core worth understanding precisely: calling _logger.LogInformation(...) doesn't write to "the log" — it hands the log entry to every currently-registered provider, each of which independently decides how (and whether, per Section 6's filtering) to record it. This is exactly what makes it possible to log once in application code and have that single call simultaneously appear on the console during local development, in a file, and in a centralized aggregation service in production, all without touching the calling code at all.

Registering providers: the built-in set, and how third-party ones plug in

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // remove the DEFAULT set, if you want full control
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Logging.AddEventSourceLogger();
// a third-party provider, e.g.: builder.Logging.AddSerilog(...); — see Section 8
Enter fullscreen mode Exit fullscreen mode

WebApplication.CreateBuilder registers a sensible default set of providers automatically (Console, Debug, EventSource, and a couple of platform-specific ones) — worth knowing ClearProviders() exists for applications that want to replace that default set entirely, most commonly when adopting a comprehensive third-party framework like Serilog as the sole provider.


2. Log Levels: Choosing Correctly, Not Just Available Options

The six levels, in increasing severity, and what each one actually signals

_logger.LogTrace("Entering method with parameters {A}, {B}", a, b);       // finest-grained, usually disabled even in dev
_logger.LogDebug("Cache miss for key {Key}", key);                          // useful during active development/diagnosis
_logger.LogInformation("Order {OrderId} placed", orderId);                   // notable, EXPECTED application events
_logger.LogWarning("Retry attempt {Attempt} for {Operation}", attempt, op);   // something UNEXPECTED, but recoverable
_logger.LogError(ex, "Failed to process order {OrderId}", orderId);           // a genuine FAILURE, not routine
_logger.LogCritical(ex, "Database connection pool exhausted");                 // the APPLICATION ITSELF may be at risk
Enter fullscreen mode Exit fullscreen mode

This directly extends this series' Exception Handling guide's Section 9 log-level guidance — worth restating the full six-level picture here as this guide's own foundation: each level is a precise signal about how much attention this entry deserves, and choosing the right one is what makes level-based filtering (Section 6) and alerting genuinely useful rather than noise.

Why over-using Warning/Error for routine, expected conditions is a real, common mistake

// ❌ A client requesting a resource that doesn't exist is an EXPECTED, routine outcome —
//    logging it as an Error creates noise that drowns out GENUINE failures
_logger.LogError("Order {OrderId} not found", orderId);

// ✅ Reserve Error for genuinely UNEXPECTED failures; use Information or Warning for
//    routine, expected conditions the application handled correctly
_logger.LogInformation("Order {OrderId} not found, returning 404", orderId);
Enter fullscreen mode Exit fullscreen mode

This is precisely the same discipline this series' Exception Handling guide's Section 9 establishes for exception-specific logging, generalized here to logging as a whole — a log level's entire value comes from it reliably signaling severity; an application that logs Error for every routine "not found" or validation failure trains whoever's watching (a human, or an automated alert) to ignore Error-level entries entirely, which defeats the level system's actual purpose.

Trace and Debug: why they're usually disabled outside active local development

Trace and Debug levels are typically FILTERED OUT entirely in
  Production (Section 6) — they're meant for genuinely fine-grained,
  high-volume diagnostic detail useful while actively debugging a
  specific issue locally, not for routine production operation, where
  their sheer VOLUME would both cost real money (storage, ingestion) and
  bury the signal that actually matters.
Enter fullscreen mode Exit fullscreen mode

3. Structured Logging: Message Templates, Not String Interpolation

The critical distinction: a message TEMPLATE with named placeholders, not a pre-formatted string

// ❌ String interpolation — by the time this reaches ANY provider, it's just ONE OPAQUE STRING,
//    with all the structured information (that "42" was specifically an OrderId) already LOST
_logger.LogInformation($"Order {orderId} placed by {customerId}");

// ✅ A message TEMPLATE — the provider receives the TEMPLATE ITSELF, plus each named
//    value SEPARATELY, TYPED, and queryable — nothing is collapsed into a string until
//    (and unless) a specific provider chooses to render it that way for display
_logger.LogInformation("Order {OrderId} placed by {CustomerId}", orderId, customerId);
Enter fullscreen mode Exit fullscreen mode

This is the single most important, and most commonly gotten wrong, distinction in this entire guide — these two lines can produce visually identical console output, which is exactly why the difference is so easy to overlook, but they are fundamentally, structurally different under the hood: the interpolated version has already destroyed the individual values' identity by the time logging even sees them; the templated version preserves OrderId and CustomerId as genuinely separate, named, typed pieces of data all the way through to wherever the log entry is finally stored.

Why the placeholder ORDER doesn't need to match the string's word order, and why that's a hint about what's really happening

_logger.LogInformation("Processing {OrderId} for {CustomerId}", orderId, customerId);
// the RUNTIME matches {OrderId} to orderId and {CustomerId} to customerId by POSITION —
// but underneath, this is building a STRUCTURED EVENT, not concatenating a string on the spot
Enter fullscreen mode Exit fullscreen mode

Worth noting as a small but telling detail: this is genuinely closer to how string.Format's positional placeholders work mechanically than it might first appear, but the crucial difference is that .NET's structured logging providers can access the name (OrderId) alongside the value, which string.Format never preserves at all — this is precisely the extra layer of information string interpolation throws away before logging ever gets a chance to use it.


4. Why Structured Logging Matters at Query Time

The practical payoff: querying logs by field, not by regex-matching text

With STRUCTURED logs (a provider like Application Insights, Seq, or
  Elasticsearch storing the named values properly): "show me every log
  entry where OrderId = 42" is a direct, precise, FAST query against a
  field.
With UNSTRUCTURED (interpolated-string) logs: the SAME question requires
  a regex or text search against a giant pile of opaque strings, hoping
  the specific formatting used at log time happens to match your search
  pattern exactly, and CANNOT reliably distinguish "OrderId 42" from
  "CustomerId 42" or any other stray "42" in the log line.
Enter fullscreen mode Exit fullscreen mode

This is the concrete, practical reason the distinction in Section 3 matters as much as it does — a production incident investigation frequently comes down to exactly this kind of query ("show me everything that happened around this specific order/customer/request"), and structured logging is what makes that query fast, precise, and reliable, rather than a hopeful text search through potentially millions of log lines.

Aggregation and analytics become possible, not just search

Structured fields enable genuine AGGREGATION: "average processing time
  GROUPED BY OrderStatus," "count of errors PER CustomerId over the last
  hour" — queries that are straightforward against structured, typed
  fields and effectively impossible against opaque, pre-formatted strings.
Enter fullscreen mode Exit fullscreen mode

This is worth knowing as the second major payoff beyond simple search — once log data is genuinely structured, it becomes a real data source for dashboards, alerting rules, and operational analytics, not just a searchable archive of what happened, which is a meaningfully bigger value proposition than logging is often given credit for.


5. Log Scopes: Contextual Enrichment Across Multiple Log Entries

The problem: the same contextual value (an order ID, a request ID) needs to appear on EVERY log entry within some unit of work

public void ProcessOrder(int orderId)
{
    _logger.LogInformation("Validating order");   // ❌ which order? not obvious from THIS line alone
    _logger.LogInformation("Charging payment");     // ❌ same problem, repeated
    _logger.LogInformation("Shipping order");        // ❌ same problem, again
}
Enter fullscreen mode Exit fullscreen mode

Without repeating {OrderId} and passing orderId explicitly on every single log call within this method (tedious, and easy to forget on one of several calls), there's no way to know which order these log entries actually relate to when reading them later, especially interleaved with entries from other, concurrently-processing orders.

BeginScope: attaching contextual data to every log entry written within it

public void ProcessOrder(int orderId)
{
    using (_logger.BeginScope("Processing order {OrderId}", orderId)) // per this series' Memory Management
                                                                          //  guide's `using` discipline
    {
        _logger.LogInformation("Validating order");  // the SCOPE'S OrderId is AUTOMATICALLY attached
        _logger.LogInformation("Charging payment");    // to EVERY entry logged WITHIN this using block
        _logger.LogInformation("Shipping order");
    }
}
Enter fullscreen mode Exit fullscreen mode

BeginScope (returning an IDisposable, exactly the pattern this series' Memory Management guide's Section 7 covers) establishes contextual data that every log entry written within the scope automatically carries, without needing to be repeated on each individual call — this is precisely the right tool for exactly the situation above: a piece of context (an order ID, a batch ID, a correlation ID per Section 9) that's constant across a whole unit of work, but would otherwise need tedious, error-prone repetition on every log line within it.

Nested scopes: context accumulates, it doesn't replace

using (_logger.BeginScope("Order {OrderId}", orderId))
using (_logger.BeginScope("Attempt {AttemptNumber}", attemptNumber)) // NESTED — BOTH scopes' data now apply
{
    _logger.LogWarning("Payment failed, will retry"); // carries BOTH OrderId AND AttemptNumber
}
Enter fullscreen mode Exit fullscreen mode

Scopes nest exactly like this series' Middleware guide's Section 2 middleware chain and this series' Filters guide's Section 10 filter scoping both nest — an inner scope's context adds to, rather than replaces, whatever an outer scope already established, letting genuinely layered context (a request ID from the outermost scope, an order ID from a middle scope, a retry attempt number from an innermost scope) all apply simultaneously to a deeply-nested log entry.


6. Configuring Log Levels Per Category

appsettings.json's Logging section: filtering by category, hierarchically

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "MyApp.Services.OrderService": "Debug"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This configuration says: log at Information or above by default across the whole application, EXCEPT for anything under the Microsoft.AspNetCore namespace (which is genuinely noisy at Information — routing decisions, individual middleware timing — so it's raised to Warning), and except for OrderService specifically, which is currently being actively debugged and needs Debug-level detail. Category names match hierarchically by namespace prefix — configuring Microsoft.AspNetCore applies to every class within that namespace and its sub-namespaces, without needing to list each one individually.

Why this matters: you don't need to redeploy code to change what gets logged

Because filtering happens at the LOGGING INFRASTRUCTURE level, driven
  by configuration (which, per this series' ASP.NET Core Dependency
  Injection guide's Options pattern discussion, can be reloaded live via
  IOptionsMonitor<T> without a restart in many hosting setups), turning
  UP logging detail for a SPECIFIC, currently-problematic area of the
  application doesn't require a code change or even necessarily a
  restart — just a configuration update.
Enter fullscreen mode Exit fullscreen mode

This is a genuinely practical, everyday payoff worth knowing about explicitly — during a live production investigation, being able to temporarily raise OrderService's log level to Debug via configuration alone, without a deployment, is often the difference between diagnosing an issue quickly and needing to ship a debug build just to get the detail you need.


7. The LoggerMessage Source Generator: High-Performance Logging

The problem: ordinary ILogger calls have real, measurable overhead, even when the log level is disabled

_logger.LogDebug("Processing item {ItemId} with value {Value}", itemId, value);
// even if Debug is DISABLED (per Section 6's filtering), this call still involves:
// - BOXING itemId and value if they're value types (per this series' Generics guide's Section 8)
// - allocating a params array to hold them
// - string PARSING of the template, on EVERY call, to identify the placeholders
Enter fullscreen mode Exit fullscreen mode

This connects directly to this series' Generics guide's boxing discussion and this series' Memory Management guide's allocation-pressure concerns — an ordinary LogDebug/LogInformation call, even one that's ultimately filtered out and never actually written anywhere, still pays real, measurable allocation and parsing overhead on every single invocation, which matters in genuinely hot, high-frequency code paths.

[LoggerMessage]: a source-generated, allocation-minimizing alternative

public static partial class OrderLogs
{
    [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Order {OrderId} placed by {CustomerId}")]
    public static partial void OrderPlaced(this ILogger logger, int orderId, int customerId);
}

// usage, elsewhere:
_logger.OrderPlaced(orderId, customerId); // looks like an ORDINARY method call —
                                             //  the SOURCE GENERATOR produces the actual, optimized implementation
Enter fullscreen mode Exit fullscreen mode

This uses C#'s source generator feature (compile-time code generation, distinct from runtime reflection) to produce a fully-optimized logging method at compile time — the generated code checks whether the log level is enabled before doing any work at all, avoids boxing value-type parameters, and skips the runtime template-parsing entirely, since the template is already known and processed at compile time. This is genuinely the recommended, idiomatic approach for any log call on a hot, frequently-executed path, per current Microsoft guidance.

Why the EventId matters, beyond just performance

Assigning an explicit EventId to each LoggerMessage gives every DISTINCT
  kind of log entry a stable, unique NUMERIC identifier — genuinely
  useful for filtering/alerting on "this SPECIFIC kind of event"
  reliably, independent of the message TEXT (which might be reworded
  later without the underlying EVENT's identity changing).
Enter fullscreen mode Exit fullscreen mode

This is worth knowing as a secondary, non-performance benefit of adopting the source-generated pattern broadly — having every meaningfully distinct log event carry a stable ID makes building reliable alerting rules and dashboards considerably easier than matching against message text, which is exactly the kind of thing that quietly breaks when someone innocently rewords a log message's phrasing.


8. Third-Party Logging Frameworks: Where Serilog Fits

ILogger is an abstraction — third-party frameworks plug in as PROVIDERS, not replacements for application code

builder.Host.UseSerilog((context, configuration) =>
{
    configuration
        .ReadFrom.Configuration(context.Configuration)
        .WriteTo.Console()
        .WriteTo.Seq("http://localhost:5341") // a dedicated STRUCTURED LOG storage/query server
        .Enrich.FromLogContext();
});
Enter fullscreen mode Exit fullscreen mode

This is worth stating clearly, since it's a genuinely common point of confusion: adopting Serilog (or NLog, or another third-party logging library) doesn't mean rewriting application code's logging calls — _logger.LogInformation(...) calls throughout the application stay exactly as this guide has already covered them; Serilog plugs in underneath ILogger, as the actual provider handling how those structured events get formatted, filtered, and written, typically to richer, more structured-log-aware destinations (a dedicated log server like Seq, Elasticsearch, or a cloud logging service) than the built-in Console/Debug providers alone offer.

Why teams reach for a third-party framework at all, given .NET's built-in logging already supports structured logging

The built-in Console/Debug providers are genuinely adequate for LOCAL
  DEVELOPMENT, but production-grade log MANAGEMENT — routing to multiple
  DESTINATIONS simultaneously, rich enrichment (Section 5's scopes, but
  more elaborately), sophisticated FILTERING rules, and integration with
  dedicated structured-log query tools — is where third-party frameworks
  like Serilog have historically offered considerably more maturity and
  flexibility than the built-in providers alone.
Enter fullscreen mode Exit fullscreen mode

Worth knowing this as the genuine, practical reason for the ecosystem's strong convergence on Serilog specifically for production ASP.NET Core applications, rather than assuming the built-in system is somehow deficient — it's a capable foundation (and Sections 3-4's structured logging discipline applies identically either way), but the provider ecosystem around it is where third-party frameworks add the most real value.


9. Correlation IDs: Tying Logs to a Single Request

The problem: a busy application's logs interleave entries from MANY concurrent requests

Per this series' Threading guide's Section 3: multiple requests are
  genuinely processed concurrently — their log entries interleave in
  whatever order they happen to be written, making it genuinely hard to
  reconstruct "everything that happened for THIS ONE specific request"
  from a raw, chronological log stream alone.
Enter fullscreen mode Exit fullscreen mode

HttpContext.TraceIdentifier, attached via a scope, as the standard fix

public class CorrelationIdMiddleware // per this series' Middleware guide's custom middleware pattern
{
    private readonly RequestDelegate _next;
    private readonly ILogger<CorrelationIdMiddleware> _logger;
    public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
    {
        _next = next; _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        using (_logger.BeginScope("RequestId: {RequestId}", context.TraceIdentifier)) // Section 5's scope, applied here
        {
            await _next(context);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This directly connects this series' Exception Handling guide's Section 9 correlation-ID discussion to this guide's own scope mechanism (Section 5) — wrapping the entire request in a scope carrying TraceIdentifier means every log entry written anywhere during that request's handling, across every service and layer, automatically carries the same request identifier, making "show me everything that happened for this specific request" a precise, reliable query rather than a best-effort reconstruction.

Distributed tracing: extending correlation across multiple services

Per this series' Notification System and Order Management guides'
  discussion of multi-service coordination: a request that spans
  SEVERAL services (an API gateway, an order service, a payment
  service) needs its correlation ID to PROPAGATE across those service
  boundaries too — this is precisely what distributed tracing standards
  (like W3C Trace Context, and tools like OpenTelemetry) are built for,
  extending the same correlation concept this section covers within a
  single process out across an entire distributed system.
Enter fullscreen mode Exit fullscreen mode

Worth knowing this exists as a genuinely deeper, related discipline — for a single ASP.NET Core application, TraceIdentifier and a request-scoped correlation ID cover the need; for a genuinely distributed system spanning multiple services (this series' Order Management guide's saga-coordinated services, for instance), a full distributed tracing solution is the natural, more complete extension of the same underlying idea.


10. What NOT to Log

PII, secrets, and sensitive data should never appear in log output

// ❌ Logs a raw password, a full credit card number, or other genuinely sensitive data directly
_logger.LogInformation("User {Username} logged in with password {Password}", username, password);

// ✅ Log identifying, non-sensitive context only
_logger.LogInformation("User {Username} logged in successfully", username);
Enter fullscreen mode Exit fullscreen mode

This connects directly to this series' Payment Processing guide's Section 11 (never log raw card data) and this series' Authentication guide's own security-conscious framing — logs are frequently retained for extended periods, often with broader access than the primary application database, and accidentally logging sensitive data turns your logging infrastructure into an additional, often less-guarded copy of exactly the information you're supposed to be protecting.

Why this needs deliberate, structural discipline, not just "remembering not to"

public class LoginRequest
{
    public string Username { get; set; } = "";
    [JsonIgnore] // or a custom formatter/redaction attribute
    public string Password { get; set; } = "";

    public override string ToString() => $"LoginRequest {{ Username = {Username} }}"; // password DELIBERATELY excluded
}
Enter fullscreen mode Exit fullscreen mode

Given that a whole request/response object is sometimes logged wholesale (for debugging convenience) rather than field-by-field, relying purely on developer discipline to "remember not to log the password field" is fragile — overriding ToString() to deliberately exclude sensitive fields, or using .NET's newer data classification/redaction attributes, builds the protection into the type itself, so it holds even when a future developer logs the whole object without thinking carefully about each field.


11. Sampling and Volume Management

The problem: at genuine production scale, logging EVERYTHING becomes its own cost and its own noise problem

A high-throughput service (per this series' High-Volume Transaction
  Processing guide's own scale) logging every single request at
  Information level can generate a genuinely enormous VOLUME of log
  data — real storage cost, real ingestion cost for whatever aggregation
  service receives it, and a genuine SIGNAL-TO-NOISE problem for anyone
  trying to find something specific within it.
Enter fullscreen mode Exit fullscreen mode

Sampling: deliberately logging only a representative fraction of routine, high-volume events

if (Random.Shared.Next(100) < 1) // log roughly 1% of these, as a representative SAMPLE
{
    _logger.LogInformation("Cache hit for key {Key}", key);
}
Enter fullscreen mode Exit fullscreen mode

For genuinely high-frequency, routine events where every single occurrence doesn't need its own log entry (a cache hit, a successful routine health check), sampling — logging a representative fraction rather than every occurrence — keeps volume manageable while still preserving enough data to understand overall patterns and rates; this is a real, deliberate trade-off worth making consciously, not something to reach for reflexively for events that genuinely do need complete, individual records (per this series' Investment Monitoring guide's own framing around what deserves complete versus sampled logging).

The asymmetry worth remembering: never sample errors or genuinely rare, important events

Sampling is appropriate for HIGH-VOLUME, ROUTINE, LOW-INDIVIDUAL-VALUE
  events — it is NEVER appropriate for errors, security-relevant events,
  or anything genuinely rare, where missing even ONE occurrence because
  it happened to fall outside the sample could mean missing the ONE
  piece of evidence a real investigation actually needed.
Enter fullscreen mode Exit fullscreen mode

This asymmetry is worth stating explicitly as a firm rule rather than a general guideline — the entire value proposition of sampling depends on the sampled category being genuinely high-volume and individually low-value; applying the same logic to errors or security events (which are, definitionally, the entries most worth having complete records of) would undermine the exact thing logging exists to provide.


12. Logging in Background Services and Non-Request Contexts

ILogger<T> works identically outside HTTP request handling

public class OrderCleanupBackgroundService : BackgroundService // per this series' ASP.NET Core Dependency
                                                                    //  Injection guide's Section 11
{
    private readonly ILogger<OrderCleanupBackgroundService> _logger;
    public OrderCleanupBackgroundService(ILogger<OrderCleanupBackgroundService> logger) => _logger = logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Running scheduled cleanup"); // works exactly the SAME here as in a controller
            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is worth confirming explicitly, since background services (per this series' ASP.NET Core Dependency Injection guide's Section 11) have no ambient HTTP request or its TraceIdentifier to correlate against — ILogger<T> injects and works identically regardless, and for a background service's own genuine "unit of work" (a specific cleanup run, a specific message being processed from a queue), a manually-generated correlation ID (a fresh Guid, wrapped in a scope per Section 5) fills exactly the same role TraceIdentifier plays for an HTTP request.


13. Common Pitfalls

Pitfall Why it hurts Better approach
Using string interpolation ($"...") instead of message templates Destroys the structured, queryable identity of each logged value before logging even sees it Always use message templates with named placeholders and separate arguments (Section 3)
Logging routine, expected conditions at Error or Warning Creates noise that trains people (and alerting systems) to ignore those levels entirely Reserve Error/Warning for genuinely unexpected conditions; use Information for expected outcomes (Section 2)
Repeating the same contextual value on every log call within a unit of work Tedious, error-prone, and easy to omit accidentally on one of several related calls Use BeginScope to attach shared context once, applying it to every entry within the scope automatically (Section 5)
Logging sensitive data (passwords, tokens, full card numbers) directly Turns logging infrastructure into an additional, often less-guarded copy of exactly the data you're supposed to protect Deliberately exclude sensitive fields, structurally (via ToString() overrides or redaction attributes), not just by developer discipline (Section 10)
Using ordinary ILogger calls on a genuinely hot, high-frequency code path Real allocation and parsing overhead is paid on every call, even when the log level is disabled Use the [LoggerMessage] source generator for hot paths, which avoids that overhead entirely (Section 7)
Sampling error or security-relevant log entries the same way as routine, high-volume ones Risks missing the one occurrence that mattered most for an investigation Never sample errors or genuinely rare, important events; reserve sampling for high-volume, low-individual-value routine events (Section 11)
Assuming a third-party logging framework replaces ILogger calls in application code Leads to confusion about where structured logging discipline actually needs to be applied Understand third-party frameworks (Serilog, etc.) as PROVIDERS underneath ILogger, not a replacement for it (Section 8)
No correlation ID strategy for a multi-request or multi-service application Reconstructing "everything that happened for this one request/operation" from interleaved, uncorrelated logs is a genuinely difficult, unreliable task Attach a correlation ID via a scope for every request/unit of work, and extend it across service boundaries with distributed tracing where relevant (Section 9)

Quick Reference Table

Concept C# Syntax Purpose
Injecting a logger ILogger<MyClass> logger (constructor injection) The standard, DI-resolved way to obtain a logger, categorized by class
Structured message logger.LogInformation("Order {OrderId}", orderId) Preserves named, typed values, not just a formatted string
Log levels Trace < Debug < Information < Warning < Error < Critical Precise severity signaling, filterable independently
Contextual scope using (logger.BeginScope("Order {OrderId}", orderId)) Attaches shared context to every entry logged within it
Per-category filtering appsettings.json's Logging:LogLevel section Controls verbosity per namespace, without a code change
High-performance logging [LoggerMessage(...)] source generator Allocation-minimizing, compile-time-optimized logging for hot paths
Third-party providers builder.Host.UseSerilog(...) Plugs in underneath ILogger, handling richer routing/formatting
Correlation HttpContext.TraceIdentifier, wrapped in a scope Ties every log entry from one request/operation together

Conclusion

Logging's real value in .NET comes almost entirely from a distinction that's easy to overlook because it's invisible in typical console output: a structured message template preserves each logged value's identity — its name, its type — all the way through to wherever it's ultimately stored, while string interpolation collapses that same information into an opaque string the moment the log call is made, discarding exactly what makes logs genuinely queryable and analyzable at real production scale rather than merely searchable by hopeful text-matching. Log levels, scopes, and correlation IDs are what turn a raw stream of structured events into something a specific incident investigation can actually use — precise severity filtering, contextual enrichment without tedious repetition, and a reliable way to reconstruct exactly what happened for one specific request or operation out of a busy, concurrent application's interleaved output.

The practical disciplines this guide closes with — never logging sensitive data, sampling deliberately and asymmetrically (never for errors), and reaching for source-generated logging on genuinely hot paths — are what keep a logging system sustainable and trustworthy as an application scales, rather than becoming either a security liability or a cost and noise problem that undermines its own usefulness. Getting the structured-logging discipline right from the start (Sections 3-4) is the foundation everything else in this guide builds on; everything else is refinement of how to apply that discipline correctly across levels, context, volume, and scale.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the tried-to-regex-search-interpolated-log-strings-during-an-incident story that made structured logging's real value click far better than any explanation of message templates ever could.

Top comments (0)