Alerts Revisited

Alerts Revisited

I recently reimagined my alert library. Here is a quick walkthrough of my new design.


By the way, this library now builds upon the features of my event aggregator library. That code can be downloaded, for free, HERE. The NUGET package, for CG.Events, can also be downloaded from HERE. Finally, I have previously written about my event aggregator HERE.


Two things I want to achieve, with my new alert library are:

  1. I want a service that inherently knows about a “standard” set of alert types, but is also flexible enough to allow us to override those types, and do so transparently to any library that happens to be using the service.
  2. I also want a service that is extensible enough to add new, completely custom alert types, without sacrificing any of the features of the abstraction in the process.

For the first goal, I want to support “standard” alert types, like Audit, Information, Warning, Error, etc. You know, familiar types that most developers will already know what to do with. But, at the same time, I want the alert abstraction itself to handle any details involved with registering those alert event types, managing their lifetimes, etc. In short, I want a caller wishing to my alert service to be able to raise alerts and never have to worry about how any of it works, under the covers.

For the second goal, I want to be able to add custom alerts. That’s because I already have several types of fairly specialized alerts that I routinely raise, in my projects. I want to be able to do that with goal # 1 in mind, and not lose any features of my alert service, in the process of extending it.

So, how will I pull this off? Let’s start by looking at the IAlertService interface, which looks like this:

public interface IAlertService
{
    void Raise<TEvent>(
        params object[] args
        ) where TEvent : AlertEventBase;

    Task RaiseAsync<TEvent>(
        params object[] args
        ) where TEvent : AlertEventBase;
}

So, two methods for raising an alert. Pretty simple, right? By the way, if IAlertService looks suspiciously like an event aggregator, that’s because, it sort of is. What I mean by that is, the alert service sends alerts, like an event aggregator, but only those alert types that derive from the AlertEventBase class. Let’s look at that class now:

public abstract class AlertEventBase : EventBase { }

So, not a lot going on, With AlertEventBase. But, since AlertEventBase shows up in the type constraints, on the IAlertService interface, it means we can force the abstraction to only work with alert types that derive from AlertEventBase.

I won’t attempt to cover a bunch of concrete alert classes in this article. Instead, I’ll focus on one, and just say that the default implementation of the rest are all, probably, very similar. Let’s look at the ErrorAlert class, which looks like this:

public class ErrorAlert : AlertEventBase
{
    protected readonly ILogger<ErrorAlert> _logger;

    public ErrorAlert(
        ILogger<ErrorAlert> logger
        )
    {
        // Validate the parameters before attempting to use them.
        Guard.Instance().ThrowIfNull(logger, nameof(logger));

        // Save the references.
        _logger = logger;
    }

    protected override void OnInvoke(
        params object[] args
        )
    {
        try
        {
            // Are there arguments?
            if (args.Any())
            {
                // Look for an exception in the arguments.
                var ex = args.FirstOrDefault(x =>
                    x.GetType().IsAssignableTo(typeof(Exception))
                    ) as Exception;

                // Did we find one?
                if (null != ex)
                {
                    // Filter out the exception.
                    args = args.Where(x =>
                        !x.GetType().IsAssignableTo(typeof(Exception))
                        ).ToArray();

                    // Are there other arguments?
                    if (args.Any())
                    {
                        // Use structured logging for the args and exception.
                        _logger.LogError(
                            exception: ex,
                            message: "'{AlertType}' Alert --> {Args}",
                            nameof(CriticalErrorAlert),
                            string.Join(", ", args)
                            );
                    }
                    else
                    {
                        // Use structured logging for the exception.
                        _logger.LogError(
                            exception: ex,
                            message: "'{AlertType}' Alert --> (No args)",
                            nameof(CriticalErrorAlert)
                            );
                    }
                }
                else
                {
                    // Use structured logging for the args.
                    _logger.LogError(
                        "'{AlertType}' Alert --> {Args}",
                        nameof(CriticalErrorAlert),
                        string.Join(", ", args)
                        );
                }
            }
            else
            {
                // Log with no arguments.
                _logger.LogError(
                    "'{AlertType}' Alert --> (No args)",
                    nameof(CriticalErrorAlert)
                    );
            }

            // Give the base class a chance.
            base.OnInvoke(args);
        }
        catch (Exception ex)
        {
            // Log the error.
            _logger.LogError(
                ex,
                "Failed to process an error alert event!"
                );
        }
    }
}

First, note that the class derives from our AlertEventBase class, which means we can use it with our IAlertService abstraction.

This default implementation of ErrorAlert does nothing more than log the alert. That’s because a good many applications won’t need anything more than that, anyway. Well, you might say, in that case, why bother with alerts at all? Why not just call a log method on a logger, instead? Keep in mind that this is a default implementation of an alert handler. As we’ll eventually demonstrate, it’s very easy to add custom logic to a handler. But, for now, for out-of-the-box alert handling, there’s no need to force the overhead of email handlers and whatnot when, as I say, most applications simply won’t need that stuff anyway.

Since this alert handler will log an alert, it’s constructor accepts an ILogger reference. It gets that ILogger reference directly from the DI container. I point that out to emphasize that concrete alert classes can accept any registered type, as a parameter, in their constructor. That DI support makes it much, much easier to do whatever you need to do, in your own alert classes.

The OnInvoke override is where the alert class starts to get interesting. Whenever a caller raises an alert, this method is eventually called. For this override, we do some argument parsing, to ensure that, whatever we log, takes advantage of the structured logging facilities built into the ILogger object. Note that, if there is an exception argument, we explicitly pull that out in order to plug it into the logger using the overload that accepts an Exception. I’ve found that produces much better results than simply folding the exception into the array of arguments.

It really doesn’t matter whether we’re raising a warning alert, or an error alert, or whatever, all the default “standard” alert classes will log just like the ErrorAlert class does. The only real difference is, information alerts will log with LogInformation. Warning alerts will log with LogWarning, etc.

Next, let’s look at the AlertService class, which is the default implementation of IAlertService. The class looks like this:

public class AlertService : IAlertService
{
    private readonly AlertOptions _options;
    private readonly IEventAggregator _eventAggregator;
    private readonly ILogger<AlertService> _logger;

    public AlertService(
        IOptions<AlertOptions> options,
        IEventAggregator eventAggregator,
        ILogger<AlertService> logger
        )
    {
        // Validate the parameters before attempting to use them.
        Guard.Instance().ThrowIfNull(options, nameof(options))
            .ThrowIfNull(eventAggregator, nameof(eventAggregator))
            .ThrowIfNull(logger, nameof(logger));

        // Save the references.
        _options = options.Value;
        _eventAggregator = eventAggregator;
        _logger = logger;
    }

    public virtual void Raise<TAlert>(
        params object[] args
        ) where TAlert : AlertEventBase
    {
        try
        {
            // Is anything overridden?
            if (_options.HasOverrides)
			{
                // If we get here then at least one type of 'standard alert'
                //   is overridden. So, now, we need to figure out if TAlert
                //   is derived from a 'standard alert' type, and if so,
                //   substitute the overridden type for TAlert.

                // Is TAlert a kind of audit alert?
                if (_options.AuditAlertType.IsAssignableTo(typeof(TAlert)))
                {
                    // Make the generic method call.
                    var methodInfo = typeof(IEventAggregator)
                        .GetMethod("GetEvent")
                        .MakeGenericMethod(_options.AuditAlertType);

                    // Raise the alert with the overridden type.
                    (methodInfo.Invoke(_eventAggregator, args) as AlertEventBase)
                        .Publish(args);
                }

                // Is TAlert a kind of information alert?
                else if (_options.InformationAlertType.IsAssignableTo(
                         typeof(TAlert)))
                {
                    // Make the generic method call.
                    var methodInfo = typeof(IEventAggregator)
                        .GetMethod("GetEvent")
                        .MakeGenericMethod(_options.InformationAlertType);

                    // Raise the alert with the overridden type.
                    (methodInfo.Invoke(_eventAggregator, args) as AlertEventBase)
                        .Publish(args);
                }

                // Is TAlert a kind of warning alert?
                else if (_options.WarningAlertType.IsAssignableTo(typeof(TAlert)))
                {
                    // Make the generic method call.
                    var methodInfo = typeof(IEventAggregator)
                        .GetMethod("GetEvent")
                        .MakeGenericMethod(_options.WarningAlertType);

                    // Raise the alert with the overridden type.
                    (methodInfo.Invoke(_eventAggregator, args) as AlertEventBase)
                        .Publish(args);
                }

                // Is TAlert a kind of error alert?
                else if (_options.ErrorAlertType.IsAssignableTo(typeof(TAlert)))
                {
                    // Make the generic method call.
                    var methodInfo = typeof(IEventAggregator)
                        .GetMethod("GetEvent")
                        .MakeGenericMethod(_options.ErrorAlertType);

                    // Raise the alert with the overridden type.
                    (methodInfo.Invoke(_eventAggregator, args) as AlertEventBase)
                        .Publish(args);
                }

                // Is TAlert a kind of critical error alert?
                else if (_options.CriticalErrorAlertType.IsAssignableTo(
                         typeof(TAlert)))
                {
                    // Make the generic method call.
                    var methodInfo = typeof(IEventAggregator)
                        .GetMethod("GetEvent")
                        .MakeGenericMethod(_options.CriticalErrorAlertType);

                    // Raise the alert with the overridden type.
                    (methodInfo.Invoke(_eventAggregator, args) as AlertEventBase)
                        .Publish(args);
                }

                // Otherwise we don't know this type.
                else
				{
                    // If we get here then TAlert isn't a 'standard alert' type
                    //   so we can just raise the alert and be done.

                    // Raise the event for the alert.
                    _eventAggregator.GetEvent<TAlert>().Publish(args);
                }
			}
			else
			{
                // If we get here then nothing is overridden so we can just
                //   raise the alert and be done.

                // Raise the event for the alert.
                _eventAggregator.GetEvent<TAlert>().Publish(args);
            }                
        }
        catch (Exception ex)
        {
            // Log the error.
            _logger.LogError(
                ex, 
                "Failed to raise a '{AlertType}' alert!",
                typeof(TAlert).Name
                );
        }
    }
}

Of course, the class implements the IAlertService interface. For brevity, I have only included one of the RaiseAlert methods, since they are essentially the same – other than synchronous / asynchronous differences.

The class has a constructor that accepts an AlertOptions instance (which we’ll cover shortly), an IEventAggregator instance (for distributing events), and an ILogger instance (for internal logging). All of those references are stored, for later, as private fields.

The RaiseAlert method , as its name implies, is used to send alert throughout the application. The method starts by validating all the incoming arguments. Next, it checks to see if any “standard” alert types have been overridden. We haven’t covered overriding a “standard” alert type, yet. For now, just understand that we can do that in order to specify a custom handler, for a “standard” alert type.

If no alert types have been overridden, which is probably true most of the time, then we simply raise the event through the event aggregator and we’re done.

On the other hand, if one or more “standard” alert types have been overridden, then things get a little more interesting. Let’s assume, for this discussion, that the WarningAlert type has been overridden with a hypothetical CustomWarningAlert type (not a real type, just for discussion purposes). That means the HasOverrides property, on the AlertOptions class, will return true. At that point we know we need to figure out which “standard” alert type was overridden. We do that by checking each type, one by one, until we find the one we seek. Once we know which type was overridden, we can use a bit of reflection to create the overridden alert event, in place of the TAlert type that was passed in, by the caller? Once we have the event, we can call Publish on it to raise the actual alert.

Why go through all that? Well, the idea is, to prevent callers from having to know what the current override is, when they raise an event. In other words, you, as a caller, undoubtedly know about the WarningAlert type, and how to use that type to raise a warning alert, but you may not know that the “standard” warning alert type was overridden with the CustomWarningAlert type. Since you don’t know about CustomWarningAlert, you can’t use that type to raise a custom warning alert. Think of this situation arising in library code, where alerts are overridden after your code is compiled and shipped … Yeah, that’s why we do all this.

So, for example, thanks to our ability to override a “standard” alert type, at runtime, we can always raise a warning alert like this:

var alertService = // get a reference to the alert service ...
alertService.RaiseAlert<WarningAlert>("something happened!");

Whereas, if we didn’t do this, your calling code would have to track all overrides, even for “standard” alert types, and you would have to raise your custom warning alert like this:

var alertService = // get a reference to the alert service ...
alertService.RaiseAlert<CustomWarningAlert>("something happened!");

I briefly mentioned the AlertOptions type earlier, and said that I would cover it soon. Let’s do that now. Here is the code for that:

public class AlertOptions
{
    internal Type InformationAlertType { get; set; }
    internal Type WarningAlertType { get; set; }
    internal Type ErrorAlertType { get; set; }
    internal Type CriticalErrorAlertType { get; set; }
    internal Type AuditAlertType { get; set; }
    internal IList<Type> CustomAlertTypes { get; set; }

    internal bool HasOverrides 
    {
		get
		{
            return InformationAlertType != typeof(InformationAlert) ||
                WarningAlertType != typeof(WarningAlert) ||
                ErrorAlertType != typeof(ErrorAlert) ||
                CriticalErrorAlertType != typeof(CriticalErrorAlert) ||
                AuditAlertType != typeof(AuditAlert);
        }
    }

    public AlertOptions()
    {
        // Set defaults.
        InformationAlertType = typeof(InformationAlert);
        WarningAlertType = typeof(WarningAlert);
        ErrorAlertType = typeof(ErrorAlert);
        CriticalErrorAlertType = typeof(CriticalErrorAlert);
        AuditAlertType = typeof(AuditAlert);
        CustomAlertTypes = new List<Type>();
    }

    public void SetInformationAlertType<TEvent>() 
        where TEvent : InformationAlert
    {
        // Set the override.
        InformationAlertType = typeof(TEvent);
    }

    public void SetWarningAlertType<TEvent>()
        where TEvent : WarningAlert
    {
        // Set the override.
        WarningAlertType = typeof(TEvent);
    }

    public void SetErrorAlertType<TEvent>()
        where TEvent : ErrorAlert
    {
        // Set the override.
        ErrorAlertType = typeof(TEvent);
    }

    public void SetCriticalErrorAlertType<TEvent>()
        where TEvent : CriticalErrorAlert
    {
        // Set the override.
        CriticalErrorAlertType = typeof(TEvent);
    }

    public void SetAuditAlertType<TEvent>()
        where TEvent : AuditAlert
    {
        // Set the override.
        AuditAlertType = typeof(TEvent);
    }

    public void AddCustomAlertType<TEvent>()
        where TEvent : AlertEventBase
    {
        // Add the custom type.
        CustomAlertTypes.Add(typeof(TEvent));
    }
}

In this class, each “standard” alert type has a placeholder for an override. Setting an override is as easy as calling the SetXXXAlertType method that corresponds with the alert type you want to override. So, for instance, call SetInformationAlertType to override the information alert.

In addition to the various set methods, for what I keep referring to as “standard” alerts, this class also supports adding completely custom alert types, using the AddCustomAlertType method. That method adds the types to an internal collection. Later, when we’re setting the alert service up, we will iterate through that loop and register, and maintain, each custom type, just like it was part of the set of “standard” alerts. The only real difference is that you can’t override a custom alert type, so the onus of knowing which customer alert type is currently registered falls back to you – at least for your own custom types.

That brings us almost to the end of this library. We need to quickly cover the code that sets up the alert service, with the DI container, and registers all the alert types. That code comprises two extension methods. Let go look at them now.

The first method is called AddAlertServices, and it looks like this:

public static partial class ServiceCollectionExtensions
{
    public static IServiceCollection AddAlertServices(
        this IServiceCollection serviceCollection,
        Action<AlertOptions> optionsDelegate = null 
        )
    {
        // Validate the parameters before attempting to use them.
        Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection));

        // Ensure the event aggregator is registered.
        serviceCollection.AddEventAggregation();

        // Register the alert service.
        serviceCollection.AddSingleton<IAlertService, AlertService>();

        // Create default options.
        var options = new AlertOptions();

        // Should we call the delegate?
        if (null != optionsDelegate)
        {
            // Let the caller set any overrides.
            optionsDelegate(options);
        }

        // Configure the options.
        serviceCollection.AddSingleton<IOptions<AlertOptions>>(
            new OptionsWrapper<AlertOptions>(options)
            );
            
        // Return the service collection.
        return serviceCollection;
    }
}

This method starts by validating incoming arguments. It then makes sure the event aggregator service is registered, since we’ll need that for this library. It then registers our alert service. Finally, if there was an options delegate supplied by the caller, we call that so that the caller can specify any overrides. Once that’s done, we register the AlertOptions, since we’ll need those later, when we create the AlertService instance.

Starting the alert service up and registering all the “standard” alert types is done with the UseAlertService extension method. That code looks like this:

public static partial class ApplicationBuilderExtensions
{
    public static IApplicationBuilder UseAlertServices(
        this IApplicationBuilder applicationBuilder,
        IWebHostEnvironment webHostEnvironment
        )
    {
        // Validate the parameters before attempting to use them.
        Guard.Instance().ThrowIfNull(applicationBuilder, nameof(applicationBuilder))
            .ThrowIfNull(webHostEnvironment, nameof(webHostEnvironment));

        // Get the event aggregator.
        var eventAggregator = applicationBuilder.ApplicationServices.GetRequiredService<
            IEventAggregator
            >();

        // Get the alert service options.
        var options = applicationBuilder.ApplicationServices.GetRequiredService<
            IOptions<AlertOptions>
            >();

        // These are the 'standard' alert events. Other alert events
        //   can, of course, be created elsewhere by the users. These are
        //   the ones we take of creating, here. 

        // Make the generic method call for creating an information alert.
        var methodInfo = typeof(IEventAggregator)
            .GetMethod("GetEvent")
            .MakeGenericMethod(options.Value.InformationAlertType);            

        // Create an information event and subscribe.
        (methodInfo.Invoke(eventAggregator, null) as InformationAlert)
            ?.Subscribe(true);

        // Make the generic method call for creating a warning alert.
        methodInfo = typeof(IEventAggregator)
            .GetMethod("GetEvent")
            .MakeGenericMethod(options.Value.WarningAlertType);

        // Create a warning event and subscribe.
        (methodInfo.Invoke(eventAggregator, null) as WarningAlert)
            ?.Subscribe(true);

        // Make the generic method call for creating a error alert.
        methodInfo = typeof(IEventAggregator)
            .GetMethod("GetEvent")
            .MakeGenericMethod(options.Value.ErrorAlertType);

        // Create an error event and subscribe.
        (methodInfo.Invoke(eventAggregator, null) as ErrorAlert)
            ?.Subscribe(true);

        // Make the generic method call for creating a critical error alert.
        methodInfo = typeof(IEventAggregator)
            .GetMethod("GetEvent")
            .MakeGenericMethod(options.Value.CriticalErrorAlertType);

        // Create a critical error event and subscribe.
        (methodInfo.Invoke(eventAggregator, null) as CriticalErrorAlert)
            ?.Subscribe(true);

        // Make the generic method call for creating an audit alert.
        methodInfo = typeof(IEventAggregator)
            .GetMethod("GetEvent")
            .MakeGenericMethod(options.Value.AuditAlertType);

        // Create an audit error event and subscribe.
        (methodInfo.Invoke(eventAggregator, null) as AuditAlert)
            ?.Subscribe(true);

        // Loop through any custom alert types.
        foreach (var customAlertType in options.Value.CustomAlertTypes)
        {
            // Make the generic method call for creating the alert type.
            methodInfo = typeof(IEventAggregator)
                .GetMethod("GetEvent")
                .MakeGenericMethod(customAlertType);

            // Create a custom event and subscribe.
            (methodInfo.Invoke(eventAggregator, null) as AlertEventBase)
                ?.Subscribe(true);
        }

        // Return the application builder.
        return applicationBuilder;
    }
}

This code begins by validating incoming arguments. Then it asks the DI container for an instance of the event aggregator, and the alert options. Once that’s done, we move through and create the indicated alert type for each of the “standard” alerts. Once each type is created, we call Subscribe on the underlying event, to keep it alive and listening for incoming alerts.

Using the alert services is pretty simple. Start by adding this line to your Startup class:

public void ConfigureServices(IServiceCollection services)
{
    // This line demonstrates registering alert services with a custom alert type.
    services.AddAlertServices(options =>
    {
        options.AddCustomAlertType<CustomAlert>(); 
        options.SetErrorAlertType<CustomErrorAlert>(); 
    });
}

In this example, I’ve demonstrated how to call AddAlertServices to register the alert library. I’ve also demonstrated how to use the optional delegate to specify an override for the error alert type, as well as how to specify a custom alert type. If you don’t want to do either of those last two things, you can also make the call like this:

public void ConfigureServices(IServiceCollection services)
{
    // This line demonstrates registering alert services with a custom alert type.
    services.AddAlertServices();
}

After that, you need to add this line to your Startup class:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // This line demonstrates starting the alert services.
    app.UseAlertServices(env);
}

From there, raising alerts, in your code, is as simple as this:

@page "/"
@inject IAlertService Alerts

<div class="form-group">
    <label>This button demonstrates how to raise an audit alert.</label><br />
    <button @onclick="@(() => Alerts.Raise<AuditAlert>())">Raise Audit Alert</button>
    <label class="small">Look at the console for this alert's output.</label>
</div>

That’s about it! Here is an alert library that can help you simplify the overall error handling logic for your next application. It can also be extended to handle a variety of other scenarios. For instance, I typically use a custom alert to notify my Blazor services when my database(s) are all connected to, migrated, and any seed data is applied. That way, my services don’t start before my database is ready.

Next time I’ll demonstrate overriding a “standard” alert. I’ll also lay out how to use a completely custom alert type. Until then, thanks for reading!


The source code for this library is available, for free, HERE.

The NUGET package is available, for free, HERE.

Photo by Milan Bosancic on Unsplash