Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
Expand All @@ -10,6 +11,7 @@
using Microsoft.Extensions.Http.Logging.Internal;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Telemetry.Internal;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.DependencyInjection;
Expand All @@ -34,7 +36,31 @@ public static IHttpClientBuilder AddExtendedHttpClientLogging(this IHttpClientBu
{
_ = Throw.IfNull(builder);

return AddExtendedHttpClientLoggingInternal(builder);
return AddExtendedHttpClientLoggingInternal(builder, configureOptionsBuilder: null, wrapHandlersPipeline: true);
}

/// <summary>
/// Adds an <see cref="IHttpClientAsyncLogger" /> to emit logs for outgoing requests for a named <see cref="HttpClient"/>.
/// </summary>
/// <param name="builder">The <see cref="IHttpClientBuilder" />.</param>
/// <param name="wrapHandlersPipeline">
/// Determines the logging behavior when resilience strategies (like retries or hedging) are configured.
/// When <see langword="true"/>, logs one record per logical request with total duration including all retry attempts.
/// When <see langword="false"/>, logs each individual attempt separately with per-attempt duration.
/// </param>
/// <returns>The value of <paramref name="builder"/>.</returns>
/// <remarks>
/// All other loggers are removed - including the default one, registered via <see cref="HttpClientBuilderExtensions.AddDefaultLogger(IHttpClientBuilder)"/>.
/// A lot of the information logged by this method (like bodies, methods, host, path, and duration) will be added as enrichment tags to the structured log. Make sure
/// you have a way of viewing structured logs in order to view this extra information.
/// </remarks>
/// <exception cref="ArgumentNullException">Argument <paramref name="builder"/> is <see langword="null"/>.</exception>
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
public static IHttpClientBuilder AddExtendedHttpClientLogging(this IHttpClientBuilder builder, bool wrapHandlersPipeline)
{
_ = Throw.IfNull(builder);

return AddExtendedHttpClientLoggingInternal(builder, configureOptionsBuilder: null, wrapHandlersPipeline);
}
Comment on lines +58 to 64
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new overload with wrapHandlersPipeline parameter is marked with [Experimental] attribute, but the existing overload without this parameter (line 35) is not, even though both can result in the same behavior (wrapHandlersPipeline: true). This creates an inconsistency where users calling AddExtendedHttpClientLogging(builder, true) get an experimental warning, but users calling AddExtendedHttpClientLogging(builder) (which also uses wrapHandlersPipeline: true internally) do not. Consider either: (1) marking all overloads as experimental for consistency, (2) removing the experimental attribute from the new overloads if the underlying functionality is stable, or (3) providing clear documentation explaining why only the new overloads are experimental.

Copilot uses AI. Check for mistakes.

/// <summary>
Expand All @@ -54,7 +80,33 @@ public static IHttpClientBuilder AddExtendedHttpClientLogging(this IHttpClientBu
_ = Throw.IfNull(builder);
_ = Throw.IfNull(section);

return AddExtendedHttpClientLoggingInternal(builder, options => options.Bind(section));
return AddExtendedHttpClientLoggingInternal(builder, options => options.Bind(section), wrapHandlersPipeline: true);
}

/// <summary>
/// Adds an <see cref="IHttpClientAsyncLogger" /> to emit logs for outgoing requests for a named <see cref="HttpClient"/>.
/// </summary>
/// <param name="builder">The <see cref="IHttpClientBuilder" />.</param>
/// <param name="section">The <see cref="IConfigurationSection"/> to use for configuring <see cref="LoggingOptions"/>.</param>
/// <param name="wrapHandlersPipeline">
/// Determines the logging behavior when resilience strategies (like retries or hedging) are configured.
/// When <see langword="true"/>, logs one record per logical request with total duration including all retry attempts.
/// When <see langword="false"/>, logs each individual attempt separately with per-attempt duration.
/// </param>
/// <returns>The value of <paramref name="builder"/>.</returns>
/// <remarks>
/// All other loggers are removed - including the default one, registered via <see cref="HttpClientBuilderExtensions.AddDefaultLogger(IHttpClientBuilder)"/>.
/// A lot of the information logged by this method (like bodies, methods, host, path, and duration) will be added as enrichment tags to the structured log. Make sure
/// you have a way of viewing structured logs in order to view this extra information.
/// </remarks>
/// <exception cref="ArgumentNullException">Any of the arguments is <see langword="null"/>.</exception>
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
public static IHttpClientBuilder AddExtendedHttpClientLogging(this IHttpClientBuilder builder, IConfigurationSection section, bool wrapHandlersPipeline)
{
_ = Throw.IfNull(builder);
_ = Throw.IfNull(section);

return AddExtendedHttpClientLoggingInternal(builder, options => options.Bind(section), wrapHandlersPipeline);
}
Comment on lines +103 to 110
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same inconsistency as the previous overload: the new overload with wrapHandlersPipeline is marked [Experimental], but the existing overload at line 78 (which calls the same internal method with wrapHandlersPipeline: true) is not. This means users will get different experimental warnings depending on which overload they call, even when the behavior is identical.

Copilot uses AI. Check for mistakes.

/// <summary>
Expand All @@ -74,10 +126,39 @@ public static IHttpClientBuilder AddExtendedHttpClientLogging(this IHttpClientBu
_ = Throw.IfNull(builder);
_ = Throw.IfNull(configure);

return AddExtendedHttpClientLoggingInternal(builder, options => options.Configure(configure));
return AddExtendedHttpClientLoggingInternal(builder, options => options.Configure(configure), wrapHandlersPipeline: true);
}

/// <summary>
/// Adds an <see cref="IHttpClientAsyncLogger" /> to emit logs for outgoing requests for a named <see cref="HttpClient"/>.
/// </summary>
/// <param name="builder">The <see cref="IHttpClientBuilder" />.</param>
/// <param name="configure">The delegate to configure <see cref="LoggingOptions"/> with.</param>
/// <param name="wrapHandlersPipeline">
/// Determines the logging behavior when resilience strategies (like retries or hedging) are configured.
/// When <see langword="true"/>, logs one record per logical request with total duration including all retry attempts.
/// When <see langword="false"/>, logs each individual attempt separately with per-attempt duration.
/// </param>
/// <returns>The value of <paramref name="builder"/>.</returns>
/// <remarks>
/// All other loggers are removed - including the default one, registered via <see cref="HttpClientBuilderExtensions.AddDefaultLogger(IHttpClientBuilder)"/>.
/// A lot of the information logged by this method (like bodies, methods, host, path, and duration) will be added as enrichment tags to the structured log. Make sure
/// you have a way of viewing structured logs in order to view this extra information.
/// </remarks>
/// <exception cref="ArgumentNullException">Any of the arguments is <see langword="null"/>.</exception>
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
public static IHttpClientBuilder AddExtendedHttpClientLogging(this IHttpClientBuilder builder, Action<LoggingOptions> configure, bool wrapHandlersPipeline)
{
_ = Throw.IfNull(builder);
_ = Throw.IfNull(configure);

return AddExtendedHttpClientLoggingInternal(builder, options => options.Configure(configure), wrapHandlersPipeline);
}
Comment on lines +149 to 156
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same inconsistency as previous overloads: the new overload with wrapHandlersPipeline is marked [Experimental], but the existing overload at line 124 (which calls the same internal method with wrapHandlersPipeline: true) is not. This means users will get different experimental warnings depending on which overload they call, even when the behavior is identical.

Copilot uses AI. Check for mistakes.

private static IHttpClientBuilder AddExtendedHttpClientLoggingInternal(IHttpClientBuilder builder, Action<OptionsBuilder<LoggingOptions>>? configureOptionsBuilder = null)
private static IHttpClientBuilder AddExtendedHttpClientLoggingInternal(
IHttpClientBuilder builder,
Action<OptionsBuilder<LoggingOptions>>? configureOptionsBuilder,
bool wrapHandlersPipeline)
{
var optionsBuilder = builder.Services
.AddOptionsWithValidateOnStart<LoggingOptions, LoggingOptionsValidator>(builder.Name);
Expand All @@ -97,6 +178,6 @@ private static IHttpClientBuilder AddExtendedHttpClientLoggingInternal(IHttpClie
.RemoveAllLoggers()
.AddLogger(
serviceProvider => serviceProvider.GetRequiredKeyedService<HttpClientLogger>(builder.Name),
wrapHandlersPipeline: true);
wrapHandlersPipeline);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Diagnostics;
using Microsoft.Extensions.Http.Latency.Internal;
using Microsoft.Extensions.Options;
using Moq;
Expand Down Expand Up @@ -105,6 +109,124 @@ public void RequestLatencyExtensions_Add_BindsToConfigSection()
Assert.Equal(expectedOptions.EnableDetailedLatencyBreakdown, options.EnableDetailedLatencyBreakdown);
}

[Fact]
public async Task LatencyInfo_IsPopulated_WhenLoggerWrapsHandlersPipeline()
{
using var sp = new ServiceCollection()
.AddLatencyContext()
.AddRedaction()
.AddHttpClientLatencyTelemetry()
.AddHttpClient("test")
.ConfigurePrimaryHttpMessageHandler(() => new ServerNameStubHandler("TestServer"))
.AddExtendedHttpClientLogging(wrapHandlersPipeline: true)
.Services
.AddFakeLogging()
.BuildServiceProvider();

var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient("test");

using var response = await client.GetAsync("http://localhost/api");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var collector = sp.GetFakeLogCollector();
var record = collector.LatestRecord;
Assert.NotNull(record);

var latencyInfo = record.GetStructuredStateValue("LatencyInfo");
Assert.False(string.IsNullOrEmpty(latencyInfo));
Assert.StartsWith("v1.0,", latencyInfo);
Assert.Contains("TestServer", latencyInfo);
}

[Fact]
public async Task LatencyInfo_IsPopulated_WithConfigurationSection_AndWrapHandlersPipeline()
{
var configSection = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Logging:LogBody", "true" }
})
.Build()
.GetSection("Logging");

using var sp = new ServiceCollection()
.AddLatencyContext()
.AddRedaction()
.AddHttpClientLatencyTelemetry()
.AddHttpClient("test")
.ConfigurePrimaryHttpMessageHandler(() => new ServerNameStubHandler("TestServer"))
.AddExtendedHttpClientLogging(configSection, wrapHandlersPipeline: true)
.Services
.AddFakeLogging()
.BuildServiceProvider();

var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient("test");

using var response = await client.GetAsync("http://localhost/api");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var collector = sp.GetFakeLogCollector();
var record = collector.LatestRecord;
Assert.NotNull(record);

var latencyInfo = record.GetStructuredStateValue("LatencyInfo");
Assert.False(string.IsNullOrEmpty(latencyInfo));
Assert.StartsWith("v1.0,", latencyInfo);
}

[Fact]
public async Task LatencyInfo_IsPopulated_WithActionConfiguration_AndWrapHandlersPipeline()
{
using var sp = new ServiceCollection()
.AddLatencyContext()
.AddRedaction()
.AddHttpClientLatencyTelemetry()
.AddHttpClient("test")
.ConfigurePrimaryHttpMessageHandler(() => new ServerNameStubHandler("TestServer"))
.AddExtendedHttpClientLogging(o => o.LogBody = true, wrapHandlersPipeline: false)
.Services
.AddFakeLogging()
.BuildServiceProvider();

var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient("test");

using var response = await client.GetAsync("http://localhost/api");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var collector = sp.GetFakeLogCollector();
var record = collector.LatestRecord;
Assert.NotNull(record);

var latencyInfo = record.GetStructuredStateValue("LatencyInfo");
Assert.False(string.IsNullOrEmpty(latencyInfo));
Assert.StartsWith("v1.0,", latencyInfo);
}

[Fact]
public async Task LatencyInfo_IsNotPresent_WhenLatencyTelemetryNotAdded()
{
using var sp = new ServiceCollection()
.AddRedaction()
.AddExtendedHttpClientLogging()
.AddHttpClient("test")
.ConfigurePrimaryHttpMessageHandler(() => new ServerNameStubHandler("TestServer"))
.Services
.AddFakeLogging()
.BuildServiceProvider();

var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient("test");

using var response = await client.GetAsync("http://localhost/api");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var collector = sp.GetFakeLogCollector();
var record = collector.LatestRecord;
Assert.NotNull(record);

var latencyInfo = record.GetStructuredStateValue("LatencyInfo");
Assert.True(string.IsNullOrEmpty(latencyInfo));
}

private static IConfigurationSection GetConfigSection(HttpClientLatencyTelemetryOptions options)
{
return new ConfigurationBuilder()
Expand All @@ -115,4 +237,14 @@ private static IConfigurationSection GetConfigSection(HttpClientLatencyTelemetry
.Build()
.GetSection($"{nameof(HttpClientLatencyTelemetryOptions)}");
}
}

private sealed class ServerNameStubHandler(string serverName) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Headers.TryAddWithoutValidation(TelemetryConstants.ServerApplicationNameHeader, serverName);
return Task.FromResult(response);
}
}
}
Loading