Logging to Sentry in ASP.NET Core

With the increasingly distributed nature of modern web applications, logging can become problematic. Infrastructure providers such as Docker enable us to linearly scale applications on any number of virtual machines. That's great, but what about the logs? When using traditional file logging implementations, we'd have to first figure out which container the error occurred on, we'd then have to attach to the container, and retrieve the relevant logs. That takes up a lot more time than it should.

Fortunately, error reporting services such as Sentry come to the rescue. They enable applications to log to a centralized repository, and provide rich tools for analysing any errors or log message that are reported. No longer are we plagued with logging into various containers or machines and retrieving text files - it's all right there, on a single dashboard.

Middleware

ASP.NET Core has a concept called middleware. Middleware is user code that runs as part of the request pipeline. It can obtain information about requests, modify the response, and virtually everything in between.

In .NET Core 2.0, a good example of standard middleware is the DeveloperExceptionPageMiddleware component. When in development, if an exception occurs, this middleware component catches it and displays the relevant details to the developer.

That's fantastic, but we definitely don't want to be exposing stack traces and other juicy details to end users in a production setting, so we can't be running that out in the wild.

But then what do we do? As it turns out, ASP.NET Core MVC doesn't really provide any production-ready solution for logging unhandled exceptions. We'll have to roll our own.

Taking a closer look

If we take a look at the source code of the aforementioned DeveloperExceptionPageMiddleware, we can see that it essentially takes the RequestDelegate, wraps it in a try/catch clause, and if an exception occurs, some code is executed to display the exception details to the developer.

Surely we can do something similar to log unhandled exceptions to an error reporting service such as Sentry.

Sentry uses a client called "Raven" to do its error reporting. For .NET Core, a package named RavenSharp.Core is available on NuGet. It's worth noting that this is not actually an official package, but instead one written by a Sentry user. At the moment of writing, there is no official .NET Core package available, and the one written by wakawaka54 works just fine.

Taking inventory, we'll need the following for our middleware to work:

  1. Configuration for our Sentry DSN
  2. A class to send our error reports to Sentry
  3. A middleware component to catch any unhandled exceptions

1. Configuration

Configuration in ASP.NET Core applications is specified in appSettings.json. In our case, we'll need to be able to configure the Sentry DSN, which is just a string:

  "Sentry": {
    "Dsn": "{your_DSN_here}"
  } 

Alright. Next up, we'll need to hook this up to a type so we can pass it as an IOptions<T> type to our error reporting class.

Something along the lines of:

    public class SentryOptions
    {
        public string Dsn { get; set; }
    }

Should do just fine. Of course, there are more variables you can configure, but I'll leave those up to you.

Next up, in Startup.cs, we'll need to grab the options in our app settings and map them to our freshly created SentryOptions type so we can inject IOptions<SentryOptions> further down the road. In your ConfigureServices method, add the following line:

    services.Configure<SentryOptions>(Configuration.GetSection("Sentry"));

Of course, if you named your configuration section anything other than Sentry, you'll need to adjust the above call accordingly.

Alright, that should take care of the configuration part. We should be all set to write the actual error reporter now.

2. Error Reporting class

Thanks to RavenSharp, writing the error reporting class is a breeze. All we'll need to do is wrap the CaptureAsync method for at least Exception (though it's useful to wrap the string overload as well for non-exception logging purposes)

The below code should be fairly straight forward:

    public interface IErrorReporter
    {
        Task CaptureAsync(Exception exception);

        Task CaptureAsync(string message);
    }

The reason I'm declaring an IErrorReporter interface instead of hooking this stuff up to the Microsoft.Extensions.Logging abstractions is because logging unhandled exceptions to services like Sentry is a hail-mary, last resort action, whereas general logging should (in my opinion) not end up in services such as Sentry, but in files, log stashes, or similar.

3. Middleware

While we have a perfectly viable way of logging exceptions to Sentry at our disposal now, we're doing this whole thing so we can handle those pesky unhandled exceptions that would otherwise go unnoticed.

As discussed before, we can achieve this through the use of a middleware component. Since we're not manipulating the response, we can simplify the developer exception page middleware significantly, down to the point where just need to inject our error reporter class and wrap the request in a try/catch block:

And that's really all there's to it.

4. Wiring

All that's left is to wire up the dependency injection and adding the middleware to the request pipeline.

First off, in your Startup.cs, add the following to your ConfigureServices method:

    services.AddScoped<IErrorReporter, SentryErrorReporter>();

This will set up the DI container to inject an instance of our SentryErrorReporter into the middleware on a per-request scope.

Then, near the top of Configure, add:

    app.UseMiddleware<SentryMiddleware>();

Keep in mind the ASP.NET Core goes in both directions, as illustrated by this image:
ASP.NET Core Middleware

Since we've written our middleware to not handle the exception but instead to rethrow it, we need to make sure we add our middleware before any other exception-handling middleware on the way back to the response.

By default, ASP.NET Core MVC applications contain an error handler along the lines of app.UseExceptionHandler("/Error"); which will actually catch the exception and handles it by displaying a page to the user whenever an error occurs. We'll want to make sure that during response, our middleware gets the chance to catch the exception before such middleware runs.

Ergo, we should add it along the lines of:

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseBrowserLink();
            }
            else
            {
                app.UseExceptionHandler("/Error");
            }

            app.UseMiddleware<SentryMiddleware>();

So we get the chance to report the exception to Sentry before any other middleware gets a chance to handle it.

Conclusion

We've set up a middleware component that allows all exceptions that aren't handled by user code to be reported directly to Sentry. This way you'll always know what exceptions are being thrown in your exception, in nigh-realtime, allowing you to quickly fix any issues instead of relying on your users to report them to you.

Of course you could replace Sentry with any other similar service in a similar fashion as outlined above.

Keep in mind that users generally don't report errors. They just abandon your product and go search for an alternative that does work. Being aware of issues your users run in as they occur is extremely important, and at least with ASP.NET Core, it's a breeze too.