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 3.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!):
Navigation
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).
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 -> [] :)"); _logger.LogInformation($"We are on [] 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.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 [] <-> []!");
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
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.
Awesome! Since we hooked into the CommandHandler, and use the logger now, let’s test a command and see what happens.
Looks good, logged as expected. Now let’s check something that would log as an error (in this case an unknown command):
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
Notice we don’t have the information messages anymore. However, if we used an unknown command, it should show an error:
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!