Instrumenting System.CommandLine-based .NET applications

Instrumenting System.CommandLine-based .NET applications

In our previous posts, we've learned how to build beautiful command-line applications using System.CommandLine and modern dependency injection. There might be situations where you want to monitor how your application is used. The specifics of what telemetry data to gather will largely depend on your application's nature. Remember, it's not appropriate to collect personally identifiable information (PII) in publicly available apps, although it might be acceptable if you're creating an internal tool for your company.

This blog post will guide you on how to - one more time - build on top of System.CommandLine to track usage, understand your application's behavior, and gather valuable data. This might include the duration of the command execution, user information, command arguments, and more.

Yet again we're going to implement a custom System.CommandLine middleware that will gather information about the current command execution:

using System.CommandLine.Invocation;
using System.CommandLine.Parsing;

namespace System.CommandLine.Builder;

internal static class TelemetryMiddleware
{
    public static CommandLineBuilder UseTelemetry(this CommandLineBuilder builder)
    {
        return builder.AddMiddleware(async (context, next) =>
        {
            // Track command name, command arguments and username
            var commandName = GetFullCommandName(context.ParseResult);
            var commandArgs = string.Join(' ', context.ParseResult.Tokens.Select(t => t.Value));
            var userName = Environment.UserName;

            try
            {
                await next(context);

                // Track command duration
                // Mark command as successful
            }
            catch (Exception ex)
            {
                // Track command duration
                // Mark command as failed, track exception

                throw;
            }
        }, MiddlewareOrder.ExceptionHandler);
    }

    private static string GetFullCommandName(ParseResult parseResult)
    {
        var commandNames = new List<string>();
        var commandResult = parseResult.CommandResult;

        while (commandResult != null && commandResult != parseResult.RootCommandResult)
        {
            commandNames.Add(commandResult.Command.Name);
            commandResult = commandResult.Parent as CommandResult;
        }

        commandNames.Reverse();

        return string.Join(' ', commandNames);
    }
}

Don't forget to add your new telemetry middleware to your application:

var rootCommand = new RootCommand
{
    // subcommands here
};

var builder = new CommandLineBuilder(rootCommand);

builder.UseDefaults();
builder.UseTelemetry();
builder.UseDependencyInjection(services => { /* [...] */ });

return builder.Build().Invoke(args);

Choosing how you collect telemetry is up to you. You could send it to a specific endpoint or use Application Insights SDK or OpenTelemetry for automated trace creation. The latter might be a more modern telemetry collection method. OpenTelemetry traces can include hierarchical spans, allowing your application to track new processes or HTTP calls and group them as part of a single trace. OpenTelemetry can really make debugging and troubleshooting way easier.

Lastly, consider offering a way for your users to opt-out of telemetry collection. You might implement something similar to .NET's DOTNET_CLI_TELEMETRY_OPTOUT environment variable.