diff --git a/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs
new file mode 100644
index 0000000..65b1437
--- /dev/null
+++ b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs
@@ -0,0 +1,135 @@
+using System.Net;
+using System.Text;
+using DebugProbe.AspNetCore.Handlers;
+using DebugProbe.AspNetCore.Models;
+using DebugProbe.AspNetCore.Options;
+
+namespace DebugProbe.AspNetCore.Tests.Handlers;
+
+public class DebugProbeHttpClientHandlerBodyTests
+{
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Builds a handler wired to the given DebugEntry and options, with a stub
+ /// inner handler that echoes back.
+ ///
+ private static (HttpClient client, DebugEntry entry) BuildClient(
+ DebugProbeOptions options,
+ HttpContent? responseContent = null)
+ {
+ var entry = new DebugEntry();
+ var context = new DefaultHttpContext();
+ context.Items["DebugProbeEntry"] = entry;
+
+ var handler = new DebugProbeHttpClientHandler(
+ new HttpContextAccessor { HttpContext = context },
+ options)
+ {
+ InnerHandler = new StubHandler(_ =>
+ new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = responseContent
+ })
+ };
+
+ return (new HttpClient(handler), entry);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Test cases
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Body_under_limit_is_captured_without_truncation_marker()
+ {
+ const int limitKb = 1; // 1 KB limit
+ var bodyText = new string('A', 100); // 100 bytes — well under 1 KB
+
+ var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb };
+ var (client, entry) = BuildClient(options,
+ responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain"));
+
+ await client.GetAsync("https://example.test/");
+
+ var outgoing = Assert.Single(entry.OutgoingRequests);
+ Assert.DoesNotContain("[truncated]", outgoing.ResponseBody);
+ Assert.Contains(bodyText, outgoing.ResponseBody);
+ }
+
+ [Fact]
+ public async Task Body_exactly_at_limit_is_captured_without_truncation_marker()
+ {
+ const int limitKb = 1;
+ var bodyText = new string('B', 1024); // exactly 1 KB
+
+ var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb };
+ var (client, entry) = BuildClient(options,
+ responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain"));
+
+ await client.GetAsync("https://example.test/");
+
+ var outgoing = Assert.Single(entry.OutgoingRequests);
+ Assert.DoesNotContain("[truncated]", outgoing.ResponseBody);
+ Assert.Contains(bodyText, outgoing.ResponseBody);
+ }
+
+ [Fact]
+ public async Task Body_over_limit_is_truncated_and_marker_is_appended()
+ {
+ const int limitKb = 1;
+ var bodyText = new string('C', 2048); // 2 KB — double the limit
+
+ var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb };
+ var (client, entry) = BuildClient(options,
+ responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain"));
+
+ await client.GetAsync("https://example.test/");
+
+ var outgoing = Assert.Single(entry.OutgoingRequests);
+ Assert.EndsWith("[truncated]", outgoing.ResponseBody);
+ // Captured prefix must be exactly the limit (1024 'C' chars)
+ Assert.StartsWith(new string('C', 1024), outgoing.ResponseBody);
+ }
+
+ [Fact]
+ public async Task Null_content_returns_empty_string()
+ {
+ var options = new DebugProbeOptions();
+ var (client, entry) = BuildClient(options, responseContent: null);
+
+ await client.GetAsync("https://example.test/");
+
+ var outgoing = Assert.Single(entry.OutgoingRequests);
+ Assert.Equal(string.Empty, outgoing.ResponseBody);
+ }
+
+ [Fact]
+ public async Task Non_text_content_returns_empty_string()
+ {
+ var binaryContent = new ByteArrayContent([0x89, 0x50, 0x4E, 0x47]); // PNG header bytes
+ binaryContent.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
+
+ var options = new DebugProbeOptions();
+ var (client, entry) = BuildClient(options, responseContent: binaryContent);
+
+ await client.GetAsync("https://example.test/");
+
+ var outgoing = Assert.Single(entry.OutgoingRequests);
+ Assert.Equal(string.Empty, outgoing.ResponseBody);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Stub inner handler
+ // ---------------------------------------------------------------------------
+
+ private sealed class StubHandler(Func send) : HttpMessageHandler
+ {
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(send(request));
+ }
+}
diff --git a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs
index b7f606e..a0fb6a3 100644
--- a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs
+++ b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics;
+using System.Diagnostics;
using DebugProbe.AspNetCore.Internal.Utils;
using DebugProbe.AspNetCore.Models;
using DebugProbe.AspNetCore.Options;
@@ -108,10 +108,31 @@ private async Task CaptureBodyAsync(HttpContent? content)
return string.Empty;
}
- var body = await content.ReadAsStringAsync();
+ if (_options.MaxBodyCaptureSizeBytes <= 0)
+ return string.Empty;
+
+ var limit = _options.MaxBodyCaptureSizeBytes;
+ var buffer = new byte[limit + 1];
+ var totalRead = 0;
+
+ var stream = await content.ReadAsStreamAsync();
+
+ int bytesRead;
+ while (totalRead < buffer.Length &&
+ (bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead))) > 0)
+ {
+ totalRead += bytesRead;
+ }
+
+ var truncated = totalRead > limit;
+ var encoding = System.Text.Encoding.UTF8;
+ var body = encoding.GetString(buffer, 0, Math.Min(totalRead, limit));
+
+ if (truncated)
+ {
+ body += "[truncated]";
+ }
- return JsonUtils.Format(RedactionUtils.RedactJsonFields(
- HttpContentUtils.Trim(body, _options.MaxBodyCaptureSizeBytes),
- _options));
+ return JsonUtils.Format(RedactionUtils.RedactJsonFields(body, _options));
}
}