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() {