File: Logging\AcceptanceTests.cs
Web Access
Project: src\test\Libraries\Microsoft.Extensions.Http.Diagnostics.Tests\Microsoft.Extensions.Http.Diagnostics.Tests.csproj (Microsoft.Extensions.Http.Diagnostics.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Compliance.Classification;
using Microsoft.Extensions.Compliance.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Diagnostics;
using Microsoft.Extensions.Http.Logging.Test.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
 
namespace Microsoft.Extensions.Http.Logging.Test;
 
public class AcceptanceTests
{
    private const string LoggingCategory = "Microsoft.Extensions.Http.Logging.HttpClientLogger";
    private static readonly Uri _unreachableRequestUri = new("https://we.wont.hit.this.domain.anyway");
 
    [Fact]
    public async Task AddHttpClientLogEnricher_WhenNullEnricherRegistered_SkipsNullEnrichers()
    {
        await using var sp = new ServiceCollection()
            .AddFakeLogging()
            .AddFakeRedaction()
            .AddExtendedHttpClientLogging()
            .AddHttpClientLogEnricher<EnricherWithCounter>()
            .AddSingleton<IHttpClientLogEnricher>(static _ => null!)
            .BlockRemoteCall()
            .BuildServiceProvider();
 
        using var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient();
        using var _ = await httpClient.GetAsync(_unreachableRequestUri);
        var collector = sp.GetFakeLogCollector();
        var logRecord = Assert.Single(collector.GetSnapshot());
 
        // No error should be logged:
        Assert.Equal(LogLevel.Information, logRecord.Level);
        Assert.Equal(LoggingCategory, logRecord.Category);
        Assert.Equal($"{HttpMethod.Get} {_unreachableRequestUri.Host}/{TelemetryConstants.Redacted}", logRecord.Message);
        Assert.Null(logRecord.Exception);
 
        var enrichers = sp.GetServices<IHttpClientLogEnricher>().ToList();
        var nullEnricher = Assert.Single(enrichers, x => x is null);
        Assert.Null(nullEnricher);
 
        var enricher = Assert.Single(enrichers, x => x is not null);
        var testEnricher = Assert.IsType<EnricherWithCounter>(enricher);
        Assert.Equal(1, testEnricher.TimesCalled);
    }
 
    [Fact]
    public async Task HttpClientLogger_WhenEnricherThrows_EmitsErrorAndKeepsExecution()
    {
        await using var sp = new ServiceCollection()
            .AddFakeLogging()
            .AddFakeRedaction()
            .AddExtendedHttpClientLogging()
            .AddSingleton<IHttpClientLogEnricher, TestEnricher>(static _ => new TestEnricher(throwOnEnrich: true))
            .BlockRemoteCall()
            .BuildServiceProvider();
 
        using var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient();
        using var _ = await httpClient.GetAsync(_unreachableRequestUri);
        var collector = sp.GetFakeLogCollector();
        Assert.Collection(
            collector.GetSnapshot(),
            static firstLogRecord =>
            {
                Assert.Equal(LogLevel.Error, firstLogRecord.Level);
                Assert.Equal(LoggingCategory, firstLogRecord.Category);
                Assert.StartsWith($"An error occurred in enricher '{typeof(TestEnricher).FullName}'", firstLogRecord.Message);
                Assert.EndsWith($"{HttpMethod.Get} {_unreachableRequestUri.Host}/{TelemetryConstants.Redacted}", firstLogRecord.Message);
                Assert.IsType<NotSupportedException>(firstLogRecord.Exception);
            },
            static secondLogRecord =>
            {
                // No error should be logged:
                Assert.Equal(LogLevel.Information, secondLogRecord.Level);
                Assert.Equal(LoggingCategory, secondLogRecord.Category);
                Assert.Equal($"{HttpMethod.Get} {_unreachableRequestUri.Host}/{TelemetryConstants.Redacted}", secondLogRecord.Message);
                Assert.Null(secondLogRecord.Exception);
            });
    }
 
    [Fact]
    public async Task AddHttpClientLogging_ServiceCollectionAndEnrichers_EnrichesLogsWithAllEnrichers()
    {
        await using var sp = new ServiceCollection()
            .AddFakeLogging()
            .AddFakeRedaction()
            .AddExtendedHttpClientLogging()
            .AddHttpClientLogEnricher<EnricherWithCounter>()
            .AddHttpClientLogEnricher<TestEnricher>()
            .AddHttpClient("testClient").Services
            .BlockRemoteCall()
            .BuildServiceProvider();
 
        using var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("testClient");
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = _unreachableRequestUri,
        };
 
        _ = await httpClient.SendAsync(httpRequestMessage);
        var collector = sp.GetFakeLogCollector();
        var logRecord = collector.GetSnapshot().Single(logRecord => logRecord.Category == LoggingCategory);
 
        Assert.Equal($"{httpRequestMessage.Method} {httpRequestMessage.RequestUri.Host}/{TelemetryConstants.Redacted}", logRecord.Message);
        var enricher1 = sp.GetServices<IHttpClientLogEnricher>().SingleOrDefault(enn => enn is EnricherWithCounter) as EnricherWithCounter;
        var enricher2 = sp.GetServices<IHttpClientLogEnricher>().SingleOrDefault(enn => enn is TestEnricher) as TestEnricher;
 
        enricher1.Should().NotBeNull();
        enricher2.Should().NotBeNull();
        enricher1!.TimesCalled.Should().Be(1);
 
        var state = logRecord.StructuredState;
        state.Should().NotBeNull();
        state!.Single(kvp => kvp.Key == enricher2!.KvpRequest.Key).Value.Should().Be(enricher2!.KvpRequest.Value!.ToString());
    }
 
    [Fact]
    public async Task AddHttpClientLogging_WithNamedHttpClients_WorksCorrectly()
    {
        await using var provider = new ServiceCollection()
             .AddFakeLogging()
             .AddFakeRedaction()
             .AddHttpClient("namedClient1")
             .AddExtendedHttpClientLogging(o =>
             {
                 o.ResponseHeadersDataClasses.Add("ResponseHeader", FakeTaxonomy.PrivateData);
                 o.RequestHeadersDataClasses.Add("RequestHeader", FakeTaxonomy.PrivateData);
                 o.RequestHeadersDataClasses.Add("RequestHeaderFirst", FakeTaxonomy.PrivateData);
                 o.RequestBodyContentTypes.Add("application/json");
                 o.ResponseBodyContentTypes.Add("application/json");
                 o.LogBody = true;
             }).Services
             .AddHttpClient("namedClient2")
             .AddExtendedHttpClientLogging(o =>
             {
                 o.ResponseHeadersDataClasses.Add("ResponseHeader", FakeTaxonomy.PrivateData);
                 o.RequestHeadersDataClasses.Add("RequestHeader", FakeTaxonomy.PrivateData);
                 o.RequestHeadersDataClasses.Add("RequestHeaderSecond", FakeTaxonomy.PrivateData);
                 o.RequestBodyContentTypes.Add("application/json");
                 o.ResponseBodyContentTypes.Add("application/json");
                 o.LogBody = true;
             }).Services
             .BlockRemoteCall()
             .BuildServiceProvider();
 
        using var namedClient1 = provider.GetRequiredService<IHttpClientFactory>().CreateClient("namedClient1");
        using var namedClient2 = provider.GetRequiredService<IHttpClientFactory>().CreateClient("namedClient2");
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = _unreachableRequestUri,
        };
 
        httpRequestMessage.Headers.Add("requestHeader", "Request Value");
        httpRequestMessage.Headers.Add("ReQuEStHeAdErFirst", new List<string> { "Request Value 2", "Request Value 3" });
        var responseString = await SendRequest(namedClient1, httpRequestMessage);
        var collector = provider.GetFakeLogCollector();
        var logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory);
        var state = logRecord.StructuredState;
        state.Should().Contain(kvp => kvp.Value == responseString);
        state.Should().Contain(kvp => kvp.Value == "Request Value");
        state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3");
 
        using var httpRequestMessage2 = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = _unreachableRequestUri,
        };
 
        httpRequestMessage2.Headers.Add("requestHeader", "Request Value");
        httpRequestMessage2.Headers.Add("ReQuEStHeAdErSecond", new List<string> { "Request Value 2", "Request Value 3" });
        collector.Clear();
        responseString = await SendRequest(namedClient2, httpRequestMessage2);
        logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory);
        state = logRecord.StructuredState;
        state.Should().Contain(kvp => kvp.Value == responseString);
        state.Should().Contain(kvp => kvp.Value == "Request Value");
        state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3");
    }
 
    private static async Task<string> SendRequest(HttpClient httpClient, HttpRequestMessage httpRequestMessage)
    {
        using var content = await httpClient
            .SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead)
            .ConfigureAwait(false);
 
        var responseStream = await content.Content.ReadAsStreamAsync();
        var buffer = new byte[32768];
        _ = await responseStream.ReadAsync(buffer, 0, 32768);
        return Encoding.UTF8.GetString(buffer);
    }
 
    [Fact]
    public async Task AddHttpClientLogging_WithTypedHttpClients_WorksCorrectly()
    {
        await using var provider = new ServiceCollection()
            .AddFakeLogging()
            .AddFakeRedaction()
            .AddSingleton<ITestHttpClient1, TestHttpClient1>()
            .AddSingleton<ITestHttpClient2, TestHttpClient2>()
            .AddHttpClient<ITestHttpClient1, TestHttpClient1>()
            .AddExtendedHttpClientLogging(x =>
            {
                x.ResponseHeadersDataClasses.Add("ResponseHeader", FakeTaxonomy.PrivateData);
                x.RequestHeadersDataClasses.Add("RequestHeader", FakeTaxonomy.PrivateData);
                x.RequestHeadersDataClasses.Add("RequestHeader2", FakeTaxonomy.PrivateData);
                x.RequestBodyContentTypes.Add("application/json");
                x.ResponseBodyContentTypes.Add("application/json");
                x.BodySizeLimit = 10000;
                x.LogBody = true;
            }).Services
            .AddHttpClient<ITestHttpClient2, TestHttpClient2>()
            .AddExtendedHttpClientLogging(x =>
            {
                x.ResponseHeadersDataClasses.Add("ResponseHeader", FakeTaxonomy.PrivateData);
                x.RequestHeadersDataClasses.Add("RequestHeader", FakeTaxonomy.PrivateData);
                x.RequestHeadersDataClasses.Add("RequestHeader2", FakeTaxonomy.PrivateData);
                x.RequestBodyContentTypes.Add("application/json");
                x.ResponseBodyContentTypes.Add("application/json");
                x.BodySizeLimit = 20000;
                x.LogBody = true;
            }).Services
            .BlockRemoteCall()
            .BuildServiceProvider();
 
        var firstClient = provider.GetService<ITestHttpClient1>() as TestHttpClient1;
        var secondClient = provider.GetService<ITestHttpClient2>() as TestHttpClient2;
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = _unreachableRequestUri,
        };
 
        httpRequestMessage.Headers.Add("requestHeader", "Request Value");
        httpRequestMessage.Headers.Add("ReQuEStHeAdEr2", new List<string> { "Request Value 2", "Request Value 3" });
        var content = await firstClient!.SendRequest(httpRequestMessage);
        var collector = provider.GetFakeLogCollector();
        var responseStream = await content.Content.ReadAsStreamAsync();
        var buffer = new byte[10000];
        _ = await responseStream.ReadAsync(buffer, 0, 10000);
        var responseString = Encoding.UTF8.GetString(buffer);
 
        var logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory);
        var state = logRecord.StructuredState;
        state.Should().NotBeNull();
        state.Should().Contain(kvp => kvp.Value == responseString);
        state.Should().Contain(kvp => kvp.Value == "Request Value");
        state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3");
 
        using var httpRequestMessage2 = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = _unreachableRequestUri,
        };
 
        httpRequestMessage2.Headers.Add("requestHeader", "Request Value");
        httpRequestMessage2.Headers.Add("ReQuEStHeAdEr2", new List<string> { "Request Value 2", "Request Value 3" });
        collector.Clear();
        content = await secondClient!.SendRequest(httpRequestMessage2);
        responseStream = await content.Content.ReadAsStreamAsync();
        buffer = new byte[20000];
        _ = await responseStream.ReadAsync(buffer, 0, 20000);
        responseString = Encoding.UTF8.GetString(buffer);
 
        logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory);
        state = logRecord.StructuredState;
        state.Should().Contain(kvp => kvp.Value == responseString);
        state.Should().Contain(kvp => kvp.Value == "Request Value");
        state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3");
    }
 
    [Theory]
    [InlineData(HttpRouteParameterRedactionMode.Strict, "v1/unit/REDACTED/users/REDACTED:123")]
    [InlineData(HttpRouteParameterRedactionMode.Loose, "v1/unit/999/users/REDACTED:123")]
    [InlineData(HttpRouteParameterRedactionMode.None, "/v1/unit/999/users/123")]
    public async Task AddHttpClientLogging_RedactSensitiveParams(HttpRouteParameterRedactionMode parameterRedactionMode, string redactedPath)
    {
        const string RequestPath = "https://fake.com/v1/unit/999/users/123";
 
        await using var sp = new ServiceCollection()
            .AddFakeLogging()
            .AddFakeRedaction(o => o.RedactionFormat = "REDACTED:{0}")
            .AddHttpClient()
            .AddExtendedHttpClientLogging(o =>
            {
                o.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
                o.RequestPathParameterRedactionMode = parameterRedactionMode;
            })
            .BlockRemoteCall()
            .BuildServiceProvider();
 
        using var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient();
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = new Uri(RequestPath),
        };
 
        var requestContext = sp.GetRequiredService<IOutgoingRequestContext>();
        requestContext.SetRequestMetadata(new RequestMetadata
        {
            RequestRoute = "/v1/unit/{unitId}/users/{userId}"
        });
 
        _ = await httpClient.SendAsync(httpRequestMessage);
 
        var collector = sp.GetFakeLogCollector();
        var logRecord = collector.GetSnapshot().Single(logRecord => logRecord.Category == LoggingCategory);
        var state = logRecord.StructuredState;
        state.Should().NotBeNull();
        state!.Single(kvp => kvp.Key == HttpClientLoggingTagNames.Path).Value.Should().Be(redactedPath);
    }
 
    [Theory]
    [InlineData(HttpRouteParameterRedactionMode.Strict, "REDACTED", "<REDACTED:123>")]
    [InlineData(HttpRouteParameterRedactionMode.Loose, "999", "<REDACTED:123>")]
    [InlineData(HttpRouteParameterRedactionMode.None, "999", "123")]
    public async Task AddHttpClientLogging_StructuredPathLogging_RedactsSensitiveParams(
        HttpRouteParameterRedactionMode parameterRedactionMode,
        string expectedUnitId,
        string expectedUserId)
    {
        const string RequestPath = "https://fake.com/v1/unit/999/users/123";
        const string RequestRoute = "/v1/unit/{unitId}/users/{userId}";
 
        await using var sp = new ServiceCollection()
            .AddFakeLogging()
            .AddFakeRedaction(o => o.RedactionFormat = "<REDACTED:{0}>")
            .AddHttpClient()
            .AddExtendedHttpClientLogging(o =>
            {
                o.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
                o.RequestPathParameterRedactionMode = parameterRedactionMode;
                o.RequestPathLoggingMode = OutgoingPathLoggingMode.Structured;
            })
            .BlockRemoteCall()
            .BuildServiceProvider();
 
        using var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient();
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = new Uri(RequestPath)
        };
 
        httpRequestMessage.SetRequestMetadata(new RequestMetadata(httpRequestMessage.Method.ToString(), RequestRoute));
 
        using var _ = await httpClient.SendAsync(httpRequestMessage);
 
        var collector = sp.GetFakeLogCollector();
        var logRecord = collector.GetSnapshot().Single(logRecord => logRecord.Category == LoggingCategory);
        var state = logRecord.StructuredState;
        var loggedPath = state.Should().NotBeNull().And
            .ContainSingle(kvp => kvp.Key == HttpClientLoggingTagNames.Path)
            .Subject.Value;
 
        state.Should().ContainSingle(kvp => kvp.Key == HttpClientLoggingTagNames.Host)
            .Which.Value.Should().Be(httpRequestMessage.RequestUri.Host);
 
        state.Should().ContainSingle(kvp => kvp.Key == HttpClientLoggingTagNames.Method)
            .Which.Value.Should().Be(httpRequestMessage.Method.ToString());
 
        state.Should().ContainSingle(kvp => kvp.Key == HttpClientLoggingTagNames.StatusCode)
            .Which.Value.Should().Be("200");
 
        state.Should().ContainSingle(kvp => kvp.Key == HttpClientLoggingTagNames.Duration)
            .Which.Value.Should().NotBeEmpty();
 
        // When the redaction mode is set to "None", the RequestPathLoggingMode is ignored
        if (parameterRedactionMode == HttpRouteParameterRedactionMode.None)
        {
            loggedPath.Should().Be(httpRequestMessage.RequestUri.AbsolutePath);
            state.Should().HaveCount(5);
        }
        else
        {
            loggedPath.Should().Be(RequestRoute);
            state.Should().ContainSingle(kvp => kvp.Key == "userId").Which.Value.Should().Be(expectedUserId);
            state.Should().ContainSingle(kvp => kvp.Key == "unitId").Which.Value.Should().Be(expectedUnitId);
            state.Should().HaveCount(7);
        }
    }
 
    [Theory]
    [InlineData(HttpRouteParameterRedactionMode.Strict, "v1/unit/REDACTED/users/REDACTED:123")]
    [InlineData(HttpRouteParameterRedactionMode.Loose, "v1/unit/999/users/REDACTED:123")]
    public async Task AddHttpClientLogging_NamedHttpClient_RedactSensitiveParams(HttpRouteParameterRedactionMode parameterRedactionMode, string redactedPath)
    {
        const string RequestPath = "https://fake.com/v1/unit/999/users/123";
 
        await using var sp = new ServiceCollection()
            .AddFakeLogging()
            .AddFakeRedaction(o => o.RedactionFormat = "REDACTED:{0}")
            .AddHttpClient("test")
            .AddExtendedHttpClientLogging(o =>
            {
                o.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
                o.RequestPathParameterRedactionMode = parameterRedactionMode;
            })
            .Services
            .BlockRemoteCall()
            .BuildServiceProvider();
 
        using var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("test");
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = new Uri(RequestPath),
        };
 
        var requestContext = sp.GetRequiredService<IOutgoingRequestContext>();
        requestContext.SetRequestMetadata(new RequestMetadata
        {
            RequestRoute = "/v1/unit/{unitId}/users/{userId}"
        });
 
        using var _ = await httpClient.SendAsync(httpRequestMessage);
 
        var collector = sp.GetFakeLogCollector();
        var logRecord = collector.GetSnapshot().Single(logRecord => logRecord.Category == LoggingCategory);
        var state = logRecord.StructuredState;
        state.Should().NotBeNull();
        state!.Single(kvp => kvp.Key == HttpClientLoggingTagNames.Path).Value.Should().Be(redactedPath);
    }
 
    [Fact]
    public void AddHttpClientLogging_WithNamedClients_RegistersNamedOptions()
    {
        const string FirstClientName = "1";
        const string SecondClientName = "2";
 
        using var provider = new ServiceCollection()
            .AddFakeRedaction()
            .AddHttpClient(FirstClientName)
            .AddExtendedHttpClientLogging(options =>
            {
                options.LogRequestStart = true;
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test1", FakeTaxonomy.PrivateData } };
            })
            .Services
            .AddHttpClient(SecondClientName)
            .AddExtendedHttpClientLogging(options =>
            {
                options.LogRequestStart = false;
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test2", FakeTaxonomy.PrivateData } };
            })
            .Services
            .BuildServiceProvider();
 
        var factory = provider.GetRequiredService<IHttpClientFactory>();
 
        var firstClient = factory.CreateClient(FirstClientName);
        var secondClient = factory.CreateClient(SecondClientName);
        firstClient.Should().NotBe(secondClient);
 
        var optionsFirst = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get(FirstClientName);
        var optionsSecond = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get(SecondClientName);
        optionsFirst.Should().NotBeNull();
        optionsSecond.Should().NotBeNull();
        optionsFirst.Should().NotBeEquivalentTo(optionsSecond);
    }
 
    [Fact]
    public void AddHttpClientLogging_WithTypedClients_RegistersNamedOptions()
    {
        using var provider = new ServiceCollection()
            .AddFakeRedaction()
            .AddSingleton<ITestHttpClient1, TestHttpClient1>()
            .AddSingleton<ITestHttpClient2, TestHttpClient2>()
            .AddHttpClient<ITestHttpClient1, TestHttpClient1>()
            .AddExtendedHttpClientLogging(options =>
            {
                options.LogRequestStart = true;
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test1", FakeTaxonomy.PrivateData } };
            })
            .Services
            .AddHttpClient<ITestHttpClient2, TestHttpClient2>()
            .AddExtendedHttpClientLogging(options =>
            {
                options.LogRequestStart = false;
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test2", FakeTaxonomy.PrivateData } };
            })
            .Services
            .BuildServiceProvider();
 
        var firstClient = provider.GetService<ITestHttpClient1>() as TestHttpClient1;
        var secondClient = provider.GetService<ITestHttpClient2>() as TestHttpClient2;
 
        firstClient.Should().NotBe(secondClient);
 
        var optionsFirst = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get(nameof(ITestHttpClient1));
        var optionsSecond = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get(nameof(ITestHttpClient2));
        optionsFirst.Should().NotBeNull();
        optionsSecond.Should().NotBeNull();
        optionsFirst.Should().NotBeEquivalentTo(optionsSecond);
    }
 
    [Fact]
    public void AddHttpClientLogging_WithTypedAndNamedClients_RegistersNamedOptions()
    {
        using var provider = new ServiceCollection()
            .AddFakeRedaction()
            .AddSingleton<ITestHttpClient1, TestHttpClient1>()
            .AddSingleton<ITestHttpClient2, TestHttpClient2>()
            .AddHttpClient<ITestHttpClient1, TestHttpClient1>()
            .AddExtendedHttpClientLogging(options =>
            {
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test1", FakeTaxonomy.PrivateData } };
            })
            .Services
            .AddHttpClient<ITestHttpClient2, TestHttpClient2>()
            .AddExtendedHttpClientLogging(options =>
            {
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test2", FakeTaxonomy.PrivateData } };
            })
            .Services
            .AddHttpClient("testClient3")
            .AddExtendedHttpClientLogging(options =>
            {
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test3", FakeTaxonomy.PrivateData } };
            })
            .Services
            .AddHttpClient("testClient4")
            .AddExtendedHttpClientLogging(options =>
            {
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test4", FakeTaxonomy.PrivateData } };
            })
            .Services
            .AddHttpClient<ITestHttpClient1, TestHttpClient1>("testClient5")
            .AddExtendedHttpClientLogging(options =>
            {
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test5", FakeTaxonomy.PrivateData } };
            })
            .Services
            .AddExtendedHttpClientLogging(options =>
            {
                options.ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { "test6", FakeTaxonomy.PrivateData } };
            })
            .BuildServiceProvider();
 
        var optionsFirst = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get(nameof(ITestHttpClient1));
        var optionsSecond = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get(nameof(ITestHttpClient2));
        var optionsThird = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("testClient3");
        var optionsFourth = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("testClient4");
        var optionsFifth = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("testClient5");
        var optionsSixth = provider.GetRequiredService<IOptions<LoggingOptions>>().Value;
 
        optionsFirst.Should().NotBeNull();
        optionsSecond.Should().NotBeNull();
        optionsFirst.Should().NotBeEquivalentTo(optionsSecond);
 
        optionsThird.Should().NotBeNull();
        optionsFourth.Should().NotBeNull();
        optionsThird.Should().NotBeEquivalentTo(optionsFourth);
 
        optionsFifth.Should().NotBeNull();
        optionsFifth.Should().NotBeEquivalentTo(optionsFourth);
 
        optionsSixth.Should().NotBeNull();
        optionsSixth.Should().NotBeEquivalentTo(optionsFifth);
    }
 
    [Fact]
    public async Task AddHttpClientLogging_DisablesNetScope()
    {
        await using var provider = new ServiceCollection()
             .AddFakeLogging()
             .AddFakeRedaction()
             .AddHttpClient("test")
             .AddExtendedHttpClientLogging()
             .Services
             .BlockRemoteCall()
             .BuildServiceProvider();
 
        var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("test");
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _unreachableRequestUri);
 
        _ = await client.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead);
        var collector = provider.GetFakeLogCollector();
        var logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory);
 
        logRecord.Scopes.Should().BeEmpty();
    }
 
    [Fact]
    public async Task AddHttpClientLogging_CallFromOtherClient_HasBuiltInLogging()
    {
        await using var provider = new ServiceCollection()
             .AddFakeLogging()
             .AddFakeRedaction()
             .AddHttpClient("test")
             .AddExtendedHttpClientLogging()
             .Services
             .AddHttpClient("normal")
             .Services
             .BlockRemoteCall()
             .BuildServiceProvider();
 
        // The test client has AddHttpClientLogging. The normal client doesn't.
        // The normal client should still log via the built-in HTTP logging.
        var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("normal");
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _unreachableRequestUri);
 
        _ = await client.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead);
        var collector = provider.GetFakeLogCollector();
        var logRecords = collector.GetSnapshot().Where(l => l.Category == "System.Net.Http.HttpClient.normal.LogicalHandler").ToList();
 
        Assert.Collection(logRecords,
            r => Assert.Equal("RequestPipelineStart", r.Id.Name),
            r => Assert.Equal("RequestPipelineEnd", r.Id.Name));
    }
 
    [Fact]
    public async Task AddDefaultHttpClientLogging_DisablesNetScope()
    {
        await using var provider = new ServiceCollection()
             .AddFakeLogging()
             .AddFakeRedaction()
             .AddHttpClient()
             .AddExtendedHttpClientLogging()
             .BlockRemoteCall()
             .BuildServiceProvider();
 
        var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("test");
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _unreachableRequestUri);
 
        _ = await client.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead);
        var collector = provider.GetFakeLogCollector();
        var logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory);
 
        logRecord.Scopes.Should().HaveCount(0);
    }
 
    [Theory]
    [InlineData(4_096)]
    [InlineData(8_192)]
    [InlineData(16_384)]
    [InlineData(32_768)]
    [InlineData(315_883)]
    public async Task HttpClientLoggingHandler_LogsBodyDataUpToSpecifiedLimit(int limit)
    {
        await using var provider = new ServiceCollection()
             .AddFakeLogging()
             .AddFakeRedaction()
             .AddHttpClient(nameof(HttpClientLoggingHandler_LogsBodyDataUpToSpecifiedLimit))
             .AddExtendedHttpClientLogging(x =>
             {
                 x.ResponseHeadersDataClasses.Add("ResponseHeader", FakeTaxonomy.PrivateData);
                 x.RequestHeadersDataClasses.Add("RequestHeader", FakeTaxonomy.PrivateData);
                 x.RequestHeadersDataClasses.Add("RequestHeader2", FakeTaxonomy.PrivateData);
                 x.RequestBodyContentTypes.Add("application/json");
                 x.ResponseBodyContentTypes.Add("application/json");
                 x.BodySizeLimit = limit;
                 x.LogBody = true;
             })
             .Services
             .BlockRemoteCall()
             .BuildServiceProvider();
 
        var client = provider
             .GetRequiredService<IHttpClientFactory>()
             .CreateClient(nameof(HttpClientLoggingHandler_LogsBodyDataUpToSpecifiedLimit));
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = _unreachableRequestUri,
        };
 
        httpRequestMessage.Headers.Add("requestHeader", "Request Value");
        httpRequestMessage.Headers.Add("ReQuEStHeAdEr2", new List<string> { "Request Value 2", "Request Value 3" });
 
        var content = await client.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead);
        var responseStream = await content.Content.ReadAsStreamAsync();
        var length = (int)responseStream.Length > limit ? limit : (int)responseStream.Length;
        var buffer = new byte[length];
        _ = await responseStream.ReadAsync(buffer, 0, length);
        var responseString = Encoding.UTF8.GetString(buffer);
 
        var collector = provider.GetFakeLogCollector();
        var logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory);
        var state = logRecord.StructuredState;
        state.Should().Contain(kvp => kvp.Value == responseString);
        state.Should().Contain(kvp => kvp.Value == "Request Value");
        state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3");
    }
}