diff --git a/src/libraries/System.Net.Http/ref/System.Net.Http.cs b/src/libraries/System.Net.Http/ref/System.Net.Http.cs index 46d2b57abfc78..ed341d31d8c0d 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -135,6 +135,8 @@ public HttpClientHandler() { } public long MaxRequestContentBufferSize { get { throw null; } set { } } [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")] public int MaxResponseHeadersLength { get { throw null; } set { } } + [System.CLSCompliantAttribute(false)] + public System.Diagnostics.Metrics.IMeterFactory? MeterFactory { get { throw null; } set { } } [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")] public bool PreAuthenticate { get { throw null; } set { } } public System.Collections.Generic.IDictionary Properties { get { throw null; } } @@ -397,6 +399,8 @@ public SocketsHttpHandler() { } public int MaxConnectionsPerServer { get { throw null; } set { } } public int MaxResponseDrainSize { get { throw null; } set { } } public int MaxResponseHeadersLength { get { throw null; } set { } } + [System.CLSCompliantAttribute(false)] + public System.Diagnostics.Metrics.IMeterFactory? MeterFactory { get { throw null; } set { } } public System.TimeSpan PooledConnectionIdleTimeout { get { throw null; } set { } } public System.TimeSpan PooledConnectionLifetime { get { throw null; } set { } } public bool PreAuthenticate { get { throw null; } set { } } @@ -901,3 +905,15 @@ public WarningHeaderValue(int code, string agent, string text, System.DateTimeOf public static bool TryParse([System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(true)] string? input, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(true)] out System.Net.Http.Headers.WarningHeaderValue? parsedValue) { throw null; } } } +namespace System.Net.Http.Metrics +{ + public sealed class HttpMetricsEnrichmentContext + { + internal HttpMetricsEnrichmentContext() { } + public System.Net.Http.HttpRequestMessage Request { get { throw null; } } + public System.Net.Http.HttpResponseMessage? Response { get { throw null; } } + public System.Exception? Exception { get { throw null; } } + public void AddCustomTag(string name, object? value) { throw null; } + public static void AddCallback(System.Net.Http.HttpRequestMessage request, System.Action callback) { throw null; } + } +} 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 3858acc73b5ec..cb3622d067a77 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -122,6 +122,8 @@ + + @@ -227,7 +229,7 @@ Link="Common\System\Net\DebugSafeHandleZeroOrMinusOneIsInvalid.cs" /> - + Common\System\Net\Http\aspnetcore\IHttpStreamHeadersHandler.cs @@ -471,4 +473,4 @@ - + \ No newline at end of file diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/BrowserHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/BrowserHttpHandler.cs index 8b9919906076e..ed8abc2a1905a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/BrowserHttpHandler.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/BrowserHttpHandler.cs @@ -289,7 +289,6 @@ private static HttpResponseMessage ConvertResponse(HttpRequestMessage request, W protected internal override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(request); bool? allowAutoRedirect = _isAllowAutoRedirectTouched ? AllowAutoRedirect : null; #if FEATURE_WASM_THREADS return JSHost.CurrentOrMainJSSynchronizationContext.Send(() => diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs index 1cd0dbdb7a645..9afd16e0d051b 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using System.Diagnostics.CodeAnalysis; using System.Diagnostics; +using System.Diagnostics.Metrics; namespace System.Net.Http { @@ -91,6 +92,13 @@ public int MaxResponseDrainSize set => throw new PlatformNotSupportedException(); } + [CLSCompliant(false)] + public IMeterFactory? MeterFactory + { + get => throw new PlatformNotSupportedException(); + set => throw new PlatformNotSupportedException(); + } + public TimeSpan ResponseDrainTimeout { get => throw new PlatformNotSupportedException(); diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs index fd9d9420f7a22..6436c1595b46e 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs @@ -78,7 +78,6 @@ internal override ValueTask SendAsync(HttpRequestMessage re { if (IsEnabled()) { - ArgumentNullException.ThrowIfNull(request); return SendAsyncCore(request, async, cancellationToken); } else diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.AnyMobile.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.AnyMobile.cs index be3f5170968dc..c948e70218c95 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.AnyMobile.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.AnyMobile.cs @@ -1,12 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; using System.Globalization; +using System.Net.Http.Metrics; using System.Net.Security; using System.Reflection; using System.Runtime.ExceptionServices; @@ -21,37 +21,28 @@ namespace System.Net.Http public partial class HttpClientHandler : HttpMessageHandler { private readonly SocketsHttpHandler? _socketHandler; - private readonly DiagnosticsHandler? _diagnosticsHandler; - private readonly HttpMessageHandler? _nativeHandler; + private MetricsHandler? _metricsHandler; private static readonly ConcurrentDictionary s_cachedMethods = new ConcurrentDictionary(); + private IMeterFactory? _meterFactory; private ClientCertificateOption _clientCertificateOptions; private volatile bool _disposed; public HttpClientHandler() { - HttpMessageHandler handler; - if (IsNativeHandlerEnabled) { _nativeHandler = CreateNativeHandler(); - handler = _nativeHandler; } else { _socketHandler = new SocketsHttpHandler(); - handler = _socketHandler; ClientCertificateOptions = ClientCertificateOption.Manual; } - - if (DiagnosticsHandler.IsGloballyEnabled()) - { - _diagnosticsHandler = new DiagnosticsHandler(handler, DistributedContextPropagator.Current); - } } protected override void Dispose(bool disposing) @@ -73,6 +64,21 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + [CLSCompliant(false)] + public IMeterFactory? MeterFactory + { + get => _meterFactory; + set + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_metricsHandler != null) + { + throw new InvalidOperationException(SR.net_http_operation_started); + } + _meterFactory = value; + } + } + [UnsupportedOSPlatform("browser")] public bool UseCookies { @@ -713,19 +719,9 @@ protected internal override HttpResponseMessage Send(HttpRequestMessage request, protected internal override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (DiagnosticsHandler.IsGloballyEnabled() && _diagnosticsHandler != null) - { - return _diagnosticsHandler!.SendAsync(request, cancellationToken); - } - - if (IsNativeHandlerEnabled) - { - return _nativeHandler!.SendAsync(request, cancellationToken); - } - else - { - return _socketHandler!.SendAsync(request, cancellationToken); - } + ArgumentNullException.ThrowIfNull(request); + MetricsHandler handler = _metricsHandler ?? SetupHandlerChain(); + return handler.SendAsync(request, cancellationToken); } // lazy-load the validator func so it can be trimmed by the ILLinker if it isn't used. @@ -741,6 +737,23 @@ protected internal override Task SendAsync(HttpRequestMessa } } + private MetricsHandler SetupHandlerChain() + { + HttpMessageHandler handler = IsNativeHandlerEnabled ? _nativeHandler! : _socketHandler!; + if (DiagnosticsHandler.IsGloballyEnabled()) + { + handler = new DiagnosticsHandler(handler, DistributedContextPropagator.Current); + } + MetricsHandler metricsHandler = new MetricsHandler(handler, _meterFactory); + + // Ensure a single handler is used for all requests. + if (Interlocked.CompareExchange(ref _metricsHandler, metricsHandler, null) != null) + { + handler.Dispose(); + } + return _metricsHandler; + } + private void ThrowForModifiedManagedSslOptionsIfStarted() { // Hack to trigger an InvalidOperationException if a property that's stored on diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.cs index cd88e7930918d..97e1923e52751 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.cs @@ -9,8 +9,10 @@ using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; -using System.Diagnostics; +using System.Diagnostics.Metrics; #if TARGET_BROWSER +using System.Diagnostics; +using System.Net.Http.Metrics; using HttpHandlerType = System.Net.Http.BrowserHttpHandler; #else using HttpHandlerType = System.Net.Http.SocketsHttpHandler; @@ -23,7 +25,33 @@ public partial class HttpClientHandler : HttpMessageHandler private readonly HttpHandlerType _underlyingHandler; #if TARGET_BROWSER - private HttpMessageHandler Handler { get; } + private IMeterFactory? _meterFactory; + private MetricsHandler? _metricsHandler; + + private MetricsHandler Handler + { + get + { + if (_metricsHandler != null) + { + return _metricsHandler; + } + + HttpMessageHandler handler = _underlyingHandler; + if (DiagnosticsHandler.IsGloballyEnabled()) + { + handler = new DiagnosticsHandler(handler, DistributedContextPropagator.Current); + } + MetricsHandler metricsHandler = new MetricsHandler(handler, _meterFactory); + + // Ensure a single handler is used for all requests. + if (Interlocked.CompareExchange(ref _metricsHandler, metricsHandler, null) != null) + { + metricsHandler.Dispose(); + } + return _metricsHandler; + } + } #else private HttpHandlerType Handler => _underlyingHandler; #endif @@ -34,14 +62,6 @@ public HttpClientHandler() { _underlyingHandler = new HttpHandlerType(); -#if TARGET_BROWSER - Handler = _underlyingHandler; - if (DiagnosticsHandler.IsGloballyEnabled()) - { - Handler = new DiagnosticsHandler(Handler, DistributedContextPropagator.Current); - } -#endif - ClientCertificateOptions = ClientCertificateOption.Manual; } @@ -60,6 +80,33 @@ protected override void Dispose(bool disposing) public virtual bool SupportsProxy => HttpHandlerType.SupportsProxy; public virtual bool SupportsRedirectConfiguration => HttpHandlerType.SupportsRedirectConfiguration; + /// + /// Gets or sets the to create a custom for the instance. + /// + /// + /// When is set to a non- value, all metrics emitted by the instance + /// will be recorded using the provided by the . + /// + [CLSCompliant(false)] + public IMeterFactory? MeterFactory + { +#if TARGET_BROWSER + get => _meterFactory; + set + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_metricsHandler != null) + { + throw new InvalidOperationException(SR.net_http_operation_started); + } + _meterFactory = value; + } +#else + get => _underlyingHandler.MeterFactory; + set => _underlyingHandler.MeterFactory = value; +#endif + } + [UnsupportedOSPlatform("browser")] public bool UseCookies { @@ -296,11 +343,21 @@ public SslProtocols SslProtocols [UnsupportedOSPlatform("browser")] //[UnsupportedOSPlatform("ios")] //[UnsupportedOSPlatform("tvos")] - protected internal override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => - Handler.Send(request, cancellationToken); + protected internal override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { +#if TARGET_BROWSER + throw new PlatformNotSupportedException(); +#else + ArgumentNullException.ThrowIfNull(request); + return Handler.Send(request, cancellationToken); +#endif + } - protected internal override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => - Handler.SendAsync(request, cancellationToken); + protected internal override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + return Handler.SendAsync(request, cancellationToken); + } // lazy-load the validator func so it can be trimmed by the ILLinker if it isn't used. private static Func? s_dangerousAcceptAnyServerCertificateValidator; diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpMessageInvoker.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpMessageInvoker.cs index ceb93c06a2aae..c9e55048c802a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpMessageInvoker.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpMessageInvoker.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO; -using System.Net.Http.Headers; using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; @@ -124,7 +122,6 @@ protected virtual void Dispose(bool disposing) if (disposing && !_disposed) { _disposed = true; - if (_disposeHandler) { _handler.Dispose(); diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs index 566d5ac46f562..691db4059a248 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs @@ -30,7 +30,7 @@ public class HttpRequestMessage : IDisposable private Version _version; private HttpVersionPolicy _versionPolicy; private HttpContent? _content; - private HttpRequestOptions? _options; + internal HttpRequestOptions? _options; public Version Version { diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/Metrics/HttpMetricsEnrichmentContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/Metrics/HttpMetricsEnrichmentContext.cs new file mode 100644 index 0000000000000..6ab0e14747468 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/Metrics/HttpMetricsEnrichmentContext.cs @@ -0,0 +1,173 @@ +// 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; +using System.Runtime.InteropServices; +using System.Threading; + +namespace System.Net.Http.Metrics +{ + /// + /// Provides functionality for enriching request metrics `http-client-request-duration` and `http-client-failed-requests`. + /// + /// + /// Enrichment is done on per-request basis by callbacks registered with . + /// The callbacks are responsible for adding custom tags via for which they can use the request, response and error + /// information exposed on the instance. + /// + /// > [!IMPORTANT] + /// > The intance must not be used from outside of the enrichment callbacks. + /// + public sealed class HttpMetricsEnrichmentContext + { + private static readonly HttpRequestOptionsKey s_optionsKeyForContext = new(nameof(HttpMetricsEnrichmentContext)); + private static readonly ConcurrentQueue s_pool = new(); + private static int s_poolItemCount; + private const int PoolCapacity = 1024; + + private readonly List> _callbacks = new(); + private HttpRequestMessage? _request; + private HttpResponseMessage? _response; + private Exception? _exception; + private List> _tags = new(capacity: 16); + + internal HttpMetricsEnrichmentContext() { } // Hide the default parameterless constructor. + + /// + /// Gets the that has been sent. + /// + /// + /// This property must not be used from outside of the enrichment callbacks. + /// + public HttpRequestMessage Request => _request!; + + /// + /// Gets the received from the server or if the request failed. + /// + /// + /// This property must not be used from outside of the enrichment callbacks. + /// + public HttpResponseMessage? Response => _response; + + /// + /// Gets the exception that occured or if there was no error. + /// + /// + /// This property must not be used from outside of the enrichment callbacks. + /// + public Exception? Exception => _exception; + + /// + /// Appends a custom tag to the list of tags to be recorded with the request metrics `http-client-request-duration` and `http-client-failed-requests`. + /// + /// The name of the tag. + /// The value of the tag. + /// + /// This method must not be used from outside of the enrichment callbacks. + /// + public void AddCustomTag(string name, object? value) => _tags.Add(new KeyValuePair(name, value)); + + /// + /// Adds a callback to register custom tags for request metrics `http-client-request-duration` and `http-client-failed-requests`. + /// + /// The to apply enrichment to. + /// The callback responsible to add custom tags via . + public static void AddCallback(HttpRequestMessage request, Action callback) + { + HttpRequestOptions options = request.Options; + + // We associate an HttpMetricsEnrichmentContext with the request on the first call to AddCallback(), + // and store the callbacks in the context. This allows us to cache all the enrichment objects together. + if (!options.TryGetValue(s_optionsKeyForContext, out HttpMetricsEnrichmentContext? context)) + { + if (s_pool.TryDequeue(out context)) + { + Debug.Assert(context._callbacks.Count == 0); + Interlocked.Decrement(ref s_poolItemCount); + } + else + { + context = new HttpMetricsEnrichmentContext(); + } + + options.Set(s_optionsKeyForContext, context); + } + context._callbacks.Add(callback); + } + + internal static HttpMetricsEnrichmentContext? GetEnrichmentContextForRequest(HttpRequestMessage request) + { + if (request._options is null) + { + return null; + } + request._options.TryGetValue(s_optionsKeyForContext, out HttpMetricsEnrichmentContext? context); + return context; + } + + internal void RecordWithEnrichment(HttpRequestMessage request, + HttpResponseMessage? response, + Exception? exception, + long startTimestamp, + in TagList commonTags, + bool recordRequestDuration, + bool recordFailedRequests, + Histogram requestDuration, + Counter failedRequests) + { + _request = request; + _response = response; + _exception = exception; + + Debug.Assert(_tags.Count == 0); + + // Adding the enrichment tags to the TagList would likely exceed its' on-stack capacity, leading to an allocation. + // To avoid this, we add all the tags to a List which is cached together with HttpMetricsEnrichmentContext. + // Use a for loop to iterate over the TagList, since TagList.GetEnumerator() allocates, see + // https://github.com/dotnet/runtime/issues/87022. + for (int i = 0; i < commonTags.Count; i++) + { + _tags.Add(commonTags[i]); + } + + try + { + foreach (Action callback in _callbacks) + { + callback(this); + } + + if (recordRequestDuration) + { + TimeSpan duration = Stopwatch.GetElapsedTime(startTimestamp, Stopwatch.GetTimestamp()); + requestDuration.Record(duration.TotalSeconds, CollectionsMarshal.AsSpan(_tags)); + } + + if (recordFailedRequests) + { + failedRequests.Add(1, CollectionsMarshal.AsSpan(_tags)); + } + } + finally + { + _request = null; + _response = null; + _exception = null; + _callbacks.Clear(); + _tags.Clear(); + + if (Interlocked.Increment(ref s_poolItemCount) <= PoolCapacity) + { + s_pool.Enqueue(this); + } + else + { + Interlocked.Decrement(ref s_poolItemCount); + } + } + } + } +} diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/Metrics/MetricsHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/Metrics/MetricsHandler.cs new file mode 100644 index 0000000000000..d7d5ec19c1c7e --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/Metrics/MetricsHandler.cs @@ -0,0 +1,193 @@ +// 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; +using System.Diagnostics.Metrics; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http.Metrics +{ + internal sealed class MetricsHandler : HttpMessageHandlerStage + { + private readonly HttpMessageHandler _innerHandler; + private readonly UpDownCounter _currentRequests; + private readonly Counter _failedRequests; + private readonly Histogram _requestsDuration; + + public MetricsHandler(HttpMessageHandler innerHandler, IMeterFactory? meterFactory) + { + _innerHandler = innerHandler; + + Meter meter = meterFactory?.Create("System.Net.Http") ?? SharedMeter.Instance; + + // Meter has a cache for the instruments it owns + _currentRequests = meter.CreateUpDownCounter( + "http-client-current-requests", + description: "Number of outbound HTTP requests that are currently active on the client."); + _failedRequests = meter.CreateCounter( + "http-client-failed-requests", + description: "Number of outbound HTTP requests that have failed."); + _requestsDuration = meter.CreateHistogram( + "http-client-request-duration", + unit: "s", + description: "The duration of outbound HTTP requests."); + } + + internal override ValueTask SendAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken) + { + if (_currentRequests.Enabled || _failedRequests.Enabled || _requestsDuration.Enabled) + { + return SendAsyncWithMetrics(request, async, cancellationToken); + } + else + { + return async ? + new ValueTask(_innerHandler.SendAsync(request, cancellationToken)) : + new ValueTask(_innerHandler.Send(request, cancellationToken)); + } + } + + private async ValueTask SendAsyncWithMetrics(HttpRequestMessage request, bool async, CancellationToken cancellationToken) + { + (long startTimestamp, bool recordCurrentRequests) = RequestStart(request); + HttpResponseMessage? response = null; + Exception? exception = null; + try + { + response = async ? + await _innerHandler.SendAsync(request, cancellationToken).ConfigureAwait(false) : + _innerHandler.Send(request, cancellationToken); + return response; + } + catch (Exception ex) + { + exception = ex; + throw; + } + finally + { + RequestStop(request, response, exception, startTimestamp, recordCurrentRequests); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _innerHandler.Dispose(); + } + + base.Dispose(disposing); + } + + private (long StartTimestamp, bool RecordCurrentRequests) RequestStart(HttpRequestMessage request) + { + bool recordCurrentRequests = _currentRequests.Enabled; + long startTimestamp = Stopwatch.GetTimestamp(); + + if (recordCurrentRequests) + { + TagList tags = InitializeCommonTags(request); + _currentRequests.Add(1, tags); + } + + return (startTimestamp, recordCurrentRequests); + } + + private void RequestStop(HttpRequestMessage request, HttpResponseMessage? response, Exception? exception, long startTimestamp, bool recordCurrentRequsts) + { + TagList tags = InitializeCommonTags(request); + + if (recordCurrentRequsts) + { + _currentRequests.Add(-1, tags); + } + + bool recordRequestDuration = _requestsDuration.Enabled; + bool recordFailedRequests = _failedRequests.Enabled && response is null; + + HttpMetricsEnrichmentContext? enrichmentContext = null; + if (recordRequestDuration || recordFailedRequests) + { + if (response is not null) + { + tags.Add("status-code", GetBoxedStatusCode((int)response.StatusCode)); + tags.Add("protocol", GetProtocolName(response.Version)); + } + enrichmentContext = HttpMetricsEnrichmentContext.GetEnrichmentContextForRequest(request); + } + + if (enrichmentContext is null) + { + if (recordRequestDuration) + { + TimeSpan duration = Stopwatch.GetElapsedTime(startTimestamp, Stopwatch.GetTimestamp()); + _requestsDuration.Record(duration.TotalSeconds, tags); + } + + if (recordFailedRequests) + { + _failedRequests.Add(1, tags); + } + } + else + { + enrichmentContext.RecordWithEnrichment(request, response, exception, startTimestamp, tags, recordRequestDuration, recordFailedRequests, _requestsDuration, _failedRequests); + } + } + + private static string GetProtocolName(Version httpVersion) => (httpVersion.Major, httpVersion.Minor) switch + { + (1, 0) => "HTTP/1.0", + (1, 1) => "HTTP/1.1", + (2, 0) => "HTTP/2", + (3, 0) => "HTTP/3", + _ => $"HTTP/{httpVersion.Major}.{httpVersion.Minor}" + }; + + private static TagList InitializeCommonTags(HttpRequestMessage request) + { + TagList tags = default; + + if (request.RequestUri is Uri requestUri && requestUri.IsAbsoluteUri) + { + tags.Add("scheme", requestUri.Scheme); + tags.Add("host", requestUri.Host); + // Add port tag when not the default value for the current scheme + if (!requestUri.IsDefaultPort) + { + tags.Add("port", requestUri.Port); + } + } + tags.Add("method", request.Method.Method); + + return tags; + } + + private static object[]? s_boxedStatusCodes; + + private static object GetBoxedStatusCode(int statusCode) + { + object[] boxes = LazyInitializer.EnsureInitialized(ref s_boxedStatusCodes, static () => new object[512]); + + return (uint)statusCode < (uint)boxes.Length + ? boxes[statusCode] ??= statusCode + : statusCode; + } + + private sealed class SharedMeter : Meter + { + public static Meter Instance { get; } = new SharedMeter(); + private SharedMeter() + : base("System.Net.Http") + { + } + + protected override void Dispose(bool disposing) + { + // NOP to prevent disposing the global instance from MeterListener callbacks. + } + } + } +} 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 eaedf244ce4d4..07ea1bd2b61ad 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 @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Diagnostics; +using System.Diagnostics.Metrics; namespace System.Net.Http { @@ -35,6 +36,7 @@ internal sealed class HttpConnectionSettings internal int _maxResponseDrainSize = HttpHandlerDefaults.DefaultMaxResponseDrainSize; internal TimeSpan _maxResponseDrainTime = HttpHandlerDefaults.DefaultResponseDrainTimeout; internal int _maxResponseHeadersLength = HttpHandlerDefaults.DefaultMaxResponseHeadersLength; + internal IMeterFactory? _meterFactory; internal TimeSpan _pooledConnectionLifetime = HttpHandlerDefaults.DefaultPooledConnectionLifetime; internal TimeSpan _pooledConnectionIdleTimeout = HttpHandlerDefaults.DefaultPooledConnectionIdleTimeout; @@ -101,6 +103,7 @@ public HttpConnectionSettings CloneAndNormalize() _maxResponseDrainSize = _maxResponseDrainSize, _maxResponseDrainTime = _maxResponseDrainTime, _maxResponseHeadersLength = _maxResponseHeadersLength, + _meterFactory = _meterFactory, _pooledConnectionLifetime = _pooledConnectionLifetime, _pooledConnectionIdleTimeout = _pooledConnectionIdleTimeout, _preAuthenticate = _preAuthenticate, 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 eae4cb5245717..4e71e396a5511 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 @@ -10,6 +10,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text; using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Net.Http.Metrics; namespace System.Net.Http { @@ -450,6 +452,24 @@ public DistributedContextPropagator? ActivityHeadersPropagator } } + /// + /// Gets or sets the to create a custom for the instance. + /// + /// + /// When is set to a non- value, all metrics emitted by the instance + /// will be recorded using the provided by the . + /// + [CLSCompliant(false)] + public IMeterFactory? MeterFactory + { + get => _settings._meterFactory; + set + { + CheckDisposedOrStarted(); + _settings._meterFactory = value; + } + } + internal ClientCertificateOption ClientCertificateOptions { get => _settings._clientCertificateOptions; @@ -459,6 +479,7 @@ internal ClientCertificateOption ClientCertificateOptions _settings._clientCertificateOptions = value; } } + protected override void Dispose(bool disposing) { if (disposing && !_disposed) @@ -495,6 +516,8 @@ private HttpMessageHandlerStage SetupHandlerChain() handler = new DiagnosticsHandler(handler, propagator, settings._allowAutoRedirect); } + handler = new MetricsHandler(handler, _settings._meterFactory); + if (settings._allowAutoRedirect) { // Just as with WinHttpHandler, for security reasons, we do not support authentication on redirects diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.General.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.General.cs new file mode 100644 index 0000000000000..b356aa3a4030b --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.General.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Http.Functional.Tests +{ + public class HttpClientHandlerTest_General : HttpClientHandlerTestBase + { + public HttpClientHandlerTest_General(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public Task SendAsync_Null_ThrowsArgumentNullException() => + Assert.ThrowsAsync(() => new TestHttpClientHandler().SendNullAsync()); + + public static bool SupportsSyncSend => PlatformDetection.IsNotMobile && PlatformDetection.IsNotBrowser; + + [ConditionalFact(nameof(SupportsSyncSend))] + public void Send_Null_ThrowsArgumentNullException() => Assert.Throws(() => new TestHttpClientHandler().SendNull()); + + private class TestHttpClientHandler : HttpClientHandler + { + public Task SendNullAsync() => base.SendAsync(null, default); + public void SendNull() => base.Send(null, default); + } + } +} diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs new file mode 100644 index 0000000000000..03690e79656ec --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs @@ -0,0 +1,854 @@ +// 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.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Net.Http.Metrics; +using System.Net.Test.Common; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Http.Functional.Tests +{ + public abstract class HttpMetricsTestBase : HttpClientHandlerTestBase + { + 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"; + } + + protected HttpMetricsTestBase(ITestOutputHelper output) : base(output) + { + } + + protected static void VerifyOptionalTag(KeyValuePair[] tags, string name, T value) + { + if (value is null) + { + Assert.DoesNotContain(tags, t => t.Key == name); + } + else + { + Assert.Equal(value, (T)tags.Single(t => t.Key == name).Value); + } + } + + protected static void VerifyRequestDuration(Measurement measurement, Uri uri, string? protocol, int? statusCode, string method = "GET") + { + Assert.True(measurement.Value > 0); + + 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); + 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 VerifyCurrentRequest(Measurement measurement, long expectedValue, Uri uri) + { + Assert.Equal(expectedValue, measurement.Value); + + 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 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); + Assert.Equal(method, tags.Single(t => t.Key == "method").Value); + VerifyOptionalTag(tags, "port", port); + VerifyOptionalTag(tags, "protocol", protocol); + VerifyOptionalTag(tags, "status-code", statusCode); + } + + 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 Meter? _meter; + + public InstrumentRecorder(string instrumentName) + { + _instrumentName = instrumentName; + _meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "System.Net.Http" && instrument.Name == _instrumentName) + { + listener.EnableMeasurementEvents(instrument); + } + }; + _meterListener.SetMeasurementEventCallback(OnMeasurementRecorded); + _meterListener.Start(); + } + + 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) + { + listener.EnableMeasurementEvents(instrument); + } + }; + _meterListener.SetMeasurementEventCallback(OnMeasurementRecorded); + _meterListener.Start(); + } + + private void OnMeasurementRecorded(Instrument instrument, T measurement, ReadOnlySpan> tags, object? state) => _values.Add(new Measurement(measurement, tags)); + public IReadOnlyList> GetMeasurements() => _values.ToArray(); + public void Dispose() => _meterListener.Dispose(); + } + } + + public abstract class HttpMetricsTest : HttpMetricsTestBase + { + public static readonly bool SupportsSeparateHttpSpansForRedirects = PlatformDetection.IsNotMobile && PlatformDetection.IsNotBrowser; + private IMeterFactory _meterFactory = new TestMeterFactory(); + protected HttpClientHandler Handler { get; } + protected virtual bool TestHttpMessageInvoker => false; + public HttpMetricsTest(ITestOutputHelper output) : base(output) + { + Handler = CreateHttpClientHandler(); + } + + [Fact] + public Task CurrentRequests_Success_Recorded() + { + return LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.CurrentRequests); + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion }; + + HttpResponseMessage response = await SendAsync(client, request); + response.Dispose(); // Make sure disposal doesn't interfere with recording by enforcing early disposal. + + Assert.Collection(recorder.GetMeasurements(), + m => VerifyCurrentRequest(m, 1, uri), + m => VerifyCurrentRequest(m, -1, uri)); + }, async server => + { + await server.AcceptConnectionSendResponseAndCloseAsync(); + }); + } + + [Fact] + [OuterLoop("Uses Task.Delay")] + public async Task CurrentRequests_InstrumentEnabledAfterSending_NotRecorded() + { + SemaphoreSlim instrumentEnabledSemaphore = new SemaphoreSlim(0); + + await LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + + // Enable recording request-duration to test the path with metrics enabled. + using InstrumentRecorder unrelatedRecorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion }; + Task clientTask = SendAsync(client, request); + await Task.Delay(100); + using InstrumentRecorder recorder = new(Handler.MeterFactory, InstrumentNames.CurrentRequests); + instrumentEnabledSemaphore.Release(); + + using HttpResponseMessage response = await clientTask; + + Assert.Empty(recorder.GetMeasurements()); + }, async server => + { + await instrumentEnabledSemaphore.WaitAsync(); + await server.AcceptConnectionSendResponseAndCloseAsync(); + }); + } + + [Theory] + [InlineData("GET", HttpStatusCode.OK)] + [InlineData("PUT", HttpStatusCode.Created)] + public Task RequestDuration_Success_Recorded(string method, HttpStatusCode statusCode) + { + return LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + using HttpRequestMessage request = new(new HttpMethod(method), uri) { Version = UseVersion }; + + using HttpResponseMessage response = await SendAsync(client, request); + + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, uri, ExpectedProtocolString, (int)statusCode, method); + + }, async server => + { + await server.AcceptConnectionSendResponseAndCloseAsync(statusCode); + }); + } + + [Fact] + public Task RequestDuration_CustomTags_Recorded() + { + return LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion }; + + HttpMetricsEnrichmentContext.AddCallback(request, static ctx => + { + ctx.AddCustomTag("route", "/test"); + }); + + using HttpResponseMessage response = await SendAsync(client, request); + + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, uri, ExpectedProtocolString, 200); + Assert.Equal("/test", m.Tags.ToArray().Single(t => t.Key == "route").Value); + + }, async server => + { + await server.AcceptConnectionSendResponseAndCloseAsync(); + }); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData("System.Net.Http.HttpRequestOut.Start")] + [InlineData("System.Net.Http.Request")] + [InlineData("System.Net.Http.HttpRequestOut.Stop")] + public void RequestDuration_CustomTags_DiagnosticListener_Recorded(string eventName) + { + RemoteExecutor.Invoke(static async (testClassName, eventNameInner) => + { + using HttpMetricsTest test = (HttpMetricsTest)Activator.CreateInstance(Type.GetType(testClassName), (ITestOutputHelper)null); + await test.RequestDuration_CustomTags_DiagnosticListener_Recorded_Core(eventNameInner); + }, GetType().FullName, eventName).Dispose(); + } + + private async Task RequestDuration_CustomTags_DiagnosticListener_Recorded_Core(string eventName) + { + FakeDiagnosticListenerObserver diagnosticListenerObserver = new(kv => + { + if (kv.Key == eventName) + { + HttpRequestMessage request = GetProperty(kv.Value, "Request"); + HttpMetricsEnrichmentContext.AddCallback(request, static ctx => + { + ctx.AddCustomTag("observed?", "observed!"); + Assert.NotNull(ctx.Response); + }); + } + }); + + using IDisposable subscription = DiagnosticListener.AllListeners.Subscribe(diagnosticListenerObserver); + + await LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + diagnosticListenerObserver.Enable(); + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion }; + HttpMetricsEnrichmentContext.AddCallback(request, static ctx => + { + ctx.AddCustomTag("route", "/test"); + }); + + using HttpResponseMessage response = await SendAsync(client, request); + + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, uri, ExpectedProtocolString, 200); + Assert.Equal("/test", m.Tags.ToArray().Single(t => t.Key == "route").Value); + Assert.Equal("observed!", m.Tags.ToArray().Single(t => t.Key == "observed?").Value); + + }, async server => + { + await server.AcceptConnectionSendResponseAndCloseAsync(); + }); + + static T GetProperty(object obj, string propertyName) + { + Type t = obj.GetType(); + + PropertyInfo p = t.GetRuntimeProperty(propertyName); + + object propertyValue = p.GetValue(obj); + Assert.NotNull(propertyValue); + Assert.IsAssignableFrom(propertyValue); + + return (T)propertyValue; + } + } + + public enum ResponseContentType + { + Empty, + ContentLength, + TransferEncodingChunked + } + + [Theory] + [InlineData(HttpCompletionOption.ResponseContentRead, ResponseContentType.Empty)] + [InlineData(HttpCompletionOption.ResponseContentRead, ResponseContentType.ContentLength)] + [InlineData(HttpCompletionOption.ResponseContentRead, ResponseContentType.TransferEncodingChunked)] + [InlineData(HttpCompletionOption.ResponseHeadersRead, ResponseContentType.Empty)] + [InlineData(HttpCompletionOption.ResponseHeadersRead, ResponseContentType.ContentLength)] + [InlineData(HttpCompletionOption.ResponseHeadersRead, ResponseContentType.TransferEncodingChunked)] + public async Task RequestDuration_EnrichmentHandler_Success_Recorded(HttpCompletionOption completionOption, ResponseContentType responseContentType) + { + if (TestHttpMessageInvoker) + { + // HttpCompletionOption not supported for HttpMessageInvoker, skipping. + return; + } + + await LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpClient client = CreateHttpClient(new EnrichmentHandler(Handler)); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion }; + using HttpResponseMessage response = await client.SendAsync(TestAsync, request, completionOption); + + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, uri, ExpectedProtocolString, 200); ; + Assert.Equal("before!", m.Tags.ToArray().Single(t => t.Key == "before").Value); + }, async server => + { + if (responseContentType == ResponseContentType.ContentLength) + { + string content = string.Join(' ', Enumerable.Range(0, 100)); + int contentLength = Encoding.ASCII.GetByteCount(content); + await server.AcceptConnectionSendResponseAndCloseAsync(content: content, additionalHeaders: new[] { new HttpHeaderData("Content-Length", $"{contentLength}") }); + } + else if (responseContentType == ResponseContentType.TransferEncodingChunked) + { + string content = "3\r\nfoo\r\n3\r\nbar\r\n0\r\n\r\n"; + await server.AcceptConnectionSendResponseAndCloseAsync(content: content, additionalHeaders: new[] { new HttpHeaderData("Transfer-Encoding", "chunked") }); + } + else + { + // Empty + await server.AcceptConnectionSendResponseAndCloseAsync(); + } + }); + } + + [ConditionalFact(nameof(SupportsSeparateHttpSpansForRedirects))] + public Task CurrentRequests_Redirect_RecordedForEachHttpSpan() + { + return LoopbackServerFactory.CreateServerAsync((originalServer, originalUri) => + { + return LoopbackServerFactory.CreateServerAsync(async (redirectServer, redirectUri) => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.CurrentRequests); + using HttpRequestMessage request = new(HttpMethod.Get, originalUri) { Version = UseVersion }; + + Task clientTask = SendAsync(client, request); + Task serverTask = originalServer.HandleRequestAsync(HttpStatusCode.Redirect, new[] { new HttpHeaderData("Location", redirectUri.AbsoluteUri) }); + + await Task.WhenAny(clientTask, serverTask); + Assert.False(clientTask.IsCompleted, $"{clientTask.Status}: {clientTask.Exception}"); + await serverTask; + + serverTask = redirectServer.HandleRequestAsync(); + await TestHelper.WhenAllCompletedOrAnyFailed(clientTask, serverTask); + await clientTask; + + Assert.Collection(recorder.GetMeasurements(), + m => VerifyCurrentRequest(m, 1, originalUri), + m => VerifyCurrentRequest(m, -1, originalUri), + m => VerifyCurrentRequest(m, 1, redirectUri), + m => VerifyCurrentRequest(m, -1, redirectUri)); + }); + }); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Handler.Dispose(); + _meterFactory.Dispose(); + } + + base.Dispose(disposing); + } + + protected Task SendAsync(HttpMessageInvoker invoker, HttpRequestMessage request) => + TestHttpMessageInvoker ? + invoker.SendAsync(request, default) : + ((HttpClient)invoker).SendAsync(TestAsync, request); + + protected HttpMessageInvoker CreateHttpMessageInvoker(HttpMessageHandler? handler = null) => + TestHttpMessageInvoker ? + new HttpMessageInvoker(handler ?? Handler) : + CreateHttpClient(handler ?? Handler); + + protected InstrumentRecorder SetupInstrumentRecorder(string instrumentName) + where T : struct + { + Handler.MeterFactory = _meterFactory; + return new InstrumentRecorder(_meterFactory, instrumentName); + } + + protected string ExpectedProtocolString => (UseVersion.Major, UseVersion.Minor) switch + { + (1, 1) => "HTTP/1.1", + (2, 0) => "HTTP/2", + (3, 0) => "HTTP/3", + _ => throw new Exception("Unknown version.") + }; + + protected sealed class EnrichmentHandler : DelegatingHandler + { + public EnrichmentHandler(HttpMessageHandler innerHandler) : base(innerHandler) + { + } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpMetricsEnrichmentContext.AddCallback(request, Enrich); + return base.Send(request, cancellationToken); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpMetricsEnrichmentContext.AddCallback(request, Enrich); + return base.SendAsync(request, cancellationToken); + } + + private static void Enrich(HttpMetricsEnrichmentContext context) => context.AddCustomTag("before", "before!"); + } + } + + public abstract class HttpMetricsTest_Http11 : HttpMetricsTest + { + protected override Version UseVersion => HttpVersion.Version11; + public HttpMetricsTest_Http11(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task RequestDuration_EnrichmentHandler_ContentLengthError_Recorded() + { + await LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(new EnrichmentHandler(Handler)); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion }; + + if (TestHttpMessageInvoker) + { + using HttpResponseMessage response = await SendAsync(client, request); + } + else + { + await Assert.ThrowsAsync(async () => + { + using HttpResponseMessage response = await SendAsync(client, request); + }); + } + + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, uri, ExpectedProtocolString, 200); ; + Assert.Equal("before!", m.Tags.ToArray().Single(t => t.Key == "before").Value); + + }, server => server.HandleRequestAsync(headers: new[] { + new HttpHeaderData("Content-Length", "1000") + }, content: "x")); + } + + [Fact] + public async Task FailedRequests_ConnectionClosedWhileReceivingHeaders_Recorded() + { + TimeSpan timeout = TimeSpan.FromSeconds(30); + await LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.FailedRequests); + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion }; + + await Assert.ThrowsAsync(async () => + { + using HttpResponseMessage response = await SendAsync(client, request); + }).WaitAsync(timeout); + + Measurement m = recorder.GetMeasurements().Single(); + VerifyFailedRequests(m, 1, uri, null, null); + }, async server => + { + var connection = (LoopbackServer.Connection)await server.EstablishGenericConnectionAsync().WaitAsync(timeout); + connection.Socket.Close(); + }); + } + } + + public class HttpMetricsTest_Http11_Async : HttpMetricsTest_Http11 + { + public HttpMetricsTest_Http11_Async(ITestOutputHelper output) : base(output) + { + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.SupportsAlpn))] + [InlineData(false)] + [InlineData(true)] + public async Task RequestDuration_HttpVersionDowngrade_LogsActualProtocol(bool malformedResponse) + { + await LoopbackServer.CreateServerAsync(async server => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + using HttpRequestMessage request = new(HttpMethod.Get, server.Address) + { + Version = HttpVersion.Version20, + VersionPolicy = HttpVersionPolicy.RequestVersionOrLower + }; + + Task clientTask = SendAsync(client, request); + + await server.AcceptConnectionAsync(async connection => + { + if (malformedResponse) + { + await connection.ReadRequestHeaderAndSendCustomResponseAsync("!malformed!"); + } + else + { + await connection.ReadRequestHeaderAndSendResponseAsync(); + } + + }); + + if (malformedResponse) + { + await Assert.ThrowsAsync(() => clientTask); + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, server.Address, null, null); // Protocol is not logged. + } + else + { + using HttpResponseMessage response = await clientTask; + + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, server.Address, "HTTP/1.1", 200); + } + + }, new LoopbackServer.Options() { UseSsl = true }); + } + } + + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + public class HttpMetricsTest_Http11_Async_HttpMessageInvoker : HttpMetricsTest_Http11_Async + { + protected override bool TestHttpMessageInvoker => true; + public HttpMetricsTest_Http11_Async_HttpMessageInvoker(ITestOutputHelper output) : base(output) + { + } + } + + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))] + public class HttpMetricsTest_Http11_Sync : HttpMetricsTest_Http11 + { + protected override bool TestAsync => false; + public HttpMetricsTest_Http11_Sync(ITestOutputHelper output) : base(output) + { + } + } + + [ConditionalClass(typeof(HttpMetricsTest_Http20), nameof(IsEnabled))] + public class HttpMetricsTest_Http20 : HttpMetricsTest + { + public static bool IsEnabled = PlatformDetection.IsNotMobile && PlatformDetection.SupportsAlpn; + protected override Version UseVersion => HttpVersion.Version20; + public HttpMetricsTest_Http20(ITestOutputHelper output) : base(output) + { + } + + [ConditionalFact(nameof(SupportsSeparateHttpSpansForRedirects))] + public Task RequestDuration_Redirect_RecordedForEachHttpSpan() + { + return GetFactoryForVersion(HttpVersion.Version11).CreateServerAsync((originalServer, originalUri) => + { + return GetFactoryForVersion(HttpVersion.Version20).CreateServerAsync(async (redirectServer, redirectUri) => + { + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + using HttpRequestMessage request = new(HttpMethod.Get, originalUri) { Version = HttpVersion.Version20 }; + + Task clientTask = SendAsync(client, request); + Task serverTask = originalServer.HandleRequestAsync(HttpStatusCode.Redirect, new[] { new HttpHeaderData("Location", redirectUri.AbsoluteUri) }); + + await Task.WhenAny(clientTask, serverTask); + Assert.False(clientTask.IsCompleted, $"{clientTask.Status}: {clientTask.Exception}"); + await serverTask; + + serverTask = redirectServer.HandleRequestAsync(); + await TestHelper.WhenAllCompletedOrAnyFailed(clientTask, serverTask); + await clientTask; + + Assert.Collection(recorder.GetMeasurements(), m0 => + { + VerifyRequestDuration(m0, originalUri, $"HTTP/1.1", (int)HttpStatusCode.Redirect); + }, m1 => + { + VerifyRequestDuration(m1, redirectUri, $"HTTP/2", (int)HttpStatusCode.OK); + }); + + }, options: new GenericLoopbackOptions() { UseSsl = true }); + }, options: new GenericLoopbackOptions() { UseSsl = false}); + } + + [Fact] + public async Task RequestDuration_ProtocolError_Recorded() + { + using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(); + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.RequestDuration); + + using HttpRequestMessage request = new(HttpMethod.Get, server.Address) { Version = HttpVersion.Version20 }; + Task sendTask = SendAsync(client, request); + + Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); + int streamId = await connection.ReadRequestHeaderAsync(); + + // Send a reset stream frame so that the stream moves to a terminal state. + RstStreamFrame resetStream = new RstStreamFrame(FrameFlags.None, (int)ProtocolErrors.INTERNAL_ERROR, streamId); + await connection.WriteFrameAsync(resetStream); + + await Assert.ThrowsAsync(async () => + { + using HttpResponseMessage response = await sendTask; + }); + + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, server.Address, null, null); // Protocol is not recorded + } + + [Fact] + public async Task FailedRequests_ProtocolError_Recorded() + { + using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(); + using HttpMessageInvoker client = CreateHttpMessageInvoker(); + using InstrumentRecorder recorder = SetupInstrumentRecorder(InstrumentNames.FailedRequests); + + using HttpRequestMessage request = new(HttpMethod.Get, server.Address) { Version = HttpVersion.Version20 }; + Task sendTask = SendAsync(client, request); + + Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); + int streamId = await connection.ReadRequestHeaderAsync(); + + // Send a reset stream frame so that the stream moves to a terminal state. + RstStreamFrame resetStream = new RstStreamFrame(FrameFlags.None, (int)ProtocolErrors.INTERNAL_ERROR, streamId); + await connection.WriteFrameAsync(resetStream); + + await Assert.ThrowsAsync(async () => + { + using HttpResponseMessage response = await sendTask; + }); + + Measurement m = recorder.GetMeasurements().Single(); + VerifyFailedRequests(m, 1, server.Address, null, null); + } + } + + public class HttpMetricsTest_Http20_HttpMessageInvoker : HttpMetricsTest_Http20 + { + protected override bool TestHttpMessageInvoker => true; + public HttpMetricsTest_Http20_HttpMessageInvoker(ITestOutputHelper output) : base(output) + { + } + } + + [Collection(nameof(DisableParallelization))] + [ConditionalClass(typeof(HttpClientHandlerTestBase), nameof(IsQuicSupported))] + public class HttpMetricsTest_Http30 : HttpMetricsTest + { + protected override Version UseVersion => HttpVersion.Version30; + public HttpMetricsTest_Http30(ITestOutputHelper output) : base(output) + { + } + } + + public class HttpMetricsTest_Http30_HttpMessageInvoker : HttpMetricsTest_Http30 + { + protected override bool TestHttpMessageInvoker => true; + public HttpMetricsTest_Http30_HttpMessageInvoker(ITestOutputHelper output) : base(output) + { + } + } + + // Make sure the instruments are working with the default Meter. + public class HttpMetricsTest_DefaultMeter : HttpMetricsTestBase + { + public HttpMetricsTest_DefaultMeter(ITestOutputHelper output) : base(output) + { + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void CurrentRequests_Success_Recorded() + { + RemoteExecutor.Invoke(static async Task () => + { + using HttpMetricsTest_DefaultMeter test = new(null); + await test.LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpClient client = test.CreateHttpClient(); + using InstrumentRecorder recorder = new InstrumentRecorder(InstrumentNames.CurrentRequests); + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = test.UseVersion }; + + HttpResponseMessage response = await client.SendAsync(request); + response.Dispose(); // Make sure disposal doesn't interfere with recording by enforcing early disposal. + + Assert.Collection(recorder.GetMeasurements(), + m => VerifyCurrentRequest(m, 1, uri), + m => VerifyCurrentRequest(m, -1, uri)); + }, async server => + { + await server.AcceptConnectionSendResponseAndCloseAsync(); + }); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void RequestDuration_Success_Recorded() + { + RemoteExecutor.Invoke(static async Task () => + { + using HttpMetricsTest_DefaultMeter test = new(null); + await test.LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + using HttpClient client = test.CreateHttpClient(); + using InstrumentRecorder recorder = new InstrumentRecorder(InstrumentNames.RequestDuration); + using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = test.UseVersion }; + + using HttpResponseMessage response = await client.SendAsync(request); + Measurement m = recorder.GetMeasurements().Single(); + VerifyRequestDuration(m, uri, "HTTP/1.1", (int)HttpStatusCode.OK, "GET"); + }, async server => + { + await server.AcceptConnectionSendResponseAndCloseAsync(HttpStatusCode.OK); + }); + }).Dispose(); + } + } + + public class HttpMetricsTest_General + { + [ConditionalFact(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))] + public void SocketsHttpHandler_Dispose_DoesNotDisposeMeterFactory() + { + using TestMeterFactory factory = new(); + SocketsHttpHandler handler = new() + { + MeterFactory = factory + }; + handler.Dispose(); + Assert.False(factory.IsDisposed); + } + + [Fact] + public void HttpClientHandler_Dispose_DoesNotDisposeMeter() + { + using TestMeterFactory factory = new(); + HttpClientHandler handler = new() + { + MeterFactory = factory + }; + handler.Dispose(); + Assert.False(factory.IsDisposed); + } + + [Fact] + public void HttpClientHandler_SetMeterFactoryAfterDispose_ThrowsObjectDisposedException() + { + HttpClientHandler handler = new(); + handler.Dispose(); + Assert.ThrowsAny(() => handler.MeterFactory = new TestMeterFactory()); + } + + [ConditionalFact(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))] + public void SocketsHttpHandler_SetMeterFactoryAfterDispose_ThrowsObjectDisposedException() + { + SocketsHttpHandler handler = new(); + handler.Dispose(); + Assert.ThrowsAny(() => handler.MeterFactory = new TestMeterFactory()); + } + + [Fact] + public void HttpClientHandler_SetMeterFactoryAfterStart_ThrowsInvalidOperationException() + { + Http11LoopbackServerFactory.Singleton.CreateClientAndServerAsync(async uri => + { + using HttpClientHandler handler = new(); + using HttpClient client = new HttpClient(handler); + await client.GetAsync(uri); + + Assert.Throws(() => handler.MeterFactory = new TestMeterFactory()); + }, server => server.AcceptConnectionSendResponseAndCloseAsync()); + } + + [ConditionalFact(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))] + public void SocketsHttpHandler_SetMeterFactoryAfterStart_ThrowsInvalidOperationException() + { + Http11LoopbackServerFactory.Singleton.CreateClientAndServerAsync(async uri => + { + using SocketsHttpHandler handler = new(); + using HttpClient client = new HttpClient(handler); + await client.GetAsync(uri); + + Assert.Throws(() => handler.MeterFactory = new TestMeterFactory()); + }, server => server.AcceptConnectionSendResponseAndCloseAsync()); + } + } + + internal sealed class TestMeterFactory : IMeterFactory + { + private Meter? _meter; + public bool IsDisposed => _meter is null; + + public TestMeterFactory() => _meter = new Meter("System.Net.Http", null, null, this); + public Meter Create(MeterOptions options) + { + Assert.Equal("System.Net.Http", options.Name); + Assert.Same(this, options.Scope); + Assert.False(IsDisposed); + + return _meter; + } + public void Dispose() + { + _meter?.Dispose(); + _meter = null; + } + } + +} diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj index 260c34fc8fb17..bbeb3395d36e0 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj @@ -152,6 +152,7 @@ Link="Common\System\Net\Http\HttpClientHandlerTest.DefaultProxyCredentials.cs" /> + @@ -194,6 +195,7 @@ +