diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/NetworkMetricsRecordingHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/NetworkMetricsRecordingHandlerTests.cs
index 81b4fa4a..236bffff 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/NetworkMetricsRecordingHandlerTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/NetworkMetricsRecordingHandlerTests.cs
@@ -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);
+ }
+
+ /// Read-only, non-seekable stream — CanSeek=false keeps StreamContent from
+ /// computing a Content-Length, mirroring a decompression-wrapped response body.
+ 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); }
+ }
+
+ /// Content whose serialization always fails — drives the fallback's catch path.
+ 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()
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/RecordingHttpMessageHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/RecordingHttpMessageHandler.cs
index 6fdba0a5..cf29bfa8 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/RecordingHttpMessageHandler.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/RecordingHttpMessageHandler.cs
@@ -40,6 +40,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
return this;
}
+ ///
+ /// Queues an arbitrary response — for shapes can't build,
+ /// e.g. content without a Content-Length header (what AutomaticDecompression leaves
+ /// behind on real compressed responses).
+ ///
+ public RecordingHttpMessageHandler QueueResponse(Func factory)
+ {
+ _script.Enqueue(_ => factory());
+ return this;
+ }
+
protected override async Task SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetricsRecordingHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetricsRecordingHandler.cs
index f78fedc8..aca20054 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetricsRecordingHandler.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetricsRecordingHandler.cs
@@ -21,12 +21,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
///
/// bytes-up is read from
/// on the request content (set eagerly by ByteArrayContent/StringContent).
- /// bytes-down is read from the response Content-Length header — when
- /// AutomaticDecompression 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.
+ /// bytes-down prefers the response Content-Length header; the pipeline's
+ /// AutomaticDecompression 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. HttpClient buffers responses anyway
+ /// (default HttpCompletionOption.ResponseContentRead), so this adds no real
+ /// memory cost. The request count and failure count are always exact.
///
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;