19. Structured Logging
- Structured Logging
About this chapter
Implement structured logging with ILogger
- ILogger
fundamentals : Dependency injection and category naming - Structured logging benefits: Key-value pairs instead of string concatenation
- Log level hierarchy: Trace, Debug, Information, Warning, Error, Critical
- Configuration: Controlling log verbosity per namespace in appsettings
- Log level selection: Choosing appropriate levels for different scenarios
- Replacing Console.WriteLine: Migrating from console output to structured logging
Learning outcomes:
- Inject and use ILogger
in controllers and services - Understand structured logging and named placeholders
- Configure log levels in appsettings.json
- Choose appropriate log levels for different situations
- Use logging for debugging and monitoring
- Replace manual console output with structured logging
19.1 NEW: Introduction to ILogger
// Dependency injection of logger
public class PlatformsController : ControllerBase
{
private readonly ILogger<PlatformsController> _logger;
public PlatformsController(ILogger<PlatformsController> logger)
{
_logger = logger;
}
[HttpGet("{id}")]
public async Task<ActionResult<PlatformReadDto>> GetPlatformById(int id)
{
_logger.LogInformation("Fetching platform with ID: {PlatformId}", id);
var platform = await _repository.GetPlatformByIdAsync(id);
if (platform == null)
{
_logger.LogWarning("Platform not found. ID: {PlatformId}", id);
return NotFound();
}
_logger.LogDebug("Successfully retrieved platform: {@Platform}", platform);
return Ok(_mapper.Map<PlatformReadDto>(platform));
}
}
Why ILogger
- Structured Logging: Key-value pairs, not string concatenation
- Performance: Lazy evaluation, log level filtering
- Category:
provides automatic category name - Providers: Write to multiple destinations (console, file, cloud)
- Configuration: Control verbosity per namespace
19.2 Built-in Logging in .NET 10
// appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"CommandAPI": "Debug",
"CommandAPI.Controllers": "Information",
"CommandAPI.Data": "Debug"
}
}
}
// appsettings.Development.json
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information" // Show SQL
}
}
}
Log Level Hierarchy:
- Trace (0): Very detailed, may include sensitive data
- Debug (1): Useful for development
- Information (2): General flow, business logic
- Warning (3): Unexpected but handled situations
- Error (4): Errors and exceptions
- Critical (5): Catastrophic failures
- None (6): Disable logging
19.3 Log Levels and When to Use Them
public class CommandsController : ControllerBase
{
// TRACE: Very detailed debugging
_logger.LogTrace(
"Entering CreateCommand with DTO: {@CommandDto}",
commandDto);
// DEBUG: Detailed information for diagnosis
_logger.LogDebug(
"Validating platform ID: {PlatformId} for command creation",
commandDto.PlatformId);
// INFORMATION: General business flow
_logger.LogInformation(
"Creating new command for platform {PlatformId} by user {UserId}",
commandDto.PlatformId,
userId);
// WARNING: Unexpected but handled
_logger.LogWarning(
"Platform {PlatformId} has {CommandCount} commands, approaching limit",
platformId,
commandCount);
// ERROR: Exception occurred
try
{
await _repository.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
_logger.LogError(ex,
"Failed to save command. Platform: {PlatformId}, User: {UserId}",
commandDto.PlatformId,
userId);
throw;
}
// CRITICAL: Application-wide failure
catch (Exception ex)
{
_logger.LogCritical(ex,
"Catastrophic failure in CreateCommand. System may be unstable.");
throw;
}
}
19.4 CORRECTION: Replacing All Console.WriteLine
// ❌ WRONG (current code in ProcessCommandJob.cs)
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine(JsonConvert.SerializeObject(commandModel));
Console.ResetColor();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"--> Position {position} OK");
Console.ResetColor();
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"--> Position {position} exception: {exception}");
Console.ResetColor();
// ✅ CORRECT
public class ProcessCommandJob
{
private readonly ILogger<ProcessCommandJob> _logger;
public ProcessCommandJob(
ICommandRepository commandsRepo,
IBulkJobRepository bulkJobRepo,
IMapper mapper,
ILogger<ProcessCommandJob> logger)
{
_commandsRepo = commandsRepo;
_bulkJobRepo = bulkJobRepo;
_mapper = mapper;
_logger = logger;
}
public async Task Execute(string userId, CommandMutateDto[] commandMutateDtos,
PerformContext context)
{
string jobId = context.BackgroundJob.Id;
_logger.LogInformation(
"Starting bulk job {JobId} with {CommandCount} commands for user {UserId}",
jobId,
commandMutateDtos.Length,
userId);
int position = 0;
int successCount = 0;
int failureCount = 0;
foreach (var commandDto in commandMutateDtos)
{
position++;
string exception = "n/a";
var commandModel = _mapper.Map<Command>(commandDto);
commandModel.UserId = userId;
try
{
_logger.LogDebug(
"Processing command at position {Position}: {@Command}",
position,
commandModel);
await _commandsRepo.CreateCommandAsync(commandModel);
await _commandsRepo.SaveChangesAsync();
successCount++;
_logger.LogInformation(
"Successfully processed command at position {Position}. " +
"Job: {JobId}, CommandId: {CommandId}",
position,
jobId,
commandModel.Id);
}
catch (DbUpdateException dbuEx)
{
failureCount++;
exception = dbuEx.Message;
_logger.LogError(dbuEx,
"Database error processing command at position {Position}. " +
"Job: {JobId}, Error: {ErrorMessage}",
position,
jobId,
exception);
_commandsRepo.DetachModelState(commandModel);
}
catch (Exception ex)
{
failureCount++;
if (exception == "n/a")
exception = ex.Message;
_logger.LogError(ex,
"Unexpected error processing command at position {Position}. " +
"Job: {JobId}",
position,
jobId);
_commandsRepo.DetachModelState(commandModel);
}
finally
{
// Store job result...
}
}
_logger.LogInformation(
"Completed bulk job {JobId}. Success: {SuccessCount}, Failed: {FailureCount}",
jobId,
successCount,
failureCount);
}
}
Benefits of ILogger over Console.WriteLine:
- ✅ Works in cloud environments (Azure, AWS)
- ✅ Configurable output destinations
- ✅ Filterable by log level
- ✅ Structured data (easy to query)
- ✅ Includes timestamps, categories automatically
- ✅ Performance optimized
- ✅ Thread-safe
19.5 Logging in Controllers, Repositories, and Services
// Controller logging
public class PlatformsController : ControllerBase
{
private readonly ILogger<PlatformsController> _logger;
[HttpPost]
public async Task<ActionResult<PlatformReadDto>> CreatePlatform(
PlatformMutateDto platformDto)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
_logger.LogInformation(
"User {UserId} creating platform: {PlatformName}",
userId,
platformDto.PlatformName);
try
{
var platform = _mapper.Map<Platform>(platformDto);
platform.CreatedBy = userId;
await _repository.CreatePlatformAsync(platform);
await _repository.SaveChangesAsync();
_logger.LogInformation(
"Platform created successfully. ID: {PlatformId}, Name: {PlatformName}",
platform.Id,
platform.PlatformName);
var readDto = _mapper.Map<PlatformReadDto>(platform);
return CreatedAtRoute(nameof(GetPlatformById),
new { id = readDto.Id }, readDto);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to create platform {PlatformName} for user {UserId}",
platformDto.PlatformName,
userId);
throw;
}
}
}
// Repository logging
public class PgSqlCommandRepository : ICommandRepository
{
private readonly AppDbContext _context;
private readonly ILogger<PgSqlCommandRepository> _logger;
public PgSqlCommandRepository(
AppDbContext context,
ILogger<PgSqlCommandRepository> logger)
{
_context = context;
_logger = logger;
}
public async Task<Command?> GetCommandByIdAsync(int id)
{
_logger.LogDebug("Fetching command from database. ID: {CommandId}", id);
var command = await _context.Commands
.Include(c => c.Platform)
.FirstOrDefaultAsync(c => c.Id == id);
if (command == null)
{
_logger.LogDebug("Command not found. ID: {CommandId}", id);
}
return command;
}
public async Task<bool> SaveChangesAsync()
{
try
{
var changeCount = await _context.SaveChangesAsync();
_logger.LogDebug(
"Saved {ChangeCount} changes to database",
changeCount);
return changeCount >= 0;
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Database update failed");
throw;
}
}
}
// Service logging
public class JobStatusService
{
private readonly ILogger<JobStatusService> _logger;
public JobStatusService(ILogger<JobStatusService> logger)
{
_logger = logger;
}
public string GetJobStatus(string jobId)
{
_logger.LogDebug("Checking status for job {JobId}", jobId);
using (var connection = JobStorage.Current.GetConnection())
{
var jobData = connection.GetJobData(jobId);
if (jobData == null)
{
_logger.LogWarning("Job not found: {JobId}", jobId);
return "Job not found.";
}
_logger.LogDebug(
"Job {JobId} status: {Status}",
jobId,
jobData.State);
return jobData.State;
}
}
}