Reporting API in ASP.NET Core - Network Error Logging

Some time ago I've written a post about general aspects of Reporting API and how to use it in the context of ASP.NET Core. In this post, I want to focus on one specific aspect - reporting network errors.

Network Error Logging provides a mechanism that allows your application to declare a reporting policy that can be used by the browser to report network errors. In order to achieve that, you first need to configure a group of Reporting API endpoints and then point to it with the policy. I've described configuring reporting endpoints in the previous post, so I'll go ahead to declaring a policy.

Declaring a Policy

Network Error Logging policy tells the browser how to use previously defined Reporting API endpoint group to report network errors. The policy is delivered by a dedicated response header called NEL. This header follows a currently popular pattern, where header value is a JSON object. That object must have two members: report_to and max_age. The report_to member specifies previously defined Reporting API endpoints group to be used, while max_age defines the lifetime of the policy. This is enough to make the browser report all network errors to defined endpoints. The full policy can be represented by the following class.

public class NetworkErrorLoggingHeaderValue
{
    private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions { IgnoreNullValues = true };

    private decimal? _successFration;
    private decimal? _failureFration;

    [JsonPropertyName("report_to")]
    public string ReportTo { get; }

    [JsonPropertyName("max_age")]
    public uint MaxAge { get; }

    [JsonPropertyName("include_subdomains")]
    public bool? IncludeSubdomains { get; set; }

    [JsonPropertyName("success_fraction")]
    public decimal? SuccessFraction
    {
        get { return _successFration; }

        set
        {
            if ((value < 0) || (value > 1))
            {
                throw new ArgumentOutOfRangeException(nameof(SuccessFraction));
            }

            _successFration = value;
        }
    }

    [JsonPropertyName("failure_fraction")]
    public decimal? FailureFraction
    {
        get { return _failureFration; }

        set
        {
            if ((value < 0) || (value > 1))
            {
                throw new ArgumentOutOfRangeException(nameof(FailureFraction));
            }

            _failureFration = value;
        }
    }

    public NetworkErrorLoggingHeaderValue(string reportTo, uint maxAge)
    {
        ReportTo = reportTo ?? throw new ArgumentNullException(nameof(reportTo));
        MaxAge = maxAge;
    }

    public override string ToString()
    {
        return JsonSerializer.Serialize(this, _serializerOptions);
    }
}

In addition to the required fields, there is also a number of optional ones. They can be used to define behavior for subdomains and introduce sampling rates. Those options enable additional scenarios that I will discuss later. For now, I just want to make this work, so I want to add this header to the response. In the below example, I'm doing that while serving static files.

public class Startup
{
    private const int NetworkErrorLoggingMaxAge = 24 * 60 * 60;

    ...

    public void Configure(IApplicationBuilder app, IHostEnvironment env)
    {
        ...

        app.UseStaticFiles(new StaticFileOptions
        {
            OnPrepareResponse = ctx =>
            {
                const string networkErrorLoggingGroupName = "network-errors";

                ReportToHeaderValue networkErrorLoggingReportTo = new ReportToHeaderValue { Group = networkErrorLoggingGroupName };
                networkErrorLoggingReportTo.Endpoints.Add(new ReportToEndpoint("https://localhost:5001/report-to-endpoint"));

                NetworkErrorLoggingHeaderValue networkErrorLoggingValue =
                    new NetworkErrorLoggingHeaderValue(networkErrorLoggingGroupName, NetworkErrorLoggingMaxAge);

                ctx.Context.Response.AddReportToResponseHeader(networkErrorLoggingReportTo);
                ctx.Context.Response.AddNetworkErrorLoggingResponseHeader(networkErrorLoggingValue);
            }
        });

        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapReportToEndpoint("/report-to-endpoint");

            ...
        });
    }
}

You should never do what I did above - define an endpoint which is part of the same application. If you want to do that, you should have at least one more endpoint on separated infrastructure. Otherwise, if there is a general network related problem with the application, browser has no way to report it. But this is good enough for demo and will allow us to receive reports.

Receiving Reports

Network Error Logging reports are standard Reporting API reports. We can get the problematic URL directly from it and further details are available in the body.

internal class NetworkErrorLoggingReportBody
{
    private readonly IDictionary<string, object> _reportBody;

    public string Referrer => _reportBody["referrer"].ToString();

    public decimal SamplingFraction => Convert.ToDecimal(_reportBody["sampling_fraction"].ToString());

    public string ServerIp =>_reportBody["server_ip"].ToString();

    public string Protocol =>_reportBody["protocol"].ToString();

    public string Method => _reportBody["method"].ToString();

    public int StatusCode => Convert.ToInt32(_reportBody["status_code"].ToString());

    public int ElapsedTime => Convert.ToInt32(_reportBody["elapsed_time"].ToString());

    public string Phase => _reportBody["phase"].ToString();

    public string Type => _reportBody["type"].ToString();

    public NetworkErrorLoggingReportBody(IDictionary<string, object> reportBody)
    {
        _reportBody = reportBody ?? throw new ArgumentNullException(nameof(reportBody));
    }
}

The purpose of most of those properties is not that hard to guess. The two most enigmatic might be phase and type.

There are three phases defined by the specification:

  • dns - DNS resolution
  • connection - secure connection establishment
  • application - transmission of request and response

Not all of them have to occur. In fact, the only required one is Transmission of request and response.

When it comes to type, there is no complete list. There is quite a lot of predefined ones, but browsers are not limited to those. In general, types should adhere to following naming convention: [group].[optional-subgroup].[error-name]. Predefined groups usually correspond to phases for example dns, tcp, tls, and http.

One more important value can be age from the main part of the report. If you start playing with this, you will quickly notice that Network Error Logging reports almost never come immediately when the event occurs. Almost always browsers throttle them. The age is the way to get the information when the error occurred.

Not Only Errors

Network Error Logging, as the name implies, is primarily used to provides reports on errors. But that's not all. When success_fraction is set to value higher than 0, the browser will start to send reports about successful requests. This allows for gathering some statistics. A potentially interesting scenario can come for the new addition which is yet to come to browsers - including request and response headers in reports. One of the examples here is monitoring cache hit and miss ratios. We will be able to configure a policy to include If-None-Match request header and ETag response header (if that's our caching mechanism), enable sending reports for successful requests, and analyze those headers values in the context of 200 and 304 responses.

Try It Yourself

I've updated my Reporting API demo a little bit. You will find there all the code needed to start playing with this and hopefully later use it in your projects.