From b58b29abec0e4abdf47bb98b00f26feefd0733fa Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sun, 7 Jun 2026 22:49:50 +0300 Subject: [PATCH] feat(http-client): add configurable outgoing request capture --- .../Configuration/DebugProbeOptionsTests.cs | 3 ++ .../OutgoingHttpClientCaptureOptionsTests.cs | 53 +++++++++++++++++++ .../Infrastructure/DebugProbeTestApp.cs | 4 +- .../Extensions/DebugProbeExtensions.cs | 15 +++--- .../Options/DebugProbeOptions.cs | 6 +++ DebugProbe.AspNetCore/README.md | 4 +- README.md | 4 +- 7 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 DebugProbe.AspNetCore.Tests/Handlers/OutgoingHttpClientCaptureOptionsTests.cs diff --git a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs index 9bf8e1d..a2ad663 100644 --- a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs +++ b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs @@ -16,6 +16,7 @@ public void Defaults_work_correctly() Assert.Equal(32, options.MaxBodyCaptureSizeKb); Assert.Null(options.AllowLocalCompareTargets); Assert.False(options.AllowUiInProduction); + Assert.True(options.CaptureOutgoingHttpClientRequests); Assert.Empty(options.IgnorePaths); Assert.Equal(["Authorization", "Cookie", "Set-Cookie"], options.RedactedHeaders); Assert.Empty(options.RedactedQueryParameters); @@ -34,6 +35,7 @@ public void Custom_options_are_registered_and_used() options.MaxBodyCaptureSizeKb = 4; options.AllowLocalCompareTargets = true; options.IgnorePaths = ["/health"]; + options.CaptureOutgoingHttpClientRequests = false; options.RedactedHeaders = ["X-Api-Key"]; options.RedactedQueryParameters = ["token"]; options.RedactedJsonFields = ["password"]; @@ -48,6 +50,7 @@ public void Custom_options_are_registered_and_used() Assert.Equal(4, options.MaxBodyCaptureSizeKb); Assert.True(options.AllowLocalCompareTargets); Assert.Equal(["/health"], options.IgnorePaths); + Assert.False(options.CaptureOutgoingHttpClientRequests); Assert.Equal(["X-Api-Key"], options.RedactedHeaders); Assert.Equal(["token"], options.RedactedQueryParameters); Assert.Equal(["password"], options.RedactedJsonFields); diff --git a/DebugProbe.AspNetCore.Tests/Handlers/OutgoingHttpClientCaptureOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Handlers/OutgoingHttpClientCaptureOptionsTests.cs new file mode 100644 index 0000000..4b5aa10 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Handlers/OutgoingHttpClientCaptureOptionsTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace DebugProbe.AspNetCore.Tests.Handlers; + +public class OutgoingHttpClientCaptureOptionsTests +{ + [Fact] + public async Task Disabled_outgoing_capture_does_not_store_outgoing_traces() + { + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => + { + endpoints.MapGet("/proxy", async (IHttpClientFactory httpClientFactory) => + { + var client = httpClientFactory.CreateClient("outgoing"); + var body = await client.GetStringAsync("https://api.example.test/ping"); + + return Results.Text(body); + }); + }, + configureOptions: options => options.CaptureOutgoingHttpClientRequests = false, + configureServices: services => + { + services.AddHttpClient("outgoing") + .ConfigurePrimaryHttpMessageHandler(() => + new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("pong") + })); + }); + + var response = await app.Client.GetAsync("/proxy"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("pong", await response.Content.ReadAsStringAsync()); + + var entry = app.SingleEntry; + Assert.Equal("GET", entry.Method); + Assert.Equal("/proxy", entry.Path); + Assert.Empty(entry.OutgoingRequests); + } + + private sealed class StubHandler(Func send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(send(request)); + } + } +} diff --git a/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeTestApp.cs b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeTestApp.cs index fd6dcb7..edd2652 100644 --- a/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeTestApp.cs +++ b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeTestApp.cs @@ -32,7 +32,8 @@ private DebugProbeTestApp(IHost host) public static async Task CreateAsync( Action mapEndpoints, Action? configureOptions = null, - Action? configureAfterDebugProbe = null) + Action? configureAfterDebugProbe = null, + Action? configureServices = null) { var host = await new HostBuilder() .ConfigureWebHost(webHost => @@ -42,6 +43,7 @@ public static async Task CreateAsync( { services.AddRouting(); services.AddDebugProbe(configureOptions); + configureServices?.Invoke(services); }); webHost.Configure(app => { diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs index 2e2ded7..d59496b 100644 --- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs +++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs @@ -43,15 +43,18 @@ public static IServiceCollection AddDebugProbe(this IServiceCollection services, services.AddHttpClient(); - services.AddTransient(); - - services.ConfigureAll(options => + if (options.CaptureOutgoingHttpClientRequests) { - options.HttpMessageHandlerBuilderActions.Add(builder => + services.AddTransient(); + + services.ConfigureAll(httpClientOptions => { - builder.AdditionalHandlers.Add(builder.Services.GetRequiredService()); + httpClientOptions.HttpMessageHandlerBuilderActions.Add(builder => + { + builder.AdditionalHandlers.Add(builder.Services.GetRequiredService()); + }); }); - }); + } return services; } diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index a727a7b..0b8ee3d 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -29,6 +29,12 @@ public class DebugProbeOptions /// public bool AllowUiInProduction { get; set; } + /// + /// Captures outgoing requests made through IHttpClientFactory. + /// Defaults to true. + /// + public bool CaptureOutgoingHttpClientRequests { get; set; } = true; + /// /// Additional request paths to ignore. /// diff --git a/DebugProbe.AspNetCore/README.md b/DebugProbe.AspNetCore/README.md index bf799b6..9afdc77 100644 --- a/DebugProbe.AspNetCore/README.md +++ b/DebugProbe.AspNetCore/README.md @@ -48,6 +48,8 @@ builder.Services.AddDebugProbe(options => options.AllowUiInProduction = false; + options.CaptureOutgoingHttpClientRequests = true; + options.IgnorePaths = [ "/api/auth/login", @@ -89,7 +91,7 @@ app.UseDebugProbe(); - Configurable body capture limits - Ignored path configuration for noisy or sensitive endpoints - Configurable redaction for sensitive headers, query parameters, and JSON fields -- Outgoing `HttpClient` request tracing +- Optional outgoing `HttpClient` request tracing ## Trace Compare diff --git a/README.md b/README.md index 9aedddc..6ec74e9 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ builder.Services.AddDebugProbe(options => options.AllowUiInProduction = false; + options.CaptureOutgoingHttpClientRequests = true; + options.IgnorePaths = [ "/api/auth/login", @@ -89,7 +91,7 @@ app.UseDebugProbe(); - Configurable body capture limits - Ignored path configuration for noisy or sensitive endpoints - Configurable redaction for sensitive headers, query parameters, and JSON fields -- Outgoing `HttpClient` request tracing +- Optional outgoing `HttpClient` request tracing ## Trace Compare