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;
}
}
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.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.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.