mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+71
@@ -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()
|
||||
{
|
||||
|
||||
+11
@@ -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)
|
||||
|
||||
+28
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user