Observing all http requests in a .NET application

  • Gérald Barré

.NET provides multiple APIs to send http requests. You can use the HttpClient class and the obsolete HttpWebRequest and WebClient classes. Also, you may use libraries that send requests out of your control. So, you need to use the hooks provided by .NET to observe all http requests.

Program.cs (C#)
var client = new HttpClient();
_ = await client.GetStringAsync("https://example.com");

var request = WebRequest.CreateHttp("https://example.com");
_ = request.GetResponse();

var webClient = new WebClient();
_ = webClient.DownloadString("https://example.com");

.NET provides two ways to monitor an application:

  • DiagnosticSource: Allow code to be instrumented for production-time logging of rich data payloads for consumption within the process that was instrumented
  • EventSource: Allow code to be instrumented for production-time logging for consumption in-process or out-of-process. As these events can be observed out-of-process, the data need to be serializable. The main impact is that you cannot send rich payload to the observer.

#EventListener

You can observe events produces by an EventSource using the EventListener class. For http requests, you may be interested in the RequestStart and RequestStop events. The RequestStart event contains a few properties about the request, such as the domain, path or the http version.

Program.cs (C#)
using var eventListener = new HttpEventListener();
var client = new HttpClient();
await client.GetStringAsync("https://example.com");
await client.GetStringAsync("https://example.com");
HttpEventListener.cs (C#)
sealed class HttpEventListener : EventListener
{
    protected override void OnEventSourceCreated(EventSource eventSource)
    {
        switch (eventSource.Name)
        {
            case "System.Net.Http":
                EnableEvents(eventSource, EventLevel.Informational, EventKeywords.All);
                break;

            // Enable EventWrittenEventArgs.ActivityId to correlate Start and Stop events
            case "System.Threading.Tasks.TplEventSource":
                const EventKeywords TasksFlowActivityIds = (EventKeywords)0x80;
                EnableEvents(eventSource, EventLevel.LogAlways, TasksFlowActivityIds);
                break;
        }

        base.OnEventSourceCreated(eventSource);
    }

    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        // note: Use eventData.ActivityId to correlate Start and Stop events
        if (eventData.EventId == 1) // eventData.EventName == "RequestStart"
        {
            var scheme = (string)eventData.Payload[0];
            var host = (string)eventData.Payload[1];
            var port = (int)eventData.Payload[2];
            var pathAndQuery = (string)eventData.Payload[3];
            var versionMajor = (byte)eventData.Payload[4];
            var versionMinor = (byte)eventData.Payload[5];
            var policy = (HttpVersionPolicy)eventData.Payload[6];

            Console.WriteLine($"{eventData.ActivityId} {eventData.EventName} {scheme}://{host}:{port}{pathAndQuery} HTTP/{versionMajor}.{versionMinor}");
        }
        else if (eventData.EventId == 2) // eventData.EventName == "RequestStop"
        {
            Console.WriteLine(eventData.ActivityId + " " + eventData.EventName);
        }
    }
}

#EventListener: Correlate Start and Stop events using AsyncLocal

In the previous example, you can correlate the RequestStart and RequestStop events using the ActivityId property. Correlating events can be useful to measure the request duration. The ActivityId property is mainly useful when observing the application using out-of-process tools such as PerfView. When using in-process monitoring, you can use an AsyncLocal<T> field to store the current request.

Program.cs (C#)
using var eventListener = new HttpEventListenerAsyncLocal();
var client = new HttpClient();
await client.GetStringAsync("https://example.com");
await client.GetStringAsync("https://example.com");
HttpEventListenerAsyncLocal.cs (C#)
using System.Diagnostics;
using System.Diagnostics.Tracing;

internal sealed class HttpEventListenerAsyncLocal : EventListener
{
    private readonly AsyncLocal<Request> _currentRequest = new();

    private sealed record Request(string Url, Stopwatch ExecutionTime);

    protected override void OnEventSourceCreated(EventSource eventSource)
    {
        if (eventSource.Name == "System.Net.Http")
        {
            EnableEvents(eventSource, EventLevel.Informational, EventKeywords.All);
        }

        base.OnEventSourceCreated(eventSource);
    }

    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        if (eventData.EventId == 1) // eventData.EventName == "RequestStart"
        {
            var scheme = (string)eventData.Payload[0];
            var host = (string)eventData.Payload[1];
            var port = (int)eventData.Payload[2];
            var pathAndQuery = (string)eventData.Payload[3];
            _currentRequest.Value = new Request($"{scheme}://{host}:{port}{pathAndQuery}", Stopwatch.StartNew());
        }
        else if (eventData.EventId == 2) // eventData.EventName == "RequestStop"
        {
            var currentRequest = _currentRequest.Value;
            if (currentRequest != null)
            {
                Console.WriteLine($"{currentRequest.Url} executed in {currentRequest.ExecutionTime.ElapsedMilliseconds:F1}ms");
            }
        }
    }
}

#DiagnosticListener

If you need to access to the HttpRequestMessage/HttpResponseMessage instances, you can use a DiagnosticListener. This is useful to access request headers, or the response status code.

Program.cs (C#)
using System.Diagnostics;

using var observer = new HttpRequestsObserver();
using (DiagnosticListener.AllListeners.Subscribe(observer))
{
    var client = new HttpClient();
    await client.GetStringAsync("https://example.com");
    await client.GetStringAsync("https://example.com");
}
HttpRequestsObserver.cs (C#)
using System.Diagnostics;

internal sealed class HttpRequestsObserver : IDisposable, IObserver<DiagnosticListener>
{
    private IDisposable _subscription;

    public void OnNext(DiagnosticListener value)
    {
        if (value.Name == "HttpHandlerDiagnosticListener")
        {
            _subscription = value.Subscribe(new HttpHandlerDiagnosticListener());
        }
    }

    public void OnCompleted() { }
    public void OnError(Exception error) { }

    public void Dispose()
    {
        _subscription?.Dispose();
    }

    private sealed class HttpHandlerDiagnosticListener : IObserver<KeyValuePair<string, object>>
    {
        private static readonly Func<object, HttpRequestMessage> RequestAccessor = CreateGetRequest();
        private static readonly Func<object, HttpResponseMessage> ResponseAccessor = CreateGetResponse();

        public void OnCompleted() { }
        public void OnError(Exception error) { }

        public void OnNext(KeyValuePair<string, object> value)
        {
            // note: Legacy applications can use "System.Net.Http.HttpRequest" and "System.Net.Http.Response"
            if (value.Key == "System.Net.Http.HttpRequestOut.Start")
            {
                // The type is private, so we need to use reflection to access it.
                var request = RequestAccessor(value.Value);
                Console.WriteLine($"{request.Method} {request.RequestUri} {request.Version} (UserAgent: {request.Headers.UserAgent})");
            }
            else if (value.Key == "System.Net.Http.HttpRequestOut.Stop")
            {
                // The type is private, so we need to use reflection to access it.
                var response = ResponseAccessor(value.Value);
                Console.WriteLine($"{response.StatusCode} {response.RequestMessage.RequestUri}");
            }
        }

        private static Func<object, HttpRequestMessage> CreateGetRequest()
        {
            var requestDataType = Type.GetType("System.Net.Http.DiagnosticsHandler+ActivityStartData, System.Net.Http", throwOnError: true);
            var requestProperty = requestDataType.GetProperty("Request");
            return (object o) => (HttpRequestMessage)requestProperty.GetValue(o);
        }

        private static Func<object, HttpResponseMessage> CreateGetResponse()
        {
            var requestDataType = Type.GetType("System.Net.Http.DiagnosticsHandler+ActivityStopData, System.Net.Http", throwOnError: true);
            var requestProperty = requestDataType.GetProperty("Response");
            return (object o) => (HttpResponseMessage)requestProperty.GetValue(o);
        }
    }
}

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub