Logging in CLI Apps

Add structured logging to your CLI commands using Microsoft.Extensions.Logging

In this tutorial, we'll add logging to a CLI application with configurable log levels. By the end, you'll have commands that use structured logging with levels controllable via command-line options.

What We're Building

Here's how our CLI will work when we're done:

Screencast of the Logging Tutorial

A file processing command that logs its progress. Users can control verbosity with --logLevel.

Prerequisites

1

Starting Without Logging

Let's start with a simple file processing command:

bash
dotnet new console -n LoggingApp
cd LoggingApp
dotnet add package Spectre.Console.Cli

Replace Program.cs with a command that processes files:

csharp
public class ProcessSettings : CommandSettings
{
    [CommandArgument(0, "<path>")]
    [Description("The path to process")]
    public string Path { get; init; } = string.Empty;
}
  
internal class ProcessCommand : Command<ProcessSettings>
{
    protected override int Execute(CommandContext context, ProcessSettings settings, CancellationToken cancellation)
    {
        AnsiConsole.WriteLine($"Starting to process: {settings.Path}");
  
        // Simulate some work
        for (var i = 1; i <= 3; i++)
        {
            Thread.Sleep(100);
            AnsiConsole.WriteLine($"Processing step {i}...");
        }
  
        AnsiConsole.WriteLine("Processing complete!");
        return 0;
    }
}

Wire it up:

csharp
using Spectre.Console.Cli;
  
var app = new CommandApp<ProcessCommand>();
return app.Run(args);

Run the application:

bash
dotnet run -- myfile.txt
# Starting to process: myfile.txt
# Processing step 1...
# Processing step 2...
# Processing step 3...
# Processing complete!

This works, but we're using Console.WriteLine directly. We have no way to control verbosity or integrate with logging infrastructure.

2

Adding Structured Logging

Add the logging packages:

bash
dotnet add package Microsoft.Extensions.Logging
dotnet add package Microsoft.Extensions.Logging.Console
dotnet add package Microsoft.Extensions.DependencyInjection

Create the DI bridge classes and update the command to inject ILogger<T>:

csharp
public sealed class TypeRegistrar(IServiceCollection services) : ITypeRegistrar
{
    public ITypeResolver Build() => new TypeResolver(services.BuildServiceProvider());
  
    public void Register(Type service, Type implementation) => services.AddSingleton(service, implementation);
  
    public void RegisterInstance(Type service, object implementation) => services.AddSingleton(service, implementation);
  
    public void RegisterLazy(Type service, Func<object> factory) => services.AddSingleton(service, _ => factory());
}
  
public sealed class TypeResolver(IServiceProvider provider) : ITypeResolver
{
    public object? Resolve(Type? type) => type == null ? null : provider.GetService(type);
}
csharp
internal class ProcessCommand : Command<ProcessSettings>
{
    private readonly ILogger<ProcessCommand> _logger;
  
    public ProcessCommand(ILogger<ProcessCommand> logger)
    {
        _logger = logger;
    }
  
    protected override int Execute(CommandContext context, ProcessSettings settings, CancellationToken cancellation)
    {
        _logger.LogInformation("Starting to process: {Path}", settings.Path);
  
        // Simulate some work with different log levels
        for (var i = 1; i <= 3; i++)
        {
            _logger.LogDebug("Detailed step {Step} information", i);
            Thread.Sleep(100);
            _logger.LogInformation("Processing step {Step}...", i);
        }
  
        _logger.LogInformation("Processing complete!");
        return 0;
    }
}

Configure logging in your entry point:

csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console.Cli;
  
var services = new ServiceCollection();
services.AddLogging(builder =>
{
    builder.AddConsole();
    builder.SetMinimumLevel(LogLevel.Information);
});
  
var registrar = new TypeRegistrar(services);
var app = new CommandApp<ProcessCommand>(registrar);
return app.Run(args);

Run it again:

bash
dotnet run -- myfile.txt
# info: ProcessCommand[0]
#       Starting to process: myfile.txt
# info: ProcessCommand[0]
#       Processing step 1...

Now we have structured logging with category names and log levels. But the level is hard-coded. Let's make it configurable.

3

Configurable Log Level with Interceptor

Now we'll add command-line control over the log level using a base settings class and an interceptor.

First, create a LogLevelSwitch that holds the current minimum level, and a base LogCommandSettings class:

csharp
public class LogLevelSwitch
{
    public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
}
  
public class LogCommandSettings : CommandSettings
{
    [CommandOption("--logLevel")]
    [Description("Minimum level for logging (Trace, Debug, Information, Warning, Error, Critical)")]
    [DefaultValue(LogLevel.Information)]
    public LogLevel LogLevel { get; set; }
}

Create an interceptor that reads the settings and updates the switch before command execution:

csharp
public class LogInterceptor(LogLevelSwitch logLevelSwitch) : ICommandInterceptor
{
    public void Intercept(CommandContext context, CommandSettings settings)
    {
        if (settings is LogCommandSettings logSettings)
        {
            logLevelSwitch.MinimumLevel = logSettings.LogLevel;
        }
    }
}

Update your settings to inherit from LogCommandSettings:

csharp
public class ProcessSettings : LogCommandSettings
{
    [CommandArgument(0, "<path>")]
    [Description("The path to process")]
    public string Path { get; init; } = string.Empty;
}

Configure the logging filter to check the switch, and register the interceptor:

csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console.Cli;
  
var services = new ServiceCollection();
  
// Create a log level switch that can be modified at runtime
var logLevelSwitch = new LogLevelSwitch();
services.AddSingleton(logLevelSwitch);
  
// Configure logging with a filter that checks the switch
services.AddLogging(builder =>
{
    builder.AddConsole();
    builder.AddFilter((category, level) => level >= logLevelSwitch.MinimumLevel);
});
  
var registrar = new TypeRegistrar(services);
var app = new CommandApp<ProcessCommand>(registrar);
  
app.Configure(config =>
{
    // Set up the interceptor to configure logging before command execution
    config.SetInterceptor(new LogInterceptor(logLevelSwitch));
});
  
return app.Run(args);

The TypeRegistrar and TypeResolver stay the same as Step 2.

Try different log levels:

bash
dotnet run -- myfile.txt
# info: Processing step 1...
  
dotnet run -- myfile.txt --logLevel Debug
# dbug: Detailed step 1 information
# info: Processing step 1...
  
dotnet run -- myfile.txt --logLevel Warning
# warn: This is a warning message

This pattern has several advantages:

  • Reusable: Any command can inherit from LogCommandSettings to get the --logLevel option
  • Centralized: The interceptor handles log configuration in one place
  • Runtime configurable: Users control verbosity without recompiling
  • Structured: Log messages include categories, levels, and structured parameters

Congratulations!

You've built a CLI application with configurable logging:

  • Commands inject ILogger<T> for structured logging
  • A LogLevelSwitch allows runtime log level changes
  • An interceptor reads command settings and configures logging before execution
  • Base settings classes provide reusable options across commands

Next Steps