The problem

When doing http calls using the HttpClient, out of the box you'll get the following log output:

services.AddHttpClient("my-client", client =>
{
    client.BaseAddress = new Uri("https://josef.codes");
});
info: System.Net.Http.HttpClient.my-client.LogicalHandler[100]
      Start processing HTTP request GET https://josef.codes/any?query=any

info: System.Net.Http.HttpClient.my-client.ClientHandler[100]
      Sending HTTP request GET https://josef.codes/any?query=any

info: System.Net.Http.HttpClient.my-client.ClientHandler[101]
      Received HTTP response headers after 0.2839ms - 200

info: System.Net.Http.HttpClient.my-client.LogicalHandler[101]
      End processing HTTP request after 5.3191ms - 200

For each call, four records will be logged. That's a bit chatty IMO. Let's see how we can customize the logging behaviour.

The solution

For each call, I want two log records, one that says that we're sending the request, and one saying that we've received a response (or error).

Something like this:

Sending GET to https://josef.codes

Received 200 OK after 3.18ms

IHttpClientLogger

To customize the logging, one can implement the IHttpClientLogger interface.

My implementation looks like this:

public class HttpLogger : IHttpClientLogger
{
    private readonly ILogger<HttpLogger> _logger;

    public HttpLogger(ILogger<HttpLogger> logger)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public object? LogRequestStart(HttpRequestMessage request)
    {
        _logger.LogInformation(
            "Sending '{Request.Method}' to '{Request.Host}{Request.Path}'",
            request.Method,
            request.RequestUri?.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped),
            request.RequestUri!.PathAndQuery);
        return null;
    }

    public void LogRequestStop(
        object? context, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed)
    {
        _logger.LogInformation(
            "Received '{Response.StatusCodeInt} {Response.StatusCodeString}' after {Response.ElapsedMilliseconds}ms",
            (int)response.StatusCode,
            response.StatusCode,
            elapsed.TotalMilliseconds.ToString("F1"));
    }

    public void LogRequestFailed(
        object? context,
        HttpRequestMessage request,
        HttpResponseMessage? response,
        Exception exception,
        TimeSpan elapsed)
    {
        _logger.LogError(
            exception,
            "Request towards '{Request.Host}{Request.Path}' failed after {Response.ElapsedMilliseconds}ms",
            request.RequestUri?.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped),
            request.RequestUri!.PathAndQuery,
            elapsed.TotalMilliseconds.ToString("F1"));
    }
}

Using the logger

When registering the client using the HttpClientFactory, we need to register our logger like this:

services.AddHttpClient("my-client", client =>
{
    client.BaseAddress = new Uri("https://any.localhost");
}).AddLogger<HttpLogger>(wrapHandlersPipeline: true);

wrapHandlersPipeline

Whether the logging handler with the custom logger would be added to the top or to the bottom of the additional handlers chains.

If we now send a request again, the following will be logged:

info: System.Net.Http.HttpClient.my-client.LogicalHandler[100]
      Start processing HTTP request GET https://josef.codes/any?query=any
      
info: JOS.Http.HttpLogger[0]
      Sending 'GET' to 'https://josef.codes/any?query=any'
      
info: System.Net.Http.HttpClient.my-client.ClientHandler[100]
      Sending HTTP request GET https://josef.codes/any?query=any
      
info: System.Net.Http.HttpClient.my-client.ClientHandler[101]
      Received HTTP response headers after 0.2434ms - 200
      
info: JOS.Http.HttpLogger[0]
      Received '200 OK' after 3,494ms
      
info: System.Net.Http.HttpClient.my-client.LogicalHandler[101]
      End processing HTTP request after 6.4162ms - 200

Not quite what we wanted. We can see our own log records, nice, but the default log records are still being written.

To remove the default logs, we can call RemoveAllLoggers() before configuring our own like this:

services.AddHttpClient("my-client", client =>
{
    client.BaseAddress = new Uri("https://any.localhost");
}).RemoveAllLoggers()
  .AddLogger<HttpLogger>(wrapHandlersPipeline: true);

Now if we run it again, the desired output is achieved.

info: JOS.Http.HttpLogger[0]
      Sending 'GET' to 'https://josef.codes/any?query=any'
info: JOS.Http.HttpLogger[0]
      Received '200 OK' after 1,842ms

I've created the following extension method for setting up the logger:

public static class HttpClientBuilderExtensions
{
    public static IHttpClientBuilder ConfigureLogging(this IHttpClientBuilder builder)
    {
        builder.Services.TryAddScoped<HttpLogger>();
        return builder.RemoveAllLoggers().AddLogger<HttpLogger>(wrapHandlersPipeline: true);
    }
}

It can then be used like this:

services.AddHttpClient("my-client", client =>
{
    client.BaseAddress = new Uri("https://any.localhost");
}).ConfigureLogging();