Viewing entries tagged
csharp-part-four

C# Discord Bot: Logging All The Things

C# Discord Bot: Logging All The Things

Welcome

No matter what you're using your bot for, more likely than not you'll want to know what the heck is happening at some point. Luckily, we can add a logging service to get this done.

In this post I will be going over how to use Serilog, along with Microsoft’s logging framework/interface to wrap it up for dependency injection. The logging done will be to the console, as well as to a file.

The starting point I will be using is from the prerequisites portion, if you want to follow along and build it out as you go.

Prerequisites

  • .NET Core 2.x

  • A fundamental understanding of the Discord.Net library, or following along step-by-step with (if you’re working on a local bot, just omit the Raspberry Pi steps!):

Adding Required Packages

The first thing we will want to do is add the packages we need to take care of logging.

Here is the list of packages we will need:

Microsoft.Extensions.Logging.Debug
Microsoft.Extensions.Logging.Console
Microsoft.Extensions.Logging
Serilog.Sinks.File
Serilog.Sinks.Console
Serilog.AspNetCore

We can use the dotnet add package command to get them added.

dotnet add package Microsoft.Extensions.Logging.Debug
dotnet add package Microsoft.Extensions.Logging.Console
dotnet add package Microsoft.Extensions.Logging
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.AspNetCore

**Note**
Make sure you are in the csharpi project’s or your own project’s root folder when doing this (the one with the .csproj file in it).

add packages.png

Now that we have the required packages added, we can move on to modifying Program.cs to add some logging goodness!

Modifying Program.cs

The next step will be to modify Program.cs to setup and use logging.
We will be:

  • Adding using statements to bring in the namespaces we need

  • Setting up the Serilog logger with a few options

  • Adding logging to the services provided by dependency injection

  • Invoke the logger

Using Statements

We will need to add the following using statements to Program.cs:

1
2
using Serilog;
using Microsoft.Extensions.Logging;

Modifying the main method to add logging options

Now we will want to add some code to the main method to configure the logger.

  • We will null out the args parameter so we can better take input

  • An if statement will be added to see if an argument was passed to set the logging level

  • The Serilog logger will be created with the options we specify

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
static void Main(string[] args = null)
{
    if (args.Count() != 0)
    {
        _logLevel = args[0];
    } 
    Log.Logger = new LoggerConfiguration()
         .WriteTo.File("logs/csharpi.log", rollingInterval: RollingInterval.Day)
         .WriteTo.Console()
         .CreateLogger();

    new Program().MainAsync().GetAwaiter().GetResult();
}

A note on Log.logger

The Log.Logger portion of the code is what sets up the logging configuration.
In the above example the following options are configured:

  • Write out to the console and to a file

  • The file will be stored in the logs directory (from the project root), and will create a new file named csharpi.log (feel free to change the directory/file name to suite your needs)

  • The files will roll every day, and have each day’s date as a timestamp (Serilog takes care of this magic)

Adding the logger to our service provider / dependency injection

Now we’ll want to change our ConfigureServices method to add logging.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
private ServiceProvider ConfigureServices()
{
    // this returns a ServiceProvider that is used later to call for those services
    // we can add types we have access to here, hence adding the new using statement:
    // using csharpi.Services;
    // the config we build is also added, which comes in handy for setting the command prefix!
    var services = new ServiceCollection()
        .AddSingleton(_config)
        .AddSingleton<DiscordSocketClient>()
        .AddSingleton<CommandService>()
        .AddSingleton<CommandHandler>()
        .AddSingleton<LoggingService>()
        .AddLogging(configure => configure.AddSerilog());

    if (!string.IsNullOrEmpty(_logLevel)) 
    {
        switch (_logLevel.ToLower())
        {
            case "info":
            {
                services.Configure<LoggerFilterOptions>(options => options.MinLevel = LogLevel.Information);
                break;
            }
            case "error":
            {
                services.Configure<LoggerFilterOptions>(options => options.MinLevel = LogLevel.Error);
                break;
            } 
            case "debug":
            {
                services.Configure<LoggerFilterOptions>(options => options.MinLevel = LogLevel.Debug);
                break;
            } 
            default: 
            {
                services.Configure<LoggerFilterOptions>(options => options.MinLevel = LogLevel.Error);
                break;
            }
        }
    }
    else
    {
        services.Configure<LoggerFilterOptions>(options => options.MinLevel = LogLevel.Information);
    }

    var serviceProvider = services.BuildServiceProvider();
    return serviceProvider;
}

The above method will build out the service provider, and handle some logic in regards to the logging level.

MainAsync method modification

We can now clean up the MainAsync method a bit, here are the new contents (note that the logging hooks are removed, and a simple service retrieval takes its place):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public async Task MainAsync()
{
    // call ConfigureServices to create the ServiceCollection/Provider for passing around the services
    using (var services = ConfigureServices())
    {
        // get the client and assign to client 
        // you get the services via GetRequiredService<T>
        var client = services.GetRequiredService<DiscordSocketClient>();
        _client = client;

        // setup logging and the ready event
        services.GetRequiredService<LoggingService>();

        // this is where we get the Token value from the configuration file, and start the bot
        await client.LoginAsync(TokenType.Bot, _config["Token"]);
        await client.StartAsync();

        // we get the CommandHandler class here and call the InitializeAsync method to start things up for the CommandHandler service
        await services.GetRequiredService<CommandHandler>().InitializeAsync();

        await Task.Delay(-1);
    }
}

You can always see the most updated version of this example’s Program.cs file, here: https://github.com/gngrninja/csharpi/blob/03-logging/Program.cs.

**Note**
There is a little chicken and egg here, where we are adding the LoggingService. We will be building that out next!

Adding the LoggingService

The logging service is where we will move the old logging hooks and methods to. This will allow us to remove the following from Program.cs, if it is there:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
client.Log += LogAsync;
client.Ready += ReadyAsync;
services.GetRequiredService<CommandService>().Log += LogAsync;
private Task LogAsync(LogMessage log)
{
    Console.WriteLine(log.ToString());
    return Task.CompletedTask;
}

private Task ReadyAsync()
{
    Console.WriteLine($"Connected as -> [] :)");
    return Task.CompletedTask;
}

To create the logging service, perform the following steps:

1. Create LoggingService.cs in the Services/ folder

2. Place the following content in the file (to see the most updated code, go here: https://github.com/gngrninja/csharpi/blob/03-logging/Services/LoggingService.cs). Be sure to change the namespace to yourprojectname.Services, if needed

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
using Discord;
using Discord.Commands;
using Discord.WebSocket;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using System;

namespace csharpi.Services
{
    public class LoggingService
    {

        // declare the fields used later in this class
        private readonly ILogger _logger;
        private readonly DiscordSocketClient _discord;
        private readonly CommandService _commands;

        public LoggingService(IServiceProvider services)
        { 
            // get the services we need via DI, and assign the fields declared above to them
            _discord = services.GetRequiredService<DiscordSocketClient>(); 
            _commands = services.GetRequiredService<CommandService>();
            _logger = services.GetRequiredService<ILogger<LoggingService>>();

            // hook into these events with the methods provided below
            _discord.Ready += OnReadyAsync;
            _discord.Log += OnLogAsync;
            _commands.Log += OnLogAsync;
        }

        // this method executes on the bot being connected/ready
        public Task OnReadyAsync()
        {
            _logger.LogInformation($"Connected as -> [{_discord.CurrentUser}] :)");
            _logger.LogInformation($"We are on [{_discord.Guilds.Count}] servers");
            return Task.CompletedTask;
        }

        // this method switches out the severity level from Discord.Net's API, and logs appropriately
        public Task OnLogAsync(LogMessage msg)
        { 
            string logText = $"{msg.Source}: {msg.Exception?.ToString() ?? msg.Message}";
            switch (msg.Severity.ToString())
            {
                case "Critical":
                {
                    _logger.LogCritical(logText);
                    break;
                }
                case "Warning":
                {
                    _logger.LogWarning(logText);
                    break;
                }
                case "Info":
                {
                    _logger.LogInformation(logText);
                    break;
                }
                case "Verbose":
                {
                    _logger.LogInformation(logText);
                    break;
                } 
                case "Debug":
                {
                    _logger.LogDebug(logText);
                    break;
                } 
                case "Error":
                {
                    _logger.LogError(logText);
                    break;
                } 
            }

            return Task.CompletedTask; 

        }
    }
}

What LoggingService does

  • The fields in this class are _logger, _discord, and _commands

  • The constructor is what handles the assignment of those fields, and when using Microsoft’s logging interface, we want to assign the _logger field as as ILogger<TypeName>, so in this case ILogger<LoggingService>.

    • This is a nice way to do it, because it abstracts the logging away from implemented framework, which in this case is Serilog. You could change that to a different framework, and this code would stay the same

    • After assigning the fields we hook into the OnReadyAsync and OnLogAsync events, and assign them to the appropriate methods

Now let’s move on the adding logging to an existing service, CommandHandler!

Adding Logging to CommandHandler

This next part here will demonstrate how to add logging to an existing class via dependency injection.

To add logging to the CommandHandler service, we will need to add the following using statement:

using Microsoft.Extensions.Logging;

We’ll then want to create the following field:

private readonly Microsoft.Extensions.Logging.ILogger _logger;

In the constructor, we can assign the logger as we did in the LoggingService, using the type name of this class:

_logger = services.GetRequiredService<ILogger<CommandHandler>>();

Now we can start using _logger, as such:

_logger.LogError($"Command failed to execute for [{context.User.Username}] <-> [{result.ErrorReason}]!");

Much better than strictly using System.Console.WriteLine!

Here is the full code for the updated CommandHandler.cs file (to see the most updated code, go here https://github.com/gngrninja/csharpi/blob/03-logging/Services/CommandHandler.cs):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
using System;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Discord;
using Discord.Commands;
using Discord.WebSocket;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace csharpi.Services
{
    public class CommandHandler
    {
        // setup fields to be set later in the constructor
        private readonly IConfiguration _config;
        private readonly CommandService _commands;
        private readonly DiscordSocketClient _client;
        private readonly IServiceProvider _services;
        private readonly Microsoft.Extensions.Logging.ILogger _logger;

        public CommandHandler(IServiceProvider services)
        {
            // juice up the fields with these services
            // since we passed the services in, we can use GetRequiredService to pass them into the fields set earlier
            _config = services.GetRequiredService<IConfiguration>();
            _commands = services.GetRequiredService<CommandService>();
            _client = services.GetRequiredService<DiscordSocketClient>();
            _logger = services.GetRequiredService<ILogger<CommandHandler>>();
            _services = services;
            
            // take action when we execute a command
            _commands.CommandExecuted += CommandExecutedAsync;

            // take action when we receive a message (so we can process it, and see if it is a valid command)
            _client.MessageReceived += MessageReceivedAsync;
        }

        public async Task InitializeAsync()
        {
            // register modules that are public and inherit ModuleBase<T>.
            await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
        }

        // this class is where the magic starts, and takes actions upon receiving messages
        public async Task MessageReceivedAsync(SocketMessage rawMessage)
        {
            // ensures we don't process system/other bot messages
            if (!(rawMessage is SocketUserMessage message)) 
            {
                return;
            }
            
            if (message.Source != MessageSource.User) 
            {
                return;
            }

            // sets the argument position away from the prefix we set
            var argPos = 0;

            // get prefix from the configuration file
            char prefix = Char.Parse(_config["Prefix"]);

            // determine if the message has a valid prefix, and adjust argPos based on prefix
            if (!(message.HasMentionPrefix(_client.CurrentUser, ref argPos) || message.HasCharPrefix(prefix, ref argPos))) 
            {
                return;
            }
           
            var context = new SocketCommandContext(_client, message);

            // execute command if one is found that matches
            await _commands.ExecuteAsync(context, argPos, _services); 
        }

        public async Task CommandExecutedAsync(Optional<CommandInfo> command, ICommandContext context, IResult result)
        {
            // if a command isn't found, log that info to console and exit this method
            if (!command.IsSpecified)
            {
                _logger.LogError($"Command failed to execute for [{context.User.Username}] <-> [{result.ErrorReason}]!");
                return;
            }
                

            // log success to the console and exit this method
            if (result.IsSuccess)
            {
                _logger.LogInformation($"Command [{command.Value.Name}] executed for [{context.User.Username}] on [{context.Guild.Name}]");
                return;
            }
            
            // failure scenario, let's let the user know
            await context.Channel.SendMessageAsync($"Sorry, {context.User.Username}... something went wrong -> [{result}]!");
        }        
    }
}

Testing it all out

Now its time for the fun part, seeing if it all works!

You can either debug it in VS Code (F5), or from a terminal (ensure you are in the project’s folder) and use:

dotnet run
Screen Shot 2019-07-21 at 12.32.51 PM.png

The logging to the console looks good! On the left it shows the log level next to the timestamp, in this case INF means info. Now let’s look in the logs folder for a log file and see its contents.

Screen Shot 2019-07-21 at 12.36.49 PM.png

Awesome! Since we hooked into the CommandHandler, and use the logger now, let’s test a command and see what happens.

hello.png
hello_logged.png

Looks good, logged as expected. Now let’s check something that would log as an error (in this case an unknown command):

not_cmd_error.png
cmd_error.png

Changing the log level

The log level can be changed by running the bot with an argument (that is the log level). To change the log level to error (thus not logging anything under the error level such as INF/Info, use:

dotnet run error

error.png

Notice we don’t have the information messages anymore. However, if we used an unknown command, it should show an error:

not_cmd_error.png
just_error.png

Conclusion

And that’s that. Now we have some logging in place for use with our Discord Bot!

Be sure to add the /logs folder to your .gitignore file, as you likely don’t want those uploaded to GitHub.

Feel free to change things around and see what you can make happen (or break and fix… break + fix = learn, right?).

In the next part of this series, we will add a database to our project.


If you have any questions or comments, leave them below!

[top]