diff --git a/src/libraries/System.Net.Http/src/System.Net.Http.csproj b/src/libraries/System.Net.Http/src/System.Net.Http.csproj
index cb3622d067a77..de2be83a18d84 100644
--- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj
+++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj
@@ -211,6 +211,8 @@
+
+
_failedRequests;
private readonly Histogram _requestsDuration;
- public MetricsHandler(HttpMessageHandler innerHandler, IMeterFactory? meterFactory)
+ public MetricsHandler(HttpMessageHandler innerHandler, IMeterFactory? meterFactory, out Meter meter)
{
_innerHandler = innerHandler;
- Meter meter = meterFactory?.Create("System.Net.Http") ?? SharedMeter.Instance;
+ meter = meterFactory?.Create("System.Net.Http") ?? SharedMeter.Instance;
// Meter has a cache for the instruments it owns
_currentRequests = meter.CreateUpDownCounter(
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
index b2c592a0c63b7..88678ae0b8a97 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
@@ -48,7 +48,6 @@ internal sealed partial class Http2Connection : HttpConnectionBase
private bool _receivedSettingsAck;
private int _initialServerStreamWindowSize;
private int _pendingWindowUpdate;
- private long _idleSinceTickCount;
private uint _maxConcurrentStreams;
private uint _streamsInUse;
@@ -77,10 +76,6 @@ internal sealed partial class Http2Connection : HttpConnectionBase
// When all requests have completed, the connection will be torn down.
private bool _disposed;
- private const int TelemetryStatus_Opened = 1;
- private const int TelemetryStatus_Closed = 2;
- private int _markedByTelemetryStatus;
-
private const int MaxStreamId = int.MaxValue;
// Temporary workaround for request burst handling on connection start.
@@ -138,6 +133,7 @@ internal enum KeepAliveState
private volatile KeepAliveState _keepAliveState;
public Http2Connection(HttpConnectionPool pool, Stream stream)
+ : base(pool)
{
_pool = pool;
_stream = stream;
@@ -162,7 +158,6 @@ public Http2Connection(HttpConnectionPool pool, Stream stream)
_streamsInUse = 0;
_pendingWindowUpdate = 0;
- _idleSinceTickCount = Environment.TickCount64;
_keepAlivePingDelay = TimeSpanToMs(_pool.Settings._keepAlivePingDelay);
_keepAlivePingTimeout = TimeSpanToMs(_pool.Settings._keepAlivePingTimeout);
@@ -177,12 +172,6 @@ public Http2Connection(HttpConnectionPool pool, Stream stream)
_maxHeaderListSize = maxHeaderListSize;
}
- if (HttpTelemetry.Log.IsEnabled())
- {
- HttpTelemetry.Log.Http20ConnectionEstablished();
- _markedByTelemetryStatus = TelemetryStatus_Opened;
- }
-
if (NetEventSource.Log.IsEnabled()) TraceConnection(_stream);
static long TimeSpanToMs(TimeSpan value)
@@ -327,6 +316,11 @@ public bool TryReserveStream()
if (_streamsInUse < _maxConcurrentStreams)
{
+ if (_streamsInUse == 0)
+ {
+ MarkConnectionAsNotIdle();
+ }
+
_streamsInUse++;
return true;
}
@@ -356,7 +350,7 @@ public void ReleaseStream()
if (_streamsInUse == 0)
{
- _idleSinceTickCount = Environment.TickCount64;
+ MarkConnectionAsIdle();
if (_disposed)
{
@@ -1836,7 +1830,7 @@ public override long GetIdleTicks(long nowTicks)
{
lock (SyncObject)
{
- return _streamsInUse == 0 ? nowTicks - _idleSinceTickCount : 0;
+ return _streamsInUse == 0 ? base.GetIdleTicks(nowTicks) : 0;
}
}
@@ -1894,13 +1888,7 @@ private void FinalTeardown()
// ProcessIncomingFramesAsync and ProcessOutgoingFramesAsync respectively, and those methods are
// responsible for returning the buffers.
- if (HttpTelemetry.Log.IsEnabled())
- {
- if (Interlocked.Exchange(ref _markedByTelemetryStatus, TelemetryStatus_Closed) == TelemetryStatus_Opened)
- {
- HttpTelemetry.Log.Http20ConnectionClosed();
- }
- }
+ MarkConnectionAsClosed();
}
public override void Dispose()
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs
index ead8e5e06123b..33eadf13f725e 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs
@@ -11,7 +11,6 @@
using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Headers;
-using System.Net.Security;
namespace System.Net.Http
{
@@ -21,7 +20,6 @@ namespace System.Net.Http
internal sealed class Http3Connection : HttpConnectionBase
{
private readonly HttpConnectionPool _pool;
- private readonly HttpAuthority? _origin;
private readonly HttpAuthority _authority;
private readonly byte[]? _altUsedEncodedHeader;
private QuicConnection? _connection;
@@ -48,10 +46,6 @@ internal sealed class Http3Connection : HttpConnectionBase
// A connection-level error will abort any future operations.
private Exception? _abortException;
- private const int TelemetryStatus_Opened = 1;
- private const int TelemetryStatus_Closed = 2;
- private int _markedByTelemetryStatus;
-
public HttpAuthority Authority => _authority;
public HttpConnectionPool Pool => _pool;
public uint MaxHeaderListSize => _maxHeaderListSize;
@@ -71,10 +65,10 @@ private bool ShuttingDown
}
}
- public Http3Connection(HttpConnectionPool pool, HttpAuthority? origin, HttpAuthority authority, QuicConnection connection, bool includeAltUsedHeader)
+ public Http3Connection(HttpConnectionPool pool, HttpAuthority authority, QuicConnection connection, bool includeAltUsedHeader)
+ : base(pool)
{
_pool = pool;
- _origin = origin;
_authority = authority;
_connection = connection;
@@ -93,12 +87,6 @@ public Http3Connection(HttpConnectionPool pool, HttpAuthority? origin, HttpAutho
_maxHeaderListSize = maxHeaderListSize;
}
- if (HttpTelemetry.Log.IsEnabled())
- {
- HttpTelemetry.Log.Http30ConnectionEstablished();
- _markedByTelemetryStatus = TelemetryStatus_Opened;
- }
-
// Errors are observed via Abort().
_ = SendSettingsAsync();
@@ -168,13 +156,7 @@ private void CheckForShutdown()
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
- if (HttpTelemetry.Log.IsEnabled())
- {
- if (Interlocked.Exchange(ref _markedByTelemetryStatus, TelemetryStatus_Closed) == TelemetryStatus_Opened)
- {
- HttpTelemetry.Log.Http30ConnectionClosed();
- }
- }
+ MarkConnectionAsClosed();
}
}
@@ -201,6 +183,11 @@ public async Task SendAsync(HttpRequestMessage request, lon
requestStream = new Http3RequestStream(request, this, quicStream);
lock (SyncObj)
{
+ if (_activeRequests.Count == 0)
+ {
+ MarkConnectionAsNotIdle();
+ }
+
_activeRequests.Add(quicStream, requestStream);
}
}
@@ -352,11 +339,17 @@ public void RemoveStream(QuicStream stream)
{
lock (SyncObj)
{
- bool removed = _activeRequests.Remove(stream);
-
- if (removed && ShuttingDown)
+ if (_activeRequests.Remove(stream))
{
- CheckForShutdown();
+ if (ShuttingDown)
+ {
+ CheckForShutdown();
+ }
+
+ if (_activeRequests.Count == 0)
+ {
+ MarkConnectionAsIdle();
+ }
}
}
}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs
index d79d7233f3907..036012a0cf355 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs
@@ -61,7 +61,6 @@ internal sealed partial class HttpConnection : HttpConnectionBase
private ValueTask _readAheadTask;
private ArrayBuffer _readBuffer;
- private long _idleSinceTickCount;
private int _keepAliveTimeoutSeconds; // 0 == no timeout
private bool _inUse;
private bool _detachedFromPool;
@@ -70,13 +69,13 @@ internal sealed partial class HttpConnection : HttpConnectionBase
private bool _connectionClose; // Connection: close was seen on last response
private const int Status_Disposed = 1;
- private const int Status_NotDisposedAndTrackedByTelemetry = 2;
private int _disposed;
public HttpConnection(
HttpConnectionPool pool,
Stream stream,
TransportContext? transportContext)
+ : base(pool)
{
Debug.Assert(pool != null);
Debug.Assert(stream != null);
@@ -89,14 +88,6 @@ public HttpConnection(
_writeBuffer = new ArrayBuffer(InitialWriteBufferSize, usePool: false);
_readBuffer = new ArrayBuffer(InitialReadBufferSize, usePool: false);
- _idleSinceTickCount = Environment.TickCount64;
-
- if (HttpTelemetry.Log.IsEnabled())
- {
- HttpTelemetry.Log.Http11ConnectionEstablished();
- _disposed = Status_NotDisposedAndTrackedByTelemetry;
- }
-
if (NetEventSource.Log.IsEnabled()) TraceConnection(_stream);
}
@@ -108,16 +99,11 @@ private void Dispose(bool disposing)
{
// Ensure we're only disposed once. Dispose could be called concurrently, for example,
// if the request and the response were running concurrently and both incurred an exception.
- int previousValue = Interlocked.Exchange(ref _disposed, Status_Disposed);
- if (previousValue != Status_Disposed)
+ if (Interlocked.Exchange(ref _disposed, Status_Disposed) != Status_Disposed)
{
if (NetEventSource.Log.IsEnabled()) Trace("Connection closing.");
- // Only decrement the connection count if we counted this connection
- if (HttpTelemetry.Log.IsEnabled() && previousValue == Status_NotDisposedAndTrackedByTelemetry)
- {
- HttpTelemetry.Log.Http11ConnectionClosed();
- }
+ MarkConnectionAsClosed();
if (!_detachedFromPool)
{
@@ -270,8 +256,6 @@ private bool CheckKeepAliveTimeoutExceeded()
GetIdleTicks(Environment.TickCount64) >= _keepAliveTimeoutSeconds * 1000;
}
- public override long GetIdleTicks(long nowTicks) => nowTicks - _idleSinceTickCount;
-
public TransportContext? TransportContext => _transportContext;
public HttpConnectionKind Kind => _pool.Kind;
@@ -517,6 +501,8 @@ public async Task SendAsync(HttpRequestMessage request, boo
Debug.Assert(_readBuffer.ActiveLength == 0, "Unexpected data in read buffer");
Debug.Assert(_readAheadTaskStatus != ReadAheadTask_Started);
+ MarkConnectionAsNotIdle();
+
TaskCompletionSource? allowExpect100ToContinue = null;
Task? sendRequestContentTask = null;
@@ -2086,8 +2072,6 @@ private void ReturnConnectionToPool()
{
Debug.Assert(!_detachedFromPool, "Should not be detached from pool unless _connectionClose is true");
- _idleSinceTickCount = Environment.TickCount64;
-
// Put connection back in the pool.
_pool.RecycleHttp11Connection(this);
}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs
index ee85e1d277df3..bf2b4a0a90a2c 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs
@@ -5,6 +5,7 @@
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Http.Headers;
+using System.Net.Http.Metrics;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Text;
@@ -15,11 +16,90 @@ namespace System.Net.Http
{
internal abstract class HttpConnectionBase : IDisposable, IHttpTrace
{
+ // May be null if none of the counters were enabled when the connection was established.
+ private readonly ConnectionMetrics? _connectionMetrics;
+
+ // Indicates whether we've counted this connection as established, so that we can
+ // avoid decrementing the counter once it's closed in case telemetry was enabled in between.
+ private readonly bool _httpTelemetryMarkedConnectionAsOpened;
+
+ private readonly long _creationTickCount = Environment.TickCount64;
+ private long _idleSinceTickCount;
+
/// Cached string for the last Date header received on this connection.
private string? _lastDateHeaderValue;
/// Cached string for the last Server header received on this connection.
private string? _lastServerHeaderValue;
+ public HttpConnectionBase(HttpConnectionPool pool)
+ {
+ Debug.Assert(this is HttpConnection or Http2Connection or Http3Connection);
+ Debug.Assert(pool.Settings._metrics is not null);
+
+ SocketsHttpHandlerMetrics metrics = pool.Settings._metrics;
+
+ if (metrics.CurrentConnections.Enabled ||
+ metrics.IdleConnections.Enabled ||
+ metrics.ConnectionDuration.Enabled)
+ {
+ string protocol =
+ this is HttpConnection ? "HTTP/1.1" :
+ this is Http2Connection ? "HTTP/2" :
+ "HTTP/3";
+
+ int port = pool.OriginAuthority.Port;
+ int defaultPort = pool.IsSecure ? HttpConnectionPool.DefaultHttpsPort : HttpConnectionPool.DefaultHttpPort;
+
+ _connectionMetrics = new ConnectionMetrics(
+ metrics,
+ protocol,
+ pool.IsSecure ? "https" : "http",
+ pool.OriginAuthority.HostValue,
+ port == defaultPort ? null : port);
+
+ _connectionMetrics.ConnectionEstablished();
+
+ MarkConnectionAsIdle();
+ }
+
+ if (HttpTelemetry.Log.IsEnabled())
+ {
+ _httpTelemetryMarkedConnectionAsOpened = true;
+
+ if (this is HttpConnection) HttpTelemetry.Log.Http11ConnectionEstablished();
+ else if (this is Http2Connection) HttpTelemetry.Log.Http20ConnectionEstablished();
+ else HttpTelemetry.Log.Http30ConnectionEstablished();
+ }
+ }
+
+ public void MarkConnectionAsClosed()
+ {
+ _connectionMetrics?.ConnectionClosed(durationMs: Environment.TickCount64 - _creationTickCount);
+
+ if (HttpTelemetry.Log.IsEnabled())
+ {
+ // Only decrement the connection count if we counted this connection
+ if (_httpTelemetryMarkedConnectionAsOpened)
+ {
+ if (this is HttpConnection) HttpTelemetry.Log.Http11ConnectionClosed();
+ else if (this is Http2Connection) HttpTelemetry.Log.Http20ConnectionClosed();
+ else HttpTelemetry.Log.Http30ConnectionClosed();
+ }
+ }
+ }
+
+ public void MarkConnectionAsIdle()
+ {
+ _idleSinceTickCount = Environment.TickCount64;
+
+ _connectionMetrics?.MarkAsIdle();
+ }
+
+ public void MarkConnectionAsNotIdle()
+ {
+ _connectionMetrics?.MarkAsNotIdle();
+ }
+
/// Uses , but first special-cases several known headers for which we can use caching.
public string GetResponseHeaderValueWithCaching(HeaderDescriptor descriptor, ReadOnlySpan value, Encoding? valueEncoding)
{
@@ -60,11 +140,9 @@ protected void TraceConnection(Stream stream)
}
}
- private readonly long _creationTickCount = Environment.TickCount64;
-
public long GetLifetimeTicks(long nowTicks) => nowTicks - _creationTickCount;
- public abstract long GetIdleTicks(long nowTicks);
+ public virtual long GetIdleTicks(long nowTicks) => nowTicks - _idleSinceTickCount;
/// Check whether a connection is still usable, or should be scavenged.
/// True if connection can be used.
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
index a460307b99c04..f86147c596740 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
@@ -32,7 +32,7 @@ internal sealed class HttpConnectionPool : IDisposable
private readonly Uri? _proxyUri;
/// The origin authority used to construct the .
- private readonly HttpAuthority? _originAuthority;
+ private readonly HttpAuthority _originAuthority;
/// Initially set to null, this can be set to enable HTTP/3 based on Alt-Svc.
private volatile HttpAuthority? _http3Authority;
@@ -138,10 +138,9 @@ public HttpConnectionPool(HttpConnectionPoolManager poolManager, HttpConnectionK
_proxyUri = proxyUri;
_maxHttp11Connections = Settings._maxConnectionsPerServer;
- if (host != null)
- {
- _originAuthority = new HttpAuthority(host, port);
- }
+ // The only case where 'host' will not be set is if this is a Proxy connection pool.
+ Debug.Assert(host is not null || (kind == HttpConnectionKind.Proxy && proxyUri is not null));
+ _originAuthority = new HttpAuthority(host ?? proxyUri!.IdnHost, port);
_http2Enabled = _poolManager.Settings._maxHttpVersion >= HttpVersion.Version20;
@@ -233,7 +232,7 @@ public HttpConnectionPool(HttpConnectionPoolManager poolManager, HttpConnectionK
}
string? hostHeader = null;
- if (_originAuthority != null)
+ if (host is not null)
{
// Precalculate ASCII bytes for Host header
// Note that if _host is null, this is a (non-tunneled) proxy connection, and we can't cache the hostname.
@@ -356,7 +355,7 @@ private static SslClientAuthenticationOptions ConstructSslOptions(HttpConnection
return sslOptions;
}
- public HttpAuthority? OriginAuthority => _originAuthority;
+ public HttpAuthority OriginAuthority => _originAuthority;
public HttpConnectionSettings Settings => _poolManager.Settings;
public HttpConnectionKind Kind => _kind;
public bool IsSecure => _kind == HttpConnectionKind.Https || _kind == HttpConnectionKind.SslProxyTunnel || _kind == HttpConnectionKind.SslSocksTunnel;
@@ -379,7 +378,6 @@ public byte[] Http2AltSvcOriginUri
{
var sb = new StringBuilder();
- Debug.Assert(_originAuthority != null);
sb.Append(IsSecure ? "https://" : "http://")
.Append(_originAuthority.IdnHost);
@@ -956,7 +954,7 @@ private async ValueTask GetHttp3ConnectionAsync(HttpRequestMess
}
#endif
// if the authority was sent as an option through alt-svc then include alt-used header
- http3Connection = new Http3Connection(this, _originAuthority, authority, quicConnection, includeAltUsedHeader: _http3Authority == authority);
+ http3Connection = new Http3Connection(this, authority, quicConnection, includeAltUsedHeader: _http3Authority == authority);
_http3Connection = http3Connection;
if (NetEventSource.Log.IsEnabled())
@@ -1270,7 +1268,7 @@ internal void HandleAltSvc(IEnumerable altSvcHeaderValues, TimeSpan? res
if (nextAuthority == null && value != null && value.AlpnProtocolName == "h3")
{
- var authority = new HttpAuthority(value.Host ?? _originAuthority!.IdnHost, value.Port);
+ var authority = new HttpAuthority(value.Host ?? _originAuthority.IdnHost, value.Port);
if (IsAltSvcBlocked(authority, out _))
{
// Skip authorities in our blocklist.
@@ -1543,7 +1541,6 @@ public ValueTask SendAsync(HttpRequestMessage request, bool
case HttpConnectionKind.Http:
case HttpConnectionKind.Https:
case HttpConnectionKind.ProxyConnect:
- Debug.Assert(_originAuthority != null);
stream = await ConnectToTcpHostAsync(_originAuthority.IdnHost, _originAuthority.Port, request, async, cancellationToken).ConfigureAwait(false);
if (_kind == HttpConnectionKind.ProxyConnect && _sslOptionsProxy != null)
{
@@ -1753,8 +1750,6 @@ private async ValueTask ConstructHttp2ConnectionAsync(Stream st
private async ValueTask EstablishProxyTunnelAsync(bool async, CancellationToken cancellationToken)
{
- Debug.Assert(_originAuthority != null);
-
// Send a CONNECT request to the proxy server to establish a tunnel.
HttpRequestMessage tunnelRequest = new HttpRequestMessage(HttpMethod.Connect, _proxyUri);
tunnelRequest.Headers.Host = $"{_originAuthority.IdnHost}:{_originAuthority.Port}"; // This specifies destination host/port to connect to
@@ -1785,7 +1780,6 @@ private async ValueTask EstablishProxyTunnelAsync(bool async, Cancellati
private async ValueTask EstablishSocksTunnel(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
{
- Debug.Assert(_originAuthority != null);
Debug.Assert(_proxyUri != null);
Stream stream = await ConnectToTcpHostAsync(_proxyUri.IdnHost, _proxyUri.Port, request, async, cancellationToken).ConfigureAwait(false);
@@ -1958,6 +1952,7 @@ private void ReturnHttp11Connection(HttpConnection connection, bool isNewConnect
{
// Add connection to the pool.
added = true;
+ connection.MarkConnectionAsIdle();
_availableHttp11Connections.Add(connection);
}
@@ -2421,10 +2416,10 @@ public override string ToString() =>
(_proxyUri == null ?
(_sslOptionsHttp11 == null ?
$"http://{_originAuthority}" :
- $"https://{_originAuthority}" + (_sslOptionsHttp11.TargetHost != _originAuthority!.IdnHost ? $", SSL TargetHost={_sslOptionsHttp11.TargetHost}" : null)) :
+ $"https://{_originAuthority}" + (_sslOptionsHttp11.TargetHost != _originAuthority.IdnHost ? $", SSL TargetHost={_sslOptionsHttp11.TargetHost}" : null)) :
(_sslOptionsHttp11 == null ?
$"Proxy {_proxyUri}" :
- $"https://{_originAuthority}/ tunnelled via Proxy {_proxyUri}" + (_sslOptionsHttp11.TargetHost != _originAuthority!.IdnHost ? $", SSL TargetHost={_sslOptionsHttp11.TargetHost}" : null)));
+ $"https://{_originAuthority}/ tunnelled via Proxy {_proxyUri}" + (_sslOptionsHttp11.TargetHost != _originAuthority.IdnHost ? $", SSL TargetHost={_sslOptionsHttp11.TargetHost}" : null)));
private void Trace(string? message, [CallerMemberName] string? memberName = null) =>
NetEventSource.Log.HandlerMessage(
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs
index 07ea1bd2b61ad..52ffd655d4fa6 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs
@@ -9,6 +9,7 @@
using System.Threading.Tasks;
using System.Diagnostics;
using System.Diagnostics.Metrics;
+using System.Net.Http.Metrics;
namespace System.Net.Http
{
@@ -37,6 +38,7 @@ internal sealed class HttpConnectionSettings
internal TimeSpan _maxResponseDrainTime = HttpHandlerDefaults.DefaultResponseDrainTimeout;
internal int _maxResponseHeadersLength = HttpHandlerDefaults.DefaultMaxResponseHeadersLength;
internal IMeterFactory? _meterFactory;
+ internal SocketsHttpHandlerMetrics? _metrics;
internal TimeSpan _pooledConnectionLifetime = HttpHandlerDefaults.DefaultPooledConnectionLifetime;
internal TimeSpan _pooledConnectionIdleTimeout = HttpHandlerDefaults.DefaultPooledConnectionIdleTimeout;
@@ -104,6 +106,7 @@ public HttpConnectionSettings CloneAndNormalize()
_maxResponseDrainTime = _maxResponseDrainTime,
_maxResponseHeadersLength = _maxResponseHeadersLength,
_meterFactory = _meterFactory,
+ _metrics = _metrics,
_pooledConnectionLifetime = _pooledConnectionLifetime,
_pooledConnectionIdleTimeout = _pooledConnectionIdleTimeout,
_preAuthenticate = _preAuthenticate,
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/ConnectionMetrics.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/ConnectionMetrics.cs
new file mode 100644
index 0000000000000..d047f70766fd0
--- /dev/null
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/ConnectionMetrics.cs
@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+
+namespace System.Net.Http.Metrics
+{
+ internal sealed class ConnectionMetrics
+ {
+ private readonly SocketsHttpHandlerMetrics _metrics;
+ private readonly bool _currentConnectionsEnabled;
+ private readonly bool _idleConnectionsEnabled;
+ private readonly object _protocolTag;
+ private readonly object _schemeTag;
+ private readonly object _hostTag;
+ private readonly object? _portTag;
+ private bool _currentlyIdle;
+
+ public ConnectionMetrics(SocketsHttpHandlerMetrics metrics, string protocol, string scheme, string host, int? port)
+ {
+ _metrics = metrics;
+ _currentConnectionsEnabled = _metrics.CurrentConnections.Enabled;
+ _idleConnectionsEnabled = _metrics.IdleConnections.Enabled;
+ _protocolTag = protocol;
+ _schemeTag = scheme;
+ _hostTag = host;
+ _portTag = port;
+ }
+
+ // TagList is a huge struct, so we avoid storing it in a field to reduce the amount we allocate on the heap.
+ private TagList GetTags()
+ {
+ TagList tags = default;
+
+ tags.Add("protocol", _protocolTag);
+ tags.Add("scheme", _schemeTag);
+ tags.Add("host", _hostTag);
+
+ if (_portTag is not null)
+ {
+ tags.Add("port", _portTag);
+ }
+
+ return tags;
+ }
+
+ public void ConnectionEstablished()
+ {
+ if (_currentConnectionsEnabled)
+ {
+ _metrics.CurrentConnections.Add(1, GetTags());
+ }
+ }
+
+ public void ConnectionClosed(long durationMs)
+ {
+ MarkAsNotIdle();
+
+ if (_currentConnectionsEnabled)
+ {
+ _metrics.CurrentConnections.Add(-1, GetTags());
+ }
+
+ if (_metrics.ConnectionDuration.Enabled)
+ {
+ _metrics.ConnectionDuration.Record(durationMs / 1000d, GetTags());
+ }
+ }
+
+ public void MarkAsIdle()
+ {
+ if (_idleConnectionsEnabled && !_currentlyIdle)
+ {
+ _currentlyIdle = true;
+ _metrics.IdleConnections.Add(1, GetTags());
+ }
+ }
+
+ public void MarkAsNotIdle()
+ {
+ if (_idleConnectionsEnabled && _currentlyIdle)
+ {
+ _currentlyIdle = false;
+ _metrics.IdleConnections.Add(-1, GetTags());
+ }
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/SocketsHttpHandlerMetrics.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/SocketsHttpHandlerMetrics.cs
new file mode 100644
index 0000000000000..5db3b7692c2b0
--- /dev/null
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/SocketsHttpHandlerMetrics.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.Metrics;
+
+namespace System.Net.Http.Metrics
+{
+ internal sealed class SocketsHttpHandlerMetrics(Meter meter)
+ {
+ public readonly UpDownCounter CurrentConnections = meter.CreateUpDownCounter(
+ name: "http-client-current-connections",
+ description: "Number of outbound HTTP connections that are currently active on the client.");
+
+ public readonly UpDownCounter IdleConnections = meter.CreateUpDownCounter(
+ name: "http-client-current-idle-connections",
+ description: "Number of outbound HTTP connections that are currently idle on the client.");
+
+ public readonly Histogram ConnectionDuration = meter.CreateHistogram(
+ name: "http-client-connection-duration",
+ unit: "s",
+ description: "The duration of outbound HTTP connections.");
+ }
+}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs
index 4e71e396a5511..b6c69a046e809 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs
@@ -516,7 +516,9 @@ private HttpMessageHandlerStage SetupHandlerChain()
handler = new DiagnosticsHandler(handler, propagator, settings._allowAutoRedirect);
}
- handler = new MetricsHandler(handler, _settings._meterFactory);
+ handler = new MetricsHandler(handler, settings._meterFactory, out Meter meter);
+
+ settings._metrics = new SocketsHttpHandlerMetrics(meter);
if (settings._allowAutoRedirect)
{
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs
index 03690e79656ec..c27ce9c3ff481 100644
--- a/src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
@@ -24,6 +25,9 @@ protected static class InstrumentNames
public const string RequestDuration = "http-client-request-duration";
public const string CurrentRequests = "http-client-current-requests";
public const string FailedRequests = "http-client-failed-requests";
+ public const string CurrentConnections = "http-client-current-connections";
+ public const string IdleConnections = "http-client-current-idle-connections";
+ public const string ConnectionDuration = "http-client-connection-duration";
}
protected HttpMetricsTestBase(ITestOutputHelper output) : base(output)
@@ -42,67 +46,85 @@ protected static void VerifyOptionalTag(KeyValuePair[] tags,
}
}
- protected static void VerifyRequestDuration(Measurement measurement, Uri uri, string? protocol, int? statusCode, string method = "GET")
+ private static void VerifySchemeHostPortTags(KeyValuePair[] tags, Uri uri)
{
- Assert.True(measurement.Value > 0);
+ VerifyOptionalTag(tags, "scheme", uri.Scheme);
+ VerifyOptionalTag(tags, "host", uri.Host);
+ VerifyOptionalTag(tags, "port", uri.Port);
+ }
- string scheme = uri.Scheme;
- string host = uri.IdnHost;
- int? port = uri.Port;
- KeyValuePair[] tags = measurement.Tags.ToArray();
+ protected static void VerifyRequestDuration(Measurement measurement, Uri uri, string? protocol, int? statusCode, string method = "GET") =>
+ VerifyRequestDuration(InstrumentNames.RequestDuration, measurement.Value, measurement.Tags.ToArray(), uri, protocol, statusCode, method);
- Assert.Equal(scheme, tags.Single(t => t.Key == "scheme").Value);
- Assert.Equal(host, tags.Single(t => t.Key == "host").Value);
- Assert.Equal(method, tags.Single(t => t.Key == "method").Value);
- VerifyOptionalTag(tags, "port", port);
+ protected static void VerifyRequestDuration(string instrumentName, double measurement, KeyValuePair[] tags, Uri uri, string? protocol, int? statusCode, string method = "GET")
+ {
+ Assert.Equal(InstrumentNames.RequestDuration, instrumentName);
+ Assert.InRange(measurement, double.Epsilon, 60);
+ VerifySchemeHostPortTags(tags, uri);
+ VerifyOptionalTag(tags, "method", method);
VerifyOptionalTag(tags, "protocol", protocol);
VerifyOptionalTag(tags, "status-code", statusCode);
}
- protected static void VerifyCurrentRequest(Measurement measurement, long expectedValue, Uri uri)
- {
- Assert.Equal(expectedValue, measurement.Value);
+ protected static void VerifyCurrentRequest(Measurement measurement, long expectedValue, Uri uri) =>
+ VerifyCurrentRequest(InstrumentNames.CurrentRequests, measurement.Value, measurement.Tags.ToArray(), expectedValue, uri);
- string scheme = uri.Scheme;
- string host = uri.Host;
- int? port = uri.Port;
- KeyValuePair[] tags = measurement.Tags.ToArray();
-
- Assert.Equal(scheme, tags.Single(t => t.Key == "scheme").Value);
- Assert.Equal(host, tags.Single(t => t.Key == "host").Value);
- VerifyOptionalTag(tags, "port", port);
+ protected static void VerifyCurrentRequest(string instrumentName, long measurement, KeyValuePair[] tags, long expectedValue, Uri uri)
+ {
+ Assert.Equal(InstrumentNames.CurrentRequests, instrumentName);
+ Assert.Equal(expectedValue, measurement);
+ VerifySchemeHostPortTags(tags, uri);
}
protected static void VerifyFailedRequests(Measurement measurement, long expectedValue, Uri uri, string? protocol, int? statusCode, string method = "GET")
{
Assert.Equal(expectedValue, measurement.Value);
- string scheme = uri.Scheme;
- string host = uri.IdnHost;
- int? port = uri.Port;
KeyValuePair[] tags = measurement.Tags.ToArray();
- Assert.Equal(scheme, tags.Single(t => t.Key == "scheme").Value);
- Assert.Equal(host, tags.Single(t => t.Key == "host").Value);
+ VerifySchemeHostPortTags(tags, uri);
+
Assert.Equal(method, tags.Single(t => t.Key == "method").Value);
- VerifyOptionalTag(tags, "port", port);
VerifyOptionalTag(tags, "protocol", protocol);
VerifyOptionalTag(tags, "status-code", statusCode);
}
+ protected static void VerifyConnectionCounter(string expectedName, string actualName, object measurement, KeyValuePair[] tags, long expectedValue, Uri uri, string protocol)
+ {
+ Assert.Equal(expectedName, actualName);
+ Assert.Equal(expectedValue, Assert.IsType(measurement));
+ VerifySchemeHostPortTags(tags, uri);
+ VerifyOptionalTag(tags, "protocol", protocol);
+ }
+
+ protected static void VerifyConnectionDuration(string instrumentName, double measurement, KeyValuePair[] tags, Uri uri, string protocol)
+ {
+ Assert.InRange(measurement, double.Epsilon, 60);
+ Assert.Equal(InstrumentNames.ConnectionDuration, instrumentName);
+ VerifySchemeHostPortTags(tags, uri);
+ VerifyOptionalTag(tags, "protocol", protocol);
+ }
+
+ protected static async Task WaitForEnvironmentTicksToAdvance()
+ {
+ long start = Environment.TickCount64;
+ while (Environment.TickCount64 == start)
+ {
+ await Task.Delay(1);
+ }
+ }
+
protected sealed class InstrumentRecorder : IDisposable where T : struct
{
- private readonly string _instrumentName;
- private readonly MeterListener _meterListener = new MeterListener();
- private readonly List> _values = new List>();
+ private readonly MeterListener _meterListener = new();
+ private readonly ConcurrentQueue> _values = new();
private Meter? _meter;
public InstrumentRecorder(string instrumentName)
{
- _instrumentName = instrumentName;
_meterListener.InstrumentPublished = (instrument, listener) =>
{
- if (instrument.Meter.Name == "System.Net.Http" && instrument.Name == _instrumentName)
+ if (instrument.Meter.Name == "System.Net.Http" && instrument.Name == instrumentName)
{
listener.EnableMeasurementEvents(instrument);
}
@@ -114,10 +136,9 @@ public InstrumentRecorder(string instrumentName)
public InstrumentRecorder(IMeterFactory meterFactory, string instrumentName)
{
_meter = meterFactory.Create("System.Net.Http");
- _instrumentName = instrumentName;
_meterListener.InstrumentPublished = (instrument, listener) =>
{
- if (instrument.Meter == _meter && instrument.Name == _instrumentName)
+ if (instrument.Meter == _meter && instrument.Name == instrumentName)
{
listener.EnableMeasurementEvents(instrument);
}
@@ -126,10 +147,46 @@ public InstrumentRecorder(IMeterFactory meterFactory, string instrumentName)
_meterListener.Start();
}
- private void OnMeasurementRecorded(Instrument instrument, T measurement, ReadOnlySpan> tags, object? state) => _values.Add(new Measurement(measurement, tags));
+ private void OnMeasurementRecorded(Instrument instrument, T measurement, ReadOnlySpan> tags, object? state) => _values.Enqueue(new Measurement(measurement, tags));
public IReadOnlyList> GetMeasurements() => _values.ToArray();
public void Dispose() => _meterListener.Dispose();
}
+
+ protected sealed class MultiInstrumentRecorder : IDisposable
+ {
+ private readonly MeterListener _meterListener = new();
+ private readonly ConcurrentQueue<(string InstrumentName, object Value, KeyValuePair[] Tags)> _values = new();
+
+ public MultiInstrumentRecorder()
+ : this(meter: null)
+ { }
+
+ public MultiInstrumentRecorder(IMeterFactory meterFactory)
+ : this(meterFactory.Create("System.Net.Http"))
+ { }
+
+ private MultiInstrumentRecorder(Meter? meter)
+ {
+ _meterListener.InstrumentPublished = (instrument, listener) =>
+ {
+ if (instrument.Meter == meter || (meter is null && instrument.Meter.Name == "System.Net.Http"))
+ {
+ listener.EnableMeasurementEvents(instrument);
+ }
+ };
+
+ _meterListener.SetMeasurementEventCallback((instrument, measurement, tags, _) =>
+ _values.Enqueue((instrument.Name, measurement, tags.ToArray())));
+
+ _meterListener.SetMeasurementEventCallback((instrument, measurement, tags, _) =>
+ _values.Enqueue((instrument.Name, measurement, tags.ToArray())));
+
+ _meterListener.Start();
+ }
+
+ public IReadOnlyList<(string InstrumentName, object Value, KeyValuePair[] Tags)> GetMeasurements() => _values.ToArray();
+ public void Dispose() => _meterListener.Dispose();
+ }
}
public abstract class HttpMetricsTest : HttpMetricsTestBase
@@ -392,6 +449,49 @@ public Task CurrentRequests_Redirect_RecordedForEachHttpSpan()
});
}
+ [ConditionalFact(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))]
+ public async Task AllSocketsHttpHandlerCounters_Success_Recorded()
+ {
+ TaskCompletionSource clientDisposedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
+ {
+ using MultiInstrumentRecorder recorder = new(_meterFactory);
+
+ using (HttpMessageInvoker invoker = CreateHttpMessageInvoker())
+ {
+ Handler.MeterFactory = _meterFactory;
+
+ using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion };
+ using HttpResponseMessage response = await SendAsync(invoker, request);
+ await WaitForEnvironmentTicksToAdvance();
+ }
+
+ clientDisposedTcs.SetResult();
+
+ Assert.Collection(recorder.GetMeasurements(),
+ m => VerifyCurrentRequest(m.InstrumentName, (long)m.Value, m.Tags, 1, uri),
+ m => VerifyConnectionCounter(InstrumentNames.CurrentConnections, m.InstrumentName, m.Value, m.Tags, 1, uri, ExpectedProtocolString),
+ m => VerifyConnectionCounter(InstrumentNames.IdleConnections, m.InstrumentName, m.Value, m.Tags, 1, uri, ExpectedProtocolString),
+ m => VerifyConnectionCounter(InstrumentNames.IdleConnections, m.InstrumentName, m.Value, m.Tags, -1, uri, ExpectedProtocolString),
+ m => VerifyConnectionCounter(InstrumentNames.IdleConnections, m.InstrumentName, m.Value, m.Tags, 1, uri, ExpectedProtocolString),
+ m => VerifyCurrentRequest(m.InstrumentName, (long)m.Value, m.Tags, -1, uri),
+ m => VerifyRequestDuration(m.InstrumentName, (double)m.Value, m.Tags, uri, ExpectedProtocolString, 200),
+ m => VerifyConnectionCounter(InstrumentNames.IdleConnections, m.InstrumentName, m.Value, m.Tags, -1, uri, ExpectedProtocolString),
+ m => VerifyConnectionCounter(InstrumentNames.CurrentConnections, m.InstrumentName, m.Value, m.Tags, -1, uri, ExpectedProtocolString),
+ m => VerifyConnectionDuration(m.InstrumentName, (double)m.Value, m.Tags, uri, ExpectedProtocolString));
+ },
+ async server =>
+ {
+ await server.AcceptConnectionAsync(async connection =>
+ {
+ await connection.ReadRequestDataAsync();
+ await connection.SendResponseAsync();
+ await clientDisposedTcs.Task.WaitAsync(TestHelper.PassingTestTimeout);
+ });
+ });
+ }
+
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -738,6 +838,49 @@ await test.LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
}).Dispose();
}
+ public static bool RemoteExecutorAndSocketsHttpHandlerSupported => RemoteExecutor.IsSupported && SocketsHttpHandler.IsSupported;
+
+ [ConditionalFact(nameof(RemoteExecutorAndSocketsHttpHandlerSupported))]
+ public void AllSocketsHttpHandlerCounters_Success_Recorded()
+ {
+ RemoteExecutor.Invoke(static async Task () =>
+ {
+ using HttpMetricsTest_DefaultMeter test = new(null);
+ await test.LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
+ {
+ using MultiInstrumentRecorder recorder = new();
+
+ using (HttpClient client = test.CreateHttpClient())
+ {
+ using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = test.UseVersion };
+ using HttpResponseMessage response = await client.SendAsync(request);
+ await WaitForEnvironmentTicksToAdvance();
+ }
+
+ Assert.Collection(recorder.GetMeasurements(),
+ m => VerifyCurrentRequest(m.InstrumentName, (long)m.Value, m.Tags, 1, uri),
+ m => VerifyConnectionCounter(InstrumentNames.CurrentConnections, m.InstrumentName, m.Value, m.Tags, 1, uri, "HTTP/1.1"),
+ m => VerifyConnectionCounter(InstrumentNames.IdleConnections, m.InstrumentName, m.Value, m.Tags, 1, uri, "HTTP/1.1"),
+ m => VerifyConnectionCounter(InstrumentNames.IdleConnections, m.InstrumentName, m.Value, m.Tags, -1, uri, "HTTP/1.1"),
+ m => VerifyConnectionCounter(InstrumentNames.IdleConnections, m.InstrumentName, m.Value, m.Tags, 1, uri, "HTTP/1.1"),
+ m => VerifyCurrentRequest(m.InstrumentName, (long)m.Value, m.Tags, -1, uri),
+ m => VerifyRequestDuration(m.InstrumentName, (double)m.Value, m.Tags, uri, "HTTP/1.1", 200),
+ m => VerifyConnectionCounter(InstrumentNames.IdleConnections, m.InstrumentName, m.Value, m.Tags, -1, uri, "HTTP/1.1"),
+ m => VerifyConnectionCounter(InstrumentNames.CurrentConnections, m.InstrumentName, m.Value, m.Tags, -1, uri, "HTTP/1.1"),
+ m => VerifyConnectionDuration(m.InstrumentName, (double)m.Value, m.Tags, uri, "HTTP/1.1"));
+ },
+ async server =>
+ {
+ await server.AcceptConnectionAsync(async connection =>
+ {
+ await connection.ReadRequestDataAsync();
+ await connection.SendResponseAsync(isFinal: false);
+ await connection.WaitForCloseAsync(CancellationToken.None);
+ });
+ });
+ }).Dispose();
+ }
+
[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
public void RequestDuration_Success_Recorded()
{