From 518e8afc5f624f2dd6ba0442c2a2903696cb7576 Mon Sep 17 00:00:00 2001 From: "Marcus Kanon (consultant)" Date: Tue, 9 Jun 2026 16:31:41 +0200 Subject: [PATCH] Fix HTTP client logging configuration binding --- ...lientLoggingHttpClientBuilderExtensions.cs | 12 +- ...lientLoggingServiceCollectionExtensions.cs | 6 +- .../LoggingOptionsConfigureOptions.cs | 142 ++++++++++++++++++ .../HttpClientLoggingExtensionsTest.cs | 46 ++++++ 4 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LoggingOptionsConfigureOptions.cs diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingHttpClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingHttpClientBuilderExtensions.cs index 6639f9476d5..1b96030a9b3 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingHttpClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingHttpClientBuilderExtensions.cs @@ -76,7 +76,7 @@ public static IHttpClientBuilder AddExtendedHttpClientLogging(this IHttpClientBu _ = Throw.IfNull(builder); _ = Throw.IfNull(section); - return AddExtendedHttpClientLoggingInternal(builder, options => options.Bind(section), wrapHandlersPipeline: true); + return AddExtendedHttpClientLoggingInternal(builder, options => Configure(options, section), wrapHandlersPipeline: true); } /// @@ -100,7 +100,7 @@ public static IHttpClientBuilder AddExtendedHttpClientLogging(this IHttpClientBu _ = Throw.IfNull(builder); _ = Throw.IfNull(section); - return AddExtendedHttpClientLoggingInternal(builder, options => options.Bind(section), wrapHandlersPipeline); + return AddExtendedHttpClientLoggingInternal(builder, options => Configure(options, section), wrapHandlersPipeline); } /// @@ -171,4 +171,12 @@ private static IHttpClientBuilder AddExtendedHttpClientLoggingInternal( serviceProvider => serviceProvider.GetRequiredKeyedService(builder.Name), wrapHandlersPipeline); } + + private static void Configure(OptionsBuilder optionsBuilder, IConfigurationSection section) + { + _ = optionsBuilder.Services.AddSingleton>( + new LoggingOptionsConfigureOptions(optionsBuilder.Name, section)); + _ = optionsBuilder.Services.AddSingleton>( + new ConfigurationChangeTokenSource(optionsBuilder.Name, section)); + } } \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingServiceCollectionExtensions.cs index aa0f2ce78d1..4084e309dc6 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Extensions.Http.Logging; using Microsoft.Extensions.Http.Logging.Internal; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Telemetry.Internal; using Microsoft.Shared.Diagnostics; @@ -65,7 +66,10 @@ public static IServiceCollection AddExtendedHttpClientLogging(this IServiceColle _ = services .AddOptionsWithValidateOnStart() - .Bind(section); + .Services.AddSingleton>( + new LoggingOptionsConfigureOptions(section)) + .AddSingleton>( + new ConfigurationChangeTokenSource(section)); return services.AddExtendedHttpClientLogging(); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LoggingOptionsConfigureOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LoggingOptionsConfigureOptions.cs new file mode 100644 index 00000000000..292d395b0f8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LoggingOptionsConfigureOptions.cs @@ -0,0 +1,142 @@ +// 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.Globalization; +using Microsoft.Extensions.Compliance.Classification; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Http.Diagnostics; +using Microsoft.Extensions.Options; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.Http.Logging.Internal; + +internal sealed class LoggingOptionsConfigureOptions : IConfigureNamedOptions +{ + private static readonly DataClassificationTypeConverter _dataClassificationConverter = new(); + + private readonly string? _name; + private readonly IConfigurationSection _section; + + public LoggingOptionsConfigureOptions(IConfigurationSection section) + : this(Microsoft.Extensions.Options.Options.DefaultName, section) + { + } + + public LoggingOptionsConfigureOptions(string? name, IConfigurationSection section) + { + _name = name; + _section = Throw.IfNull(section); + } + + public void Configure(LoggingOptions options) + { + _ = Throw.IfNull(options); + + Configure(Microsoft.Extensions.Options.Options.DefaultName, options); + } + + public void Configure(string? name, LoggingOptions options) + { + _ = Throw.IfNull(options); + + if (!string.Equals(name, _name, StringComparison.Ordinal) || !_section.Exists()) + { + return; + } + + BindValue(_section, nameof(LoggingOptions.LogRequestStart), bool.Parse, value => options.LogRequestStart = value); + BindDataClassifications(_section.GetSection(nameof(LoggingOptions.RequestQueryParametersDataClasses)), options.RequestQueryParametersDataClasses); + BindValue(_section, nameof(LoggingOptions.LogBody), bool.Parse, value => options.LogBody = value); + BindValue( + _section, + nameof(LoggingOptions.BodySizeLimit), + static value => int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture), + value => options.BodySizeLimit = value); + BindValue( + _section, + nameof(LoggingOptions.BodyReadTimeout), + static value => TimeSpan.Parse(value, CultureInfo.InvariantCulture), + value => options.BodyReadTimeout = value); + BindSet(_section.GetSection(nameof(LoggingOptions.RequestBodyContentTypes)), options.RequestBodyContentTypes); + BindSet(_section.GetSection(nameof(LoggingOptions.ResponseBodyContentTypes)), options.ResponseBodyContentTypes); + BindDataClassifications(_section.GetSection(nameof(LoggingOptions.RequestHeadersDataClasses)), options.RequestHeadersDataClasses); + BindDataClassifications(_section.GetSection(nameof(LoggingOptions.ResponseHeadersDataClasses)), options.ResponseHeadersDataClasses); + BindEnum(_section, nameof(LoggingOptions.RequestPathLoggingMode), value => options.RequestPathLoggingMode = value); + BindEnum(_section, nameof(LoggingOptions.RequestPathParameterRedactionMode), value => options.RequestPathParameterRedactionMode = value); + BindDataClassifications(_section.GetSection(nameof(LoggingOptions.RouteParameterDataClasses)), options.RouteParameterDataClasses); + BindValue(_section, nameof(LoggingOptions.LogContentHeaders), bool.Parse, value => options.LogContentHeaders = value); + } + + private static void BindSet(IConfigurationSection section, ISet destination) + { + foreach (var child in section.GetChildren()) + { + if (child.Value is string value) + { + _ = destination.Add(value); + } + } + } + + private static void BindDataClassifications(IConfigurationSection section, IDictionary destination) + { + foreach (var child in section.GetChildren()) + { + if (TryParseDataClassification(child, out var classification)) + { + destination[child.Key] = classification; + } + } + } + + private static void BindEnum(IConfigurationSection section, string key, Action setter) + where TEnum : struct + => BindValue(section, key, static value => (TEnum)Enum.Parse(typeof(TEnum), value, ignoreCase: true), setter); + + private static void BindValue(IConfigurationSection section, string key, Func parser, Action setter) + { + if (section[key] is string value) + { + setter(parser(value)); + } + } + + private static bool TryParseDataClassification(IConfigurationSection section, out DataClassification classification) + { + if (section.Value is string value) + { + try + { + classification = (DataClassification)_dataClassificationConverter.ConvertFromInvariantString(value)!; + return true; + } + catch (Exception) + { + classification = default; + return false; + } + } + + var taxonomyName = section["taxonomyName"] ?? section[nameof(DataClassification.TaxonomyName)]; + var classificationValue = section["value"] ?? section[nameof(DataClassification.Value)]; + + if (string.IsNullOrWhiteSpace(taxonomyName) || string.IsNullOrWhiteSpace(classificationValue)) + { + classification = default; + return false; + } + + try + { + classification = new DataClassification(taxonomyName!, classificationValue!); + return true; + } + catch (ArgumentException) + { + classification = default; + return false; + } + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggingExtensionsTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggingExtensionsTest.cs index 7c797bab089..23a15bafcad 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggingExtensionsTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggingExtensionsTest.cs @@ -239,6 +239,30 @@ public void AddHttpClientLogging_GivenConfigurationSection_SetsTimeoutCorrectly( options.BodyReadTimeout.Should().Be(timeoutValue); } + [Fact] + public void AddHttpClientLogging_GivenConfigurationSection_BindsRequestHeadersDataClasses() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:User-Agent"] = "None", + [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:Authorization"] = "Unknown", + }) + .Build(); + + using var provider = new ServiceCollection() + .AddHttpClient("test") + .AddExtendedHttpClientLogging(configuration.GetSection(nameof(LoggingOptions))) + .Services + .BuildServiceProvider(); + + var options = provider + .GetRequiredService>().Get("test"); + + options.RequestHeadersDataClasses.Should().Contain("User-Agent", DataClassification.None); + options.RequestHeadersDataClasses.Should().Contain("Authorization", DataClassification.Unknown); + } + [Fact] public void AddHttpClientLogEnricher_RegistersEnricherInDI() { @@ -392,6 +416,28 @@ public void AddHttpClientLogging_ServiceCollection_GivenConfigurationSection_Set Assert.NotNull(httpClient); } + [Fact] + public void AddHttpClientLogging_ServiceCollection_GivenConfigurationSection_BindsRequestHeadersDataClasses() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:User-Agent"] = "None", + [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:Authorization"] = "Unknown", + }) + .Build(); + + using var provider = new ServiceCollection() + .AddHttpClient() + .AddExtendedHttpClientLogging(configuration.GetSection(nameof(LoggingOptions))) + .BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + options.RequestHeadersDataClasses.Should().Contain("User-Agent", DataClassification.None); + options.RequestHeadersDataClasses.Should().Contain("Authorization", DataClassification.Unknown); + } + [Fact] public void AddHttpClientLogging_ServiceCollection_CreatesClientSuccessfully() {