An early look at Reporting API in ASP.NET Core

If you've been reading my blog you probably know I like to explore web standards in context of ASP.NET Core. I've written about Push API, Clear Site Data, Server Timing or Client Hints. Recently I've learned that Reporting API has made its way to Chrome 69 as an experimental feature, and I couldn't resist myself.

Reporting API aims at providing a framework which allows web developers to associate reporting endpoints with an origin. Different browser features can use these endpoints to deliver specific but consistent reports. If you are familiar with Content Security Policy, you might be noticing similarity to report-uri directive, as it provides this exact capability for CSP. In fact, Chrome already provides a way for integrating both of them through report-to directive.

This all looks very interesting, so I've quickly run Chrome with the feature enabled and started playing with it.

chrome.exe --enable-features=Reporting

Configuring endpoints

The reporting endpoints are configured through Report-To response header. This header value should be a JSON object which contains a list of endpoints and additional properties. It can be represented by following object.

public class ReportToHeaderValue
{
    [JsonProperty(PropertyName = "group", NullValueHandling = NullValueHandling.Ignore)]
    public string Group { get; set; }

    [JsonProperty(PropertyName = "include_subdomains", NullValueHandling = NullValueHandling.Ignore)]
    public bool? IncludeSubdomains { get; set; }

    [JsonProperty(PropertyName = "max_age")]
    public uint MaxAge { get; set; } = 10886400;

    [JsonProperty(PropertyName = "endpoints")]
    public IList<ReportToEndpoint> Endpoints { get; } = new List<ReportToEndpoint>();

    public override string ToString()
    {
        return JsonConvert.SerializeObject(this);
    }
}

The include_subdomains and max_age properties should be familiar, they are often seen in modern web standards. The include_subdomains is optional and if absent its value defaults to false. The max_age is required, and can also be used to remove specific group by using 0 as value.

The group property allows for logical grouping of endpoints (if it's not provided the group is named default). The group name can be used in places where browser features provide integration with Reporting API in order to make them send reports to different group than default. For example, previously mentioned report-to directive takes group name as parameter.

The most important part are endpoints. First of all the provide URLs, but that's not all.

public struct ReportToEndpoint
{
    [JsonProperty(PropertyName = "url")]
    public string Url { get; }

    [JsonProperty(PropertyName = "priority", NullValueHandling = NullValueHandling.Ignore)]
    public uint? Priority { get; }

    [JsonProperty(PropertyName = "weight", NullValueHandling = NullValueHandling.Ignore)]
    public uint? Weight { get; }

    public ReportToEndpoint(string url, uint? priority = null, uint? weight = null)
        : this()
    {
        Url = url ?? throw new ArgumentNullException(nameof(url));
        Priority = priority;
        Weight = weight;
    }
}

The optional priority and weight properties are there to help browser in selecting to which of the URLs the report should be send. The priority groups URLs into a single failover class while weight tells what fraction of traffic within the failover class should be send to specific URL.

To check the configured groups and failover classes for different origins one can use chrome://net-internals/#reporting.

Chrome Net Internals - Reporting

Receiving reports

Before a report can be received it must be triggered. Previously mentioned CSP violations are easy to cause, but they wouldn't be interesting. Currently Chrome supports couple other report types like deprecations, interventions and Network Error Logging. The deprecations look interesting from development pipeline perspective as they can be used in QA environments to early detect that one of features used by application is about to be removed. For demo purposes attempting a synchronous XHR request is a great example.

function triggerDeprecation() {
    // Synchronous XHR
    var xhr = new XMLHttpRequest();
    xhr.open('GET', '/xhr', false);
    xhr.send();
};

The report consists of type, age, originating URL, user agent and body. The body contains attributes specific for report type.

public class Report
{
    [JsonProperty(PropertyName = "type")]
    public string Type { get; set; }

    [JsonProperty(PropertyName = "age")]
    public uint Age { get; set; }

    [JsonProperty(PropertyName = "url")]
    public string Url { get; set; }

    [JsonProperty(PropertyName = "user_agent")]
    public string UserAgent { get; set; }

    [JsonProperty(PropertyName = "body")]
    public IDictionary<string, object> Body { get; set; }
}

Typically browser queues reports (as they can be triggered in very rapid succession) and sends all of them at later, convenient moment. This means that code receiving the POST request should be expecting a list. One approach can be a middleware (a controller action would also work, but it would require a custom formatter as reports are delivered with media type application/reports+json).

public class ReportToEndpointMiddleware
{
    private readonly RequestDelegate _next;

    public ReportToEndpointMiddleware(RequestDelegate next)
    {
        _next = next ?? throw new ArgumentNullException(nameof(next));
    }

    public async Task Invoke(HttpContext context)
    {
        if (IsReportsRequest(context.Request))
        {
            List<Report> reports = null;

            using (StreamReader requestBodyReader = new StreamReader(context.Request.Body))
            {
                using (JsonReader requestBodyJsonReader = new JsonTextReader(requestBodyReader))
                {
                    JsonSerializer serializer = new JsonSerializer();

                    reports = serializer.Deserialize<List<Report>>(requestBodyJsonReader);
                }
            }

            ...

            context.Response.StatusCode = StatusCodes.Status204NoContent;
        }
        else
        {
            await _next(context);
        }
    }

    private bool IsReportsRequest(HttpRequest request)
    {
        return HttpMethods.IsPost(request.Method)
               && (request.ContentType == "application/reports+json");
    }
}

Last remaining thing is to do something useful with the reports. In simplest scenario they can be logged.

public class ReportToEndpointMiddleware
{
    ...
    private readonly ILogger _logger;

    public ReportToEndpointMiddleware(RequestDelegate next, ILogger<ReportToEndpointMiddleware> logger)
    {
        ...
        _logger = logger;
    }

    public async Task Invoke(HttpContext context)
    {
        if (IsReportsRequest(context.Request))
        {
            ...

            LogReports(reports);

            context.Response.StatusCode = StatusCodes.Status204NoContent;
        }
        else
        {
            await _next(context);
        }
    }

    ...

    private void LogReports(List<Report> reports)
    {
        if (reports != null)
        {
            foreach (Report report in reports)
            {
                switch (report.Type.ToLowerInvariant())
                {
                    case "deprecation":
                        _logger.LogWarning("Deprecation reported for file {SourceFile}"
                            + " (Line: {LineNumber}, Column: {ColumnNumber}): '{Message}'",
                            report.Body["sourceFile"], report.Body["lineNumber"],
                            report.Body["columnNumber"], report.Body["message"]);
                        break;
                    default:
                        _logger.LogInformation("Report of type '{ReportType}' received.", report.Type);
                        break;
                }
            }
        }
    }
}

Conclusion

Reporting API looks really promising. It's early, but it can be already used in your CI pipelines and QA environments to broaden range of issues which are automatically detected. If you want to better understand what Chrome provides, I suggest this article. I have also made my demo available on GitHub.