2
votes

TL;DR Question: Is there a way to get the request body into an existing ExceptionTelemetry instance, in ASP.NET Core, without copying ALL request bodies?

I would like to be able to include the Request Body in the exception telemetry for application insights. I.e. I only want the request when an exception has occurred.

Browsing around for documentation on both ASP.NET Core and Application Insights, it seems the "right" way to enrich telemetry is using TelemetryProcessors or TelemetryInitializers, so I tried getting the request body in a custom telemetryinitializer, only to discover that the request body stream is closed/disposed when I want to read it (rewinding does not help because apparently it has already been disposed when the App Insights telemetryinitializer is being executed).

I ended up solving it by having a middleware that copies the request stream:

public async Task Invoke(HttpContext context)
{
    var stream = context.Request.Body;

    try
    {
        using (var buffer = new MemoryStream())
        {
            // Copy the request stream and rewind the copy
            await stream.CopyToAsync(buffer);
            buffer.Position = 0L;

            // Create another copy and rewind both
            var otherBuffer = new MemoryStream();
            await buffer.CopyToAsync(otherBuffer);
            buffer.Position = 0L;
            otherBuffer.Position = 0L;

            // Replace the request stream by the first copy
            context.Request.Body = buffer;

            // Put a separate copy in items collection for other things to use
            context.Items["RequestStreamCopy"] = otherBuffer;
            context.Response.RegisterForDispose(otherBuffer);

            await next(context);
        }
    }
    finally
    {
        context.Request.Body = stream;
    }
}

And my initializer:

public AiExceptionInitializer(IHttpContextAccessor httpContextAccessor)
{
    this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException("httpContextAccessor");
}

public void Initialize(ITelemetry telemetry)
{
    var context = this.httpContextAccessor.HttpContext;

    if (context == null)
    {
        return;
    }

    lock (context)
    {
        var request = context.Features.Get<RequestTelemetry>();
        if (request == null)
        {
            return;
        }

        this.OnInitializeTelemetry(context, request, telemetry);
    }
}

protected void OnInitializeTelemetry(HttpContext platformContext, RequestTelemetry requestTelemetry, ITelemetry telemetry)
{
    if (telemetry is ExceptionTelemetry exceptionTelemetry)
    {
        var stream = platformContext.Items["RequestStreamCopy"] as MemoryStream;

        try
        {
            if (stream?.Length <= 0)
            {
                return;
            }

            // Rewind the stream position just to be on the safe side
            stream.Position = 0L;

            using (var reader = new StreamReader(stream, Encoding.UTF8, true, 1024, true))
            {
                string requestBody = reader.ReadToEnd();
                exceptionTelemetry.Properties.Add("HttpRequestBody", requestBody);
            }
        }
        finally
        {
            if (stream != null)
            {
                // Rewind the stream for others to use.
                stream.Position = 0L;
            }
        }
    }
}

However this having to copy the request stream (TWICE) for each request, to only have it used on failures seems rather inefficient to me. So I am wondering if there is any other way to do something like this where I don't have to copy the stream of each and every request just to serialize the ones failing?

I am aware I could "just" write a middleware that would create new ExceptionTelemetry instances, but as far as I know (I might be wrong) it would leave me with two Exception instances in Application Insights (i.e. the one generated by me and the one generated by the AI extensions), instead of just one exception with the added property I need.

1
I could possibly put the stream in a feature instead, not sure when I should do one over the other, but that is irrelevant for the question I guess.Esben Bach
Will it be possible to write an exception tracking middleware but only use it to capture body of the request into the context? (paraphrasing: is request body still available in the custom exception tracking middleware?) If yes - you can write this body into the context only in that exception middleware, and then, in telemetry initializer, you will be able to check if body is not null in the context - then use it to fill out exception properties. Thus, by ordering your middleware before AI middleware - you populate context right before initializer is run.Dmitry Matveev
Not entirely sure, but I will give it a try and let you know.Esben Bach
@DmitryMatveev you suggestion worked, refer to my answer for the details.Esben Bach

1 Answers

1
votes

Thanks to the comment from @DmitryMatveev I found an alternate solution. I am not sure its the most effective, but it is better than what i had!

The middleware is "reduced" to only tracking exceptions, and then serialising the body right away (you might still have a stream copy, but I don't need it in my case), something like the following:

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Internal;

public class ExceptionBodyTrackingMiddleware
{
    public const string ExceptionRequestBodyKey = "ExceptionRequestBody";
    private readonly RequestDelegate next;

    public ExceptionBodyTrackingMiddleware(RequestDelegate next)
    {
        this.next = next ?? throw new ArgumentNullException(nameof(next));
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            context.Request.EnableRewind();
            await this.next.Invoke(context);
        }
        catch (Exception)
        {
            RegisterRequestBody(context);

            throw;
        }
    }

    private static void RegisterRequestBody(HttpContext context)
    {
        if (context.Request.Body?.CanSeek == false)
        {
            return;
        }

        var body = CopyStreamToString(context.Request.Body);
        context.Items[ExceptionRequestBodyKey] = body;
    }

    private static string CopyStreamToString(Stream stream)
    {
        var originalPosition = stream.Position;
        RewindStream(stream);
        string requestBody = null;

        using (var reader = new StreamReader(stream, Encoding.UTF8, true, 1024, true))
        {
            requestBody = reader.ReadToEnd();
        }
        stream.Position = originalPosition;
        return requestBody;
    }

    private static void RewindStream(Stream stream)
    {
        if (stream != null)
        {
            stream.Position = 0L;
        }
    }
}

Likewise the Initializer becomes a whole lot simpler:

public AiExceptionInitializer(IHttpContextAccessor httpContextAccessor)
{
    this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException("httpContextAccessor");
}

public void Initialize(ITelemetry telemetry)
{
    var context = this.httpContextAccessor.HttpContext;

    if (context == null)
    {
        return;
    }

    lock (context)
    {
        var request = context.Features.Get<RequestTelemetry>();
        if (request == null)
        {
            return;
        }

        this.OnInitializeTelemetry(context, request, telemetry);
    }
}

protected void OnInitializeTelemetry(HttpContext platformContext, RequestTelemetry requestTelemetry, ITelemetry telemetry)
{
    if (telemetry is ExceptionTelemetry exceptionTelemetry)
    {
        var requestBody = platformContext.Items[ExceptionBodyTrackingMiddleware.ExceptionRequestBodyKey] as string;
        exceptionTelemetry.Properties.Add("HttpRequestBody", requestBody);
    }
}