Alerts – Part Two

Alerts – Part Two

Last time I laid out my abstraction for handling alerts. I showed everyone my default alert handler, which, admittedly, doesn’t do much. I promised I would follow up by showing a more robust alert handler in my next post. I’ll do that now.

The handler I use most often is called HostedAlertHandler, and I put the code for it the CODEGATOR CG.Hosting NUGET package. That package contains general hosting extensions that I use on a good many projects. I put the handler in the CG.Hosting project, rather than the CG.Alert package, because this handler relies on the host for resolving services at runtime. I don’t want CG.Alert to have dependencies on the Microsoft.Extensions.Hosting package, since at least some of the projects I support, don’t use hosting, and I want them to be able to benefit from alert handling, even without the use of hosting.

The handler class is fairly lengthy, so, for the sake of brevity, I’ll show a single method, say HandleCritical, and then we can break down what it’s doing. The other methods in the class are all similar, in function. Some send emails, some SMS messages, others only log. Whatever the case though, they all generally follow this pattern:

protected override void HandleCritical(
    IDictionary<string, object> args,
    [CallerMemberName] string memberName = null,
    [CallerFilePath] string sourceFilePath = null,
    [CallerLineNumber] int sourceLineNumber = 0
    )
{
    Guard.Instance().ThrowIfNull(args, nameof(args));

    var handled = false;

    var options = Host.Services.GetService<IOptions<TOptions>>();
    if (null != options)
    {
        if (null != options.Value.Alerts)
        {
            if (null != options.Value.Alerts.CriticalAlerts)
            {
                var logger = Host.Services.GetService<ILogger<Alert>>();

                if (null != logger)
                {
                    try
                    {
                        if (args.ContainsKey("ex"))
                        {
                            logger.LogCritical(
                                args["message"] as string,
                                args["ex"] as Exception
                                );
                        }
                        else
                        {
                            logger.LogCritical(
                                args["message"] as string
                                );
                        }

                        if ((null == options.Value.Alerts.CriticalAlerts.Email ||
                            false == options.Value.Alerts.CriticalAlerts.Email.Enabled) && 
                            (null == options.Value.Alerts.CriticalAlerts.Sms ||
                            false == options.Value.Alerts.CriticalAlerts.Sms.Enabled))
                        {
                            handled = true;
                        }
                    }
                    catch (Exception ex)
                    {
                        var newArgs = new Dictionary<string, object>()
                        {
                            { "message", $"Failed to log a critical event in the hosted handler! Error: {ex.Message}" }
                        };

                        base.HandleError(
                            newArgs
                            );
                    }
                }
            }

            var tokens = new Dictionary<string, string>(Tokens)
            {
                { TokenNames.MSG, args["message"] as string }
            };

            if (args.ContainsKey("ex"))
            {
                tokens[TokenNames.EX] = (args["ex"] as Exception).Message;
            }

            if (null != options.Value.Alerts.CriticalAlerts.Email)
            {
                if (options.Value.Alerts.CriticalAlerts.Email.Enabled)
                {
                    var email = Host.Services.GetService<IEmailService>();
                    if (null != email)
                    {
                        try
                        {
                            var body = Template.Render(
                                options.Value.Alerts.CriticalAlerts.Email.Body,
                                tokens
                                );

                            var subject = Template.Render(
                                options.Value.Alerts.CriticalAlerts.Email.Subject,
                                tokens
                                );

                            _ = email.Send(
                                options.Value.Alerts.CriticalAlerts.Email.From,
                                options.Value.Alerts.CriticalAlerts.Email.To,
                                subject,
                                body,
                                options.Value.Alerts.CriticalAlerts.Email.IsHtml
                                );

                            if (null == options.Value.Alerts.CriticalAlerts.Sms ||
                                false == options.Value.Alerts.CriticalAlerts.Sms.Enabled)
                            {
                                handled = true;
                            }
                        }
                        catch (Exception ex)
                        {
                            var newArgs = new Dictionary<string, object>()
                            {
                                { "message", $"Failed to email a critical event in the hosted handler! Error: {ex.Message}" }
                            };

                            base.HandleError(
                                newArgs
                                );
                        }
                    }
                }
            }

            if (null != options.Value.Alerts.CriticalAlerts.Sms)
            {
                if (options.Value.Alerts.CriticalAlerts.Sms.Enabled)
                {
                    var sms = Host.Services.GetService<ISmsService>();

                    if (null != sms)
                    {
                        var body = Template.Render(
                            options.Value.Alerts.CriticalAlerts.Sms.Body,
                            tokens
                            );

                        _ = sms.Send(
                            options.Value.Alerts.CriticalAlerts.Sms.To,
                            body
                            );

                        handled = true;
                    }
                }
            }
        }
    }

    if (false == handled)
    {
        base.HandleError(
            args,
            memberName,
            sourceFilePath,
            sourceLineNumber
            );
    }
}

This method handles critical error alerts. In my mind, a critical alert is one where the application is about to die, or at the very least require some human interaction soon. Because of that, this handler performs the following steps:

  1. Checks the options to see if the handler should process critical alerts, or not.
  2. Log the alert arguments.
  3. Check the options to see if the handler should send an email for critical alerts, or not.
  4. Format an email body from the template options, using runtime values.
  5. Send the alert to the email destination(s) in the options.
  6. Check the options to see if the handler should send an SMS message for critical alerts, or not.
  7. Format an SMS body from the template options, using runtime values.
  8. Send an SMS message to the destination(s) in the options.

As we can see, even this one handler is doing quite a bit of work. Let’s walk through the code and see what it all looks like.

The first thing I do is validate the incoming parameters. After that, I look for the TOptions in the host’s service collection. If I don’t find options in the service collection, I don’t handle the alert – instead I simply defer to the base class’s HandleCritical method. Most of the time there will be options, since the application will have been configured when it was deployed. Still, it pays not to assume things, so, I check first.

Logging is the next thing I do in this handler method, so I try to get a logger object from the host’s service collection. If I can’t, for whatever reason, I just skip the logging portion of the handler. Assuming I do find a logger, I then use it to log the alert – using either the message argument, or the message and the exception arguments, from the args parameter. After logging, I check the options again and if that’s all I’m supposed to do, for this alert, then I set the handled variable to true and return from the method.

After logging I move on to sending an email for the alert. Just like for logging, I start by getting an email object from the host’s service collection. If I can’t, for whatever reason, I just skip the email portion of the handler. Assuming I do, I then move on to check the options, to determine whether I should send an email for the alert. If I should, then I build up a template for the email using values taken from the environment, and passed in using the args parameter. Once I have the template built up, I use it to generate the email. After emailing, I check the options again and if that’s all I’m supposed to do, for this alert, then I set the handled variable to true and return from the method.

After emailing, I move on to sending an SMS message for the alert. Just like for emailing, I start by getting an SMS object from the host’s service collection. If I can’t, for whatever reason, I just skip the SMS portion of the handler. Assuming I do, I then move on to check the options again, to determine whether I should send an SMS for the alert. If I should, then I build up a template for the SMS message using values taken from the environment, and passed in using the args parameter. Once I have the template built up, I use it to generate the SMS message.

After the handler has finished logging, emailing and SMSing, I check the value of the handled variable. If it’s set to true, I just exit the method. If it’s set to false, I call the base class’s implementation of the HandleCritical method. In this way, I ensure that, even if this handler fails to process the alert, we can still do something for it, by using the base implementation.

Sprinkled throughout the handler are try/catch blocks. These attempt to catch any errors that happen while I’m in the process of handling the alert. Handling errors while in the process of handling errors can get very involved and hazardous. Because of that, I respond to any errors in this handler method (as well as any of my other handler methods) by calling the HandleError method on the base class. From there, it’s really up to the base class to determine what happens to the error information generated by the handler method. Either way though, the original error (which is really an alert), is processed by the rest of this handler method. I’ve tried various strategies for handling errors that are raised while handling errors, and, this seems to be the most straightforward, and reliable at runtime.

The SMS and email templates are populated using a class utility called Template. Here is the code for that:

public static class Template
{
    public static string Render(
        string template,
        IDictionary<string, string> tokens
        )
    {
        var sb = new StringBuilder(template);

        foreach (var key in tokens.Keys)
        {
            var value = tokens[key];
            sb.Replace(key, value);
        }

        return sb.ToString();
    }
}

So the render method here simply loops through the tokens parameter, which is really a dictionary, and replaces any tokens in the template with their corresponding values.

The tokens themselves have standardized names, which are laid out in the TokenNames class utility:

public static class TokenNames
{
    public const string APP = "%APP%";
    public const string MN = "%MN%";
    public const string USER = "%USER%";
    public const string MSG = "%MSG%";
    public const string EX = "%EX%";
}

I created an extension off the IHost type, called SetHostedAlertHandler, for wiring up this hosted alert hander. The code for that method looks like this:

public static IHost SetHostedAlertHandler<TOptions>(
    this IHost host
    ) where TOptions : StandardOptions, new()
{
    Guard.Instance().ThrowIfNull(host, nameof(host));

    Alert.Instance().SetHandler(
        new HostedAlertHandler<TOptions>(host)
        );

    return host;
}

Using this extension method is easy, on a hosted application. Just do something like this:

StandardHost.CreateStandardBuilder<Program, MyOptions>()
    .Build()
    .SetHostedAlertHandler<MyOptions>()
    .Run();

Where Program is the standard .NET Program class and MyOptions is a custom options class, containing your application’s options. MyOptions probably looks something like this:

class MyOptions : StandardOptions
{
    // your properties here.
}

The StandardOptions class is part of my hosting extensions. That class looks like this:

public class StandardOptions : OptionsBase
{
    public AlertHandlerOptions Alerts { get; set; }
    public ServiceOptions Services { get; set; }
}

As expected, StandardOptions derives from the OptionsBase class, which are part of the options extensions in the CODEGATOR CG.Options NUGET package. If I haven’t already, by the time this blog post is published, then I’ll blog about my options extensions shortly. For now, just realize that OptionsBase provides the ability to validate the options using standard .NET data annotations attributes.

The AlertHandlerOptions class contains all the options for the alert handling process. Here is what that class looks like:

public class AlertHandlerOptions : OptionsBase
{
    public AuditAlertOptions AuditAlerts { get; set; }
    public DebugAlertOptions DebugAlerts { get; set; }
    public InformationAlertOptions InformationAlerts { get; set; }
    public WarningAlertOptions WarningAlerts { get; set; }
    public ErrorAlertOptions ErrorAlerts { get; set; }
    public CriticalAlertOptions CriticalAlerts { get; set; }
    public TraceAlertOptions TraceAlerts { get; set; }
}

As we can see, there are options for each type of alert. I won’t show all these options but I’ll show what the CriticalAlertOptions class looks like:

public class CriticalAlertOptions : OptionsBase
{
    public EmailOptions Email { get; set; }
    public SmsOptions Sms { get; set; }
}

The class contains options for emailing, as well as options for SMS messaging. That makes sense, since the corresponding HandleCritical alert method, on the HostedAlertHandler class, performs both emailing as text messaging.

The ServiceOptions class is where I configure the services that the handler relies on. Here is what that class looks like:

public class ServiceOptions : OptionsBase
{
    public SerilogServiceOptions Logging { get; set; }
    public EmailServiceOptions Email { get; set; }
    public SmsServiceOptions Sms { get; set; }
}

The SerilogServiceOptions, EmailServiceOptions and SmsServiceOptions classes are part of their respective libraries, CG.Serilog, CG.Email, and CG.Sms. Of of those packages are beyond the scope of this blog post. Just realize that they each provide services to the handler, at runtime.

That’s about it for my hosted alert handler. I tried to make a handler that is general enough for everyday use, but, configurable enough for most scenarios. In the event I ever need to add additional behavior to this handler, I can always derive from the HostedAlertHandler class and override the method that needs modifying. In all, I think that’s a pretty flexible approach to error handling. I don’t miss writing this code over and over, every time I need to handle alerts in my service projects.

Photo by camilo jimenez on Unsplash