Asp.Net Core 2.0 C# Sending Errors by Email using ILogger

Asp.Net Core’s new ILogger interface has been met with mixed reviews. Personally I’m quite a fan as it involves minimal boilerplate to get some decent logging to Azure and the Windows Event Log amongst other targets. One thing that’s not supported out of the box is sending production errors by email.

Implementing ILoggerProvider

Firstly we need our own Logger Provider which we’ll name EmailLoggerProvider. For dependency injection purposes this will implement our own IEmailLoggerProvider class which in turn inherits from the native ILoggerProvider.

The CreateLogger method will return an IEmailLogger which we’ll implement later on.

public interface IEmailLoggerProvider : ILoggerProvider
{
}
public class EmailLoggerProvider : IEmailLoggerProvider
{
    private readonly IEmailLogger _emailLogger;

    public EmailLoggerProvider(IEmailLogger emailLogger)
    {
        _emailLogger = emailLogger;
    }

    public ILogger CreateLogger(string categoryName)
    {
        return _emailLogger;
    }

    public void Dispose()
    {
    }
}<span id="mce_marker" data-mce-type="bookmark" data-mce-fragment="1">​</span>

In order for asp.net to know about this custom logger the provider needs to be addded to the Logger Factory, we also need to register the services for dependency injecion. Both of these tasks are dealt with in startup.cs.

Add the following to the ConfigureServices method:

services.AddSingleton<IEmailLogger, EmailLogger>();
services.AddSingleton<IEmailLoggerProvider, EmailLoggerProvider>();

The Configure method needs to accept an ILoggerFactory and an IEmailLoggerProvider.

public void Configure(IApplicationBuilder app,
    IHostingEnvironment env,
    ILoggerFactory loggerFactory,
    IEmailLoggerProvider emailLoggerProvider)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseBrowserLink();
    }
    else
    {
        loggerFactory.AddProvider(emailLoggerProvider);
    }
    
    app.UseMvcWithDefaultRoute();
}

Implementing ILogger

Now the real work begins, our EmailLogger will now be called whenever there’s an error in production, we need to create this class as a custom implementation of ILogger. To do this I created an IEmailLogger interface which inherits from ILogger.

public interface IEmailLogger : ILogger
{
}

Now for the rather large EmailLogger.cs, I have kept this in one file for the purposes of this blog but I would recommend refactoring this into smaller classes.

 public class EmailLogger : IEmailLogger
    {
        private readonly IOptions<SmtpDetails> _smtpDetails;
        private readonly IConfiguration _configuration;
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly IEmailSender _emailSender;
        private readonly UserManager<ApplicationUser> _userManager;

        public EmailLogger(
            IOptions<SmtpDetails> smtpDetails,
            IConfiguration configuration,
            IHttpContextAccessor httpContextAccessor,
            IEmailSender emailSender,
            UserManager<ApplicationUser> userManager)
        {
            _smtpDetails = smtpDetails;
            _configuration = configuration;
            _httpContextAccessor = httpContextAccessor;
            _emailSender = emailSender;
            _userManager = userManager;
        }

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
        {

            if (exception != null)
            {
                // Add the details of the Http Request if present
                var stringBuilder = new StringBuilder();
                var httpContext = _httpContextAccessor.HttpContext;
                if (httpContext != null)
                {
                    var request = httpContext.Request;
                    stringBuilder.Append("User: ").Append(_userManager.GetUserName(httpContext.User)).Append("<br/>")
                        .Append("Address: ").Append($"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}").Append("<br/>")
                        .Append("Local IP address: ").Append(httpContext.Connection.LocalIpAddress).Append("<br/>")
                        .Append("Remote IP address: ").Append(httpContext.Connection.RemoteIpAddress).Append("<br/>");
                }
                EmailException(exception, stringBuilder);
            }
        }

        private void EmailException(Exception exception, StringBuilder stringBuilder)
        {
            BuildExceptionText(stringBuilder, "<h1>Error </h1>", exception);

            _emailSender.SendMailAsync(
                _smtpDetails.Value, new List<string> { _configuration["ErrorDeliveryEmailAddress"] },
                "System Error", stringBuilder.ToString()).ConfigureAwait(false);
        }

        private StringBuilder BuildExceptionText(StringBuilder stringBuilder, string title, Exception exception)
        {
            stringBuilder.Append(title).Append("<h2>").Append(exception.Message).Append("</h2><br/>")
               .Append(exception.Source ?? "").Append("<hr/>");
            if (exception.StackTrace != null)
            {
                stringBuilder.Append("<h3>Stack trace: </h3><br/>").Append(exception.StackTrace.Replace(Environment.NewLine, "<br/>"));
            }

            if (exception.InnerException != null)
            {
                BuildExceptionText(stringBuilder, "<h2>Inner exception </h2>", exception.InnerException);
            }

            return stringBuilder;
        }

        public IDisposable BeginScope<TState>(TState state)
        {
            return new NoDispose();
        }

        public bool IsEnabled(LogLevel logLevel)
        {
            return true;
        }
    }

    [Serializable]
    internal class NoDispose : IDisposable
    {
        public void Dispose()
        {
        }
    }

I am using my own IEmailSender class, you should replace this with your own email functionality. I stored the SMTP details and the Error Delivery Email Address in my appsettings.json file.

"smtpDetails": {
    "host": "",
    "username": "",
    "password": "",
    "port": 25,
    "enablessl": true,
    "domain": "",
    "senderEmail": "",
    "senderName": ""
  },
  "ErrorDeliveryEmailAddress": "james@jdono.com"

There are some dependencies here on Asp.Net Identity and the HttpContext accessor, if you’re not interested in providing the email recipient with the Username or request address details you can remove these, or you could add more information from the request as desired.

Emailing HTTP 4xx/5xx responses

Using the above implementation there is a quick and easy way to log HTTP client errors and server errors.

Add the following to startup.cs:

if (env.IsDevelopment())
{
    // ...
}
else
{
    // ...
    app.UseStatusCodePagesWithReExecute("/error/{0}");
}

And in your Home controller add a method to deal with errors.

[Route("/Error/{statusCode}")]
public IActionResult Error(int statusCode)
{
    LogHttpError(statusCode);
    ViewData["StatusCode"] = statusCode;
    return View();
}

private void LogHttpError(int statusCode)
{
    var feature = HttpContext.Features.Get<IStatusCodeReExecuteFeature>();
    var exception = new Exception($"Http {statusCode} returned when accessing {feature.OriginalPath}/{feature.OriginalQueryString}");
    _logger.LogError(exception, exception.Message);
}

This will raise an error which will in turn execute our EmailLogger. Clearly, this solution may not be to everyones’ taste as HTTP client errors (4xx) are often dealt with seperately and perhaps wouldn’t be emailed to your development/support team. It would, however, be pretty easy to abstract that away.

Email Logger in action

So with all of the above, when there’s a production error in my application I’m emailed as expected.

I’m also emailed for a 404 response..

So we’re all set. If you have any questions or comments on this implementation please feel free to leave discuss below.

Leave a Reply

Your email address will not be published. Required fields are marked *