19. Structured Logging

  1. Structured Logging

About this chapter

Implement structured logging with ILogger to capture meaningful diagnostic information across your application with filtering and multiple output targets.

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