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;