ILogger Fundamentals — Log Levels, Messages and Injection

Logging is the primary observability mechanism in production ASP.NET Core applications. When something goes wrong in production, logs are the first place you look. ASP.NET Core provides a built-in logging abstraction — ILogger<T> — that works identically regardless of where logs are ultimately sent (console, file, Azure Monitor, Elasticsearch). The abstraction means you write logging code once and change the destination by swapping a provider in Program.cs without touching any application code.

ILogger Injection and Log Levels

// ── Inject ILogger via constructor (C# 12 primary constructor) ───────────
public class PostService(
    IPostRepository      repo,
    ILogger<PostService> logger)
{
    public async Task<Post> GetByIdAsync(int id, CancellationToken ct = default)
    {
        // ── Log levels — from least to most severe ────────────────────────
        logger.LogTrace("GetByIdAsync called with id={Id}", id);         // very verbose, disabled in prod
        logger.LogDebug("Fetching post from repository, id={Id}", id);   // diagnostic info for devs
        logger.LogInformation("Retrieving post {PostId}", id);           // normal operational events
        logger.LogWarning("Post {PostId} not found in cache.", id);      // unexpected but recoverable
        logger.LogError("Failed to retrieve post {PostId}.", id);        // error requiring attention
        logger.LogCritical("Database unreachable — service degraded.");  // system-level failure

        // ── Structured logging — ALWAYS use named placeholders, not interpolation
        // ✅ Correct — preserves PostId as a queryable property
        logger.LogInformation("Retrieving post {PostId}", id);

        // ❌ Wrong — PostId becomes part of the message string, not a searchable property
        // logger.LogInformation($"Retrieving post {id}");

        var post = await repo.GetByIdAsync(id, ct);

        if (post is null)
        {
            logger.LogWarning("Post {PostId} not found.", id);
            throw new NotFoundException(nameof(Post), id);
        }

        logger.LogInformation("Post {PostId} '{Title}' retrieved successfully.", id, post.Title);
        return post;
    }
}
Note: The type parameter T in ILogger<T> becomes the log category — the namespace and class name are recorded with every log entry. This lets you filter logs by category in configuration: suppress all Microsoft.EntityFrameworkCore logs below Warning while keeping your own BlogApp.Application logs at Debug level. Always use ILogger<PostService> (with the containing class) rather than ILogger or a string category, so log filtering by category works correctly.
Tip: Use named placeholders in log message templates, not string interpolation. logger.LogInformation("Post {PostId} retrieved", id) creates a structured log with a PostId property that log aggregation tools can index and query. logger.LogInformation($"Post {id} retrieved") creates a flat string with no queryable properties. In Seq, Application Insights, or Elasticsearch, you can search PostId = 42 with the former but not the latter. This is the entire point of structured logging.
Warning: Never log sensitive data — passwords, full credit card numbers, social security numbers, personal data subject to GDPR, or authentication tokens. Logs are often stored longer than application data, may be accessed by support staff, and may be sent to third-party aggregation services. Build a habit of reviewing every LogInformation call: is any property sensitive? If a log message might expose PII, either omit the property or mask it: logger.LogInformation("User {UserId} logged in", user.Id) not logger.LogInformation("User {Email} logged in", user.Email).

Configuring Log Levels in appsettings.json

// ── appsettings.json — log level configuration ────────────────────────────
// {
//   "Logging": {
//     "LogLevel": {
//       "Default":                        "Information",   // all categories
//       "Microsoft":                      "Warning",       // all Microsoft.* categories
//       "Microsoft.AspNetCore":           "Warning",       // framework HTTP pipeline
//       "Microsoft.EntityFrameworkCore":  "Warning",       // EF Core (suppress SQL by default)
//       "Microsoft.EntityFrameworkCore.Database.Command": "Information", // enable SQL logging
//       "BlogApp":                        "Debug"          // your own code — more verbose
//     }
//   }
// }

// ── appsettings.Development.json — verbose for local development ──────────
// {
//   "Logging": {
//     "LogLevel": {
//       "Default":    "Debug",
//       "BlogApp":    "Trace",
//       "Microsoft.EntityFrameworkCore.Database.Command": "Information"
//     }
//   }
// }

// ── Log level hierarchy — a minimum level filters everything below it ──────
// Trace < Debug < Information < Warning < Error < Critical < None
// Setting "Default": "Warning" suppresses Trace, Debug, and Information

Common Mistakes

Mistake 1 — Using string interpolation instead of structured placeholders

❌ Wrong — loses the structured property:

logger.LogInformation($"Post {postId} published by {authorId}");  // flat string, not queryable

✅ Correct — named placeholders become searchable properties:

logger.LogInformation("Post {PostId} published by {AuthorId}", postId, authorId);

Mistake 2 — Logging at wrong levels (Debug for errors, Information for noise)

❌ Wrong — every repository call logged at Information floods logs in production.

✅ Correct — routine operations at Debug/Trace; significant events at Information; unexpected conditions at Warning; errors at Error; system failures at Critical.

🧠 Test Yourself

Why should you use logger.LogInformation("Post {PostId} fetched", id) instead of logger.LogInformation($"Post {id} fetched")?