fix(agent): record real bytes-down when AutomaticDecompression strips Content-Length

The pipeline's AutomaticDecompression (GZip|Deflate) removes the response
Content-Length header on virtually every backend JSON response, so
net_total_bytes_down in agent_metrics_snapshot read 0 on healthy sessions.
When the header is absent, buffer the content (LoadIntoBufferAsync, 16 MB cap)
and record the DECOMPRESSED payload size — HttpClient buffers responses anyway
(default ResponseContentRead), so no extra memory cost. Fail-soft: a buffering
error keeps 0 and leaves the failure for the caller to observe.
AutomaticDecompression itself stays untouched (pinned by
MtlsHttpClientFactoryTests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-20 01:25:41 +02:00
parent ff8b847dc0
commit 78cc038426
3 changed files with 110 additions and 3 deletions
@@ -144,6 +144,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Equal(1 + 2 + 3, snap.TotalBytesUp);
}
[Fact]
public async Task Captures_bytes_down_via_buffering_when_content_length_is_missing()
{
// AutomaticDecompression strips Content-Length on the backend's gzipped JSON —
// the pre-fix handler recorded 0 for every such (healthy) response. A non-seekable
// stream body reproduces the headerless shape.
var metrics = new NetworkMetrics();
var inner = new RecordingHttpMessageHandler();
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}"); // 11 bytes
inner.QueueResponse(() => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(new NonSeekableReadStream(payload)),
});
using var client = BuildClient(metrics, inner);
using var resp = await client.SendAsync(NewPost(""));
var snap = metrics.GetSnapshot();
Assert.Equal(payload.Length, snap.TotalBytesDown);
// Buffering must not consume the body away from the caller.
Assert.Equal("{\"ok\":true}", await resp.Content.ReadAsStringAsync());
}
[Fact]
public async Task Bytes_down_fails_soft_to_zero_when_buffering_throws()
{
var metrics = new NetworkMetrics();
var inner = new RecordingHttpMessageHandler();
inner.QueueResponse(() => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ThrowingContent(),
});
using var client = BuildClient(metrics, inner);
// ResponseHeadersRead keeps HttpClient's own post-pipeline buffering out of the
// picture — the test isolates the handler's fallback catch (which must swallow
// the serialization failure and record 0, never throw).
using var resp = await client.SendAsync(NewPost(""), HttpCompletionOption.ResponseHeadersRead);
var snap = metrics.GetSnapshot();
Assert.Equal(1, snap.RequestCount);
Assert.Equal(0, snap.TotalBytesDown);
}
/// <summary>Read-only, non-seekable stream — CanSeek=false keeps StreamContent from
/// computing a Content-Length, mirroring a decompression-wrapped response body.</summary>
private sealed class NonSeekableReadStream : System.IO.Stream
{
private readonly System.IO.MemoryStream _inner;
public NonSeekableReadStream(byte[] data) { _inner = new System.IO.MemoryStream(data); }
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override long Seek(long offset, System.IO.SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing) { if (disposing) _inner.Dispose(); base.Dispose(disposing); }
}
/// <summary>Content whose serialization always fails — drives the fallback's catch path.</summary>
private sealed class ThrowingContent : HttpContent
{
protected override Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context)
=> throw new System.IO.IOException("connection dropped mid-body");
protected override bool TryComputeLength(out long length) { length = 0; return false; }
}
[Fact]
public void Constructor_rejects_null_metrics()
{
@@ -40,6 +40,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
return this;
}
/// <summary>
/// Queues an arbitrary response — for shapes <see cref="QueueStatus"/> can't build,
/// e.g. content without a Content-Length header (what AutomaticDecompression leaves
/// behind on real compressed responses).
/// </summary>
public RecordingHttpMessageHandler QueueResponse(Func<HttpResponseMessage> factory)
{
_script.Enqueue(_ => factory());
return this;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
@@ -21,12 +21,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// <remarks>
/// <b>bytes-up</b> is read from <see cref="System.Net.Http.Headers.HttpContentHeaders.ContentLength"/>
/// on the request content (set eagerly by <c>ByteArrayContent</c>/<c>StringContent</c>).
/// <b>bytes-down</b> is read from the response Content-Length header — when
/// <c>AutomaticDecompression</c> is enabled the framework may strip this, so the value
/// is best-effort (degrades to 0). The request count and failure count are always exact.
/// <b>bytes-down</b> prefers the response Content-Length header; the pipeline's
/// <c>AutomaticDecompression</c> strips that header on compressed responses (i.e. on
/// virtually every backend JSON response), so when it is absent the content is buffered
/// and the DECOMPRESSED payload size is recorded instead — before the fallback the
/// counter read 0 on healthy sessions. <c>HttpClient</c> buffers responses anyway
/// (default <c>HttpCompletionOption.ResponseContentRead</c>), so this adds no real
/// memory cost. The request count and failure count are always exact.
/// </remarks>
internal sealed class NetworkMetricsRecordingHandler : DelegatingHandler
{
// Buffering cap for the bytes-down fallback. Backend responses are small JSON
// (config, ingest acks); anything larger is counted as 0 rather than buffered.
private const long MaxCountedResponseBytes = 16 * 1024 * 1024;
private readonly NetworkMetrics _metrics;
public NetworkMetricsRecordingHandler(NetworkMetrics metrics)
@@ -54,6 +62,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
bytesDown = response?.Content?.Headers?.ContentLength ?? 0L;
if (bytesDown == 0 && response?.Content != null)
{
// AutomaticDecompression stripped Content-Length (the norm for the
// backend's gzipped JSON). Buffer the content — after buffering the
// header reflects the decompressed length. Fail-soft: a buffering
// error (too large, connection dropped mid-body) keeps 0 and leaves
// the response for the caller to observe the failure itself.
try
{
await response.Content.LoadIntoBufferAsync(MaxCountedResponseBytes).ConfigureAwait(false);
bytesDown = response.Content.Headers.ContentLength ?? 0L;
}
catch
{
// keep bytesDown = 0
}
}
if (response != null && !response.IsSuccessStatusCode)
{
failed = true;