diff --git a/eng/Packages/General.props b/eng/Packages/General.props index 64c438de5b0..1117bb0c7e8 100644 --- a/eng/Packages/General.props +++ b/eng/Packages/General.props @@ -19,6 +19,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HelperExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HelperExtensions.cs new file mode 100644 index 00000000000..c3998d964d2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HelperExtensions.cs @@ -0,0 +1,51 @@ +// 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.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Http.Telemetry.Metering; + +internal static class HelperExtensions +{ + internal static HttpStatusCode GetStatusCode(this Exception ex) + { + if (ex is TaskCanceledException) + { + return HttpStatusCode.GatewayTimeout; + } + else if (ex is HttpRequestException exception) + { +#if NET5_0_OR_GREATER + return exception.StatusCode ?? HttpStatusCode.ServiceUnavailable; +#else + return HttpStatusCode.ServiceUnavailable; +#endif + } + else + { + return HttpStatusCode.InternalServerError; + } + } + + /// + /// Classifies the result of the HTTP response. + /// + /// An to categorize the status code of HTTP response. + /// type of HTTP response and its . + internal static HttpRequestResultType GetResultCategory(this HttpStatusCode statusCode) + { + if (statusCode >= HttpStatusCode.Continue && statusCode < HttpStatusCode.BadRequest) + { + return HttpRequestResultType.Success; + } + else if (statusCode >= HttpStatusCode.BadRequest && statusCode < HttpStatusCode.InternalServerError) + { + return HttpRequestResultType.ExpectedFailure; + } + + return HttpRequestResultType.Failure; + } +} diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpClientMeteringExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpClientMeteringExtensions.cs index 3212b6c54e1..7927428efaf 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpClientMeteringExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpClientMeteringExtensions.cs @@ -2,9 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +#if !NETFRAMEWORK +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http.Telemetry.Metering.Internal; +#endif using System.Net.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Telemetry; using Microsoft.Extensions.Telemetry.Internal; using Microsoft.Extensions.Telemetry.Metering; using Microsoft.Shared.Collections; @@ -18,11 +22,50 @@ namespace Microsoft.Extensions.Http.Telemetry.Metering; /// public static class HttpClientMeteringExtensions { +#if !NETFRAMEWORK /// - /// Adds a to collect and emit metrics for outgoing requests from all http clients. + /// Adds Http client diagnostics listener to capture metrics for requests from all http clients. /// /// - /// This extension configures outgoing request metrics auto collection globally for all http clients. + /// This extension configures outgoing request metrics auto collection + /// globally for all http clients regardless of how the http client is created. + /// + /// The . + /// + /// instance for chaining. + /// + [Experimental] + public static IServiceCollection AddHttpClientMeteringForAllHttpClients(this IServiceCollection services) + { + _ = Throw.IfNull(services); + + _ = services + .RegisterMetering() + .AddOutgoingRequestContext(); + + services.TryAddSingleton(sp => + { + var meter = sp.GetRequiredService>(); + var outgoingRequestMetricEnrichers = sp.GetService>().EmptyIfNull(); + var requestMetadataContext = sp.GetService(); + var downstreamDependencyMetadataManager = sp.GetService(); + return new HttpMeteringHandler(meter, outgoingRequestMetricEnrichers, requestMetadataContext, downstreamDependencyMetadataManager); + }); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddActivatedSingleton(); + + return services; + } +#endif + + /// + /// Adds a to collect and emit metrics for outgoing requests from all http clients created using IHttpClientFactory. + /// + /// + /// This extension configures outgoing request metrics auto collection + /// for all http clients created using IHttpClientFactory. /// /// The . /// diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpMeteringHandler.cs b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpMeteringHandler.cs index abe1dd510ab..ba946b7198b 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpMeteringHandler.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpMeteringHandler.cs @@ -27,9 +27,12 @@ namespace Microsoft.Extensions.Http.Telemetry.Metering; /// public class HttpMeteringHandler : DelegatingHandler { +#pragma warning disable CA2213 // Disposable fields should be disposed + internal readonly Meter Meter; +#pragma warning restore CA2213 // Disposable fields should be disposed internal TimeProvider TimeProvider = TimeProvider.System; - private const int StandardDimensionsCount = 4; + private const int StandardDimensionsCount = 5; private const int MaxCustomDimensionsCount = 14; private static readonly RequestMetadata _fallbackMetadata = new(); @@ -60,7 +63,7 @@ internal HttpMeteringHandler( IOutgoingRequestContext? requestMetadataContext, IDownstreamDependencyMetadataManager? downstreamDependencyMetadataManager = null) { - _ = Throw.IfNull(meter); + Meter = Throw.IfNull(meter); _ = Throw.IfNull(enrichers); _requestEnrichers = enrichers.ToArray(); @@ -84,7 +87,8 @@ internal HttpMeteringHandler( Metric.ReqHost, Metric.DependencyName, Metric.ReqName, - Metric.RspResultCode + Metric.RspResultCode, + Metric.RspResultCategory }; for (int i = 0; i < _requestEnrichers.Length; i++) @@ -104,39 +108,12 @@ internal HttpMeteringHandler( _downstreamDependencyMetadataManager = downstreamDependencyMetadataManager; } - internal static string GetHostName(HttpRequestMessage request) => string.IsNullOrWhiteSpace(request.RequestUri?.Host) ? TelemetryConstants.Unknown : request.RequestUri!.Host; + internal static string GetHostName(HttpRequestMessage request) + => string.IsNullOrWhiteSpace(request.RequestUri?.Host) + ? TelemetryConstants.Unknown + : request.RequestUri!.Host; - /// - /// Sends an HTTP request to the inner handler to send to the server as an asynchronous operation. - /// - /// The HTTP request message to send to the server. - /// A cancellation token to cancel operation. - /// - /// The task object representing the asynchronous operation. - /// - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - _ = Throw.IfNull(request); - - var timestamp = TimeProvider.GetTimestamp(); - - try - { - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - OnRequestEnd(request, timestamp, response.StatusCode); - return response; - } - catch - { - // This will not catch a response that returns 4xx, 5xx, etc. but will only catch when base.SendAsync() fails. - OnRequestEnd(request, timestamp, HttpStatusCode.InternalServerError); - throw; - } - } - - private void OnRequestEnd(HttpRequestMessage request, long timestamp, HttpStatusCode statusCode) + internal void OnRequestEnd(HttpRequestMessage request, long elapsedMilliseconds, HttpStatusCode statusCode) { var requestMetadata = request.GetRequestMetadata() ?? _requestMetadataContext?.RequestMetadata ?? @@ -145,15 +122,16 @@ private void OnRequestEnd(HttpRequestMessage request, long timestamp, HttpStatus var dependencyName = requestMetadata.DependencyName; var requestName = $"{request.Method} {requestMetadata.GetRequestName()}"; var hostName = GetHostName(request); - var duration = (long)TimeProvider.GetElapsedTime(timestamp, TimeProvider.GetTimestamp()).TotalMilliseconds; - + var duration = elapsedMilliseconds; + var result = statusCode.GetResultCategory(); var tagList = new TagList { new(Metric.ReqHost, hostName), new(Metric.DependencyName, dependencyName), new(Metric.ReqName, requestName), - new(Metric.RspResultCode, (int)statusCode) - }; + new(Metric.RspResultCode, (int)statusCode), + new(Metric.RspResultCategory, result.ToInvariantString()) + }; // keep default case fast by avoiding allocations if (_enrichersCount == 0) @@ -183,4 +161,44 @@ private void OnRequestEnd(HttpRequestMessage request, long timestamp, HttpStatus } } } + + /// + /// Sends an HTTP request to the inner handler to send to the server as an asynchronous operation. + /// + /// The HTTP request message to send to the server. + /// A cancellation token to cancel operation. + /// + /// The task object representing the asynchronous operation. + /// + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _ = Throw.IfNull(request); + +#if !NETFRAMEWORK + if (HttpClientMeteringListener.UsingDiagnosticsSource) + { + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } +#endif + + var start = TimeProvider.GetTimestamp(); + + try + { + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + var elapsed = TimeProvider.GetTimestamp() - start; + long ms = (long)Math.Round(((double)elapsed / TimeProvider.System.TimestampFrequency) * 1000); + OnRequestEnd(request, ms, response.StatusCode); + return response; + } + catch (Exception ex) + { + var elapsed = TimeProvider.GetTimestamp() - start; + long ms = (long)Math.Round(((double)elapsed / TimeProvider.System.TimestampFrequency) * 1000); + OnRequestEnd(request, ms, ex.GetStatusCode()); + throw; + } + } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpRequestResultType.cs b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpRequestResultType.cs new file mode 100644 index 00000000000..d6c33a18376 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/HttpRequestResultType.cs @@ -0,0 +1,34 @@ +// 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.CodeAnalysis; +using Microsoft.Extensions.EnumStrings; + +namespace Microsoft.Extensions.Http.Telemetry.Metering; + +/// +/// Statuses for classifying http request result. +/// +[Experimental] +[EnumStrings] +public enum HttpRequestResultType +{ + /// + /// The status code of the http request indicates that the request is successful. + /// + Success, + + /// + /// The status code of the http request indicates that this request did not succeed and to be treated as failure. + /// + Failure, + + /// + /// The status code of the http request indicates that the request did not succeed but has failed with an error which is expected and acceptable for this request. + /// + /// + /// Expected failures are generally excluded from availability calculations i.e. they are neither + /// treated as success nor as failures for availability calculation. + /// + ExpectedFailure, +} diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientDiagnosticObserver.cs b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientDiagnosticObserver.cs new file mode 100644 index 00000000000..4920add805d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientDiagnosticObserver.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NETFRAMEWORK +using System; +using System.Diagnostics; + +namespace Microsoft.Extensions.Http.Telemetry.Metering.Internal; + +internal sealed class HttpClientDiagnosticObserver : IObserver, IDisposable +{ + private const string ListenerName = "HttpHandlerDiagnosticListener"; + private readonly HttpClientRequestAdapter _httpClientRequestAdapter; + private IDisposable? _observer; + + public HttpClientDiagnosticObserver(HttpClientRequestAdapter httpClientRequestAdapter) + { + _httpClientRequestAdapter = httpClientRequestAdapter; + } + + public void OnNext(DiagnosticListener value) + { + if (value.Name == ListenerName) + { + _observer?.Dispose(); + _observer = value.SubscribeWithAdapter(_httpClientRequestAdapter); + } + } + + public void OnCompleted() + { + // Method intentionally left empty. + } + + public void OnError(Exception error) + { + // Method intentionally left empty. + } + + public void Dispose() + { + _observer?.Dispose(); + } +} +#endif diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientMeteringListener.cs b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientMeteringListener.cs new file mode 100644 index 00000000000..31e738d9cd4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientMeteringListener.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NETFRAMEWORK +using System; +using System.Diagnostics; + +namespace Microsoft.Extensions.Http.Telemetry.Metering.Internal; + +internal sealed class HttpClientMeteringListener : IDisposable +{ + internal static bool UsingDiagnosticsSource { get; set; } + + private readonly IDisposable _listener; + + public HttpClientMeteringListener(HttpClientDiagnosticObserver httpClientDiagnosticObserver) + { + UsingDiagnosticsSource = true; + _listener = DiagnosticListener.AllListeners.Subscribe(httpClientDiagnosticObserver); + } + + public void Dispose() + { + _listener.Dispose(); + } +} +#endif diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientRequestAdapter.cs b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientRequestAdapter.cs new file mode 100644 index 00000000000..ac6d9476198 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/HttpClientRequestAdapter.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NETFRAMEWORK +using System; +#if !NET5_0_OR_GREATER +using System.Collections.Generic; +#endif +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.DiagnosticAdapter; + +namespace Microsoft.Extensions.Http.Telemetry.Metering.Internal; + +#pragma warning disable R9A013 // This class has virtual members and can't be sealed. +internal class HttpClientRequestAdapter +#pragma warning restore R9A013 // This class has virtual members and can't be sealed. +{ + internal TimeProvider TimeProvider = TimeProvider.System; + + private const string RequestStartTimeKey = "requestStartTimeTicks"; + +#if NET5_0_OR_GREATER + private static readonly HttpRequestOptionsKey _requestStartTimeOptionsKey = new(RequestStartTimeKey); +#endif + + private readonly HttpMeteringHandler _httpMeteringHandler; + + public HttpClientRequestAdapter(HttpMeteringHandler httpMeteringHandler) + { + _httpMeteringHandler = httpMeteringHandler; + } + + [DiagnosticName("System.Net.Http.HttpRequestOut")] + public virtual void HttpClientListenerSubscribed(HttpRequestMessage request) + { + // This won't be invoked. This is needed just to add subscription for top level namespace, + // because the http handler diagnostics listener check for this subscription to be present + // before emitting request start or end events. + } + + [DiagnosticName("System.Net.Http.HttpRequestOut.Start")] + public virtual void OnRequestStart(HttpRequestMessage request) + { +#if NET5_0_OR_GREATER + request.Options.Set(_requestStartTimeOptionsKey, TimeProvider.GetUtcNow()); +#else + request.Properties[RequestStartTimeKey] = TimeProvider.GetUtcNow(); +#endif + } + + [DiagnosticName("System.Net.Http.HttpRequestOut.Stop")] + public virtual void OnRequestStop(HttpResponseMessage? response, HttpRequestMessage request, TaskStatus requestTaskStatus) + { + if (requestTaskStatus == TaskStatus.Faulted) + { + // TaskStatus is faulted in case of any exceptions (except operation cancelled) + // Metrics emission will be handled as part of the exception event in this case. + return; + } + + long durationInMs = GetRequestDuration(request); + HttpStatusCode statusCode; + if (response == null) + { + statusCode = requestTaskStatus == TaskStatus.Canceled ? HttpStatusCode.GatewayTimeout : HttpStatusCode.InternalServerError; + } + else + { + statusCode = response.StatusCode; + } + + _httpMeteringHandler.OnRequestEnd(request, durationInMs, statusCode); + } + + [DiagnosticName("System.Net.Http.Exception")] + public virtual void OnRequestException(Exception exception, HttpRequestMessage request) + { + long durationInMs = GetRequestDuration(request); + _httpMeteringHandler.OnRequestEnd(request, durationInMs, exception.GetStatusCode()); + } + + private long GetRequestDuration(HttpRequestMessage request) + { + long durationInMs = 0; + +#if NET5_0_OR_GREATER + if (request.Options.TryGetValue(_requestStartTimeOptionsKey, out var startTime)) +#else + if (request.Properties.TryGetValue(RequestStartTimeKey, out var startTimeObject) && + startTimeObject is DateTimeOffset startTime) +#endif + { + durationInMs = (long)(TimeProvider.GetUtcNow() - startTime).TotalMilliseconds; + } + + return durationInMs; + } +} +#endif diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/Metric.cs b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/Metric.cs index 6409d250efc..5ed625565e2 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/Metric.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Metering/Internal/Metric.cs @@ -30,15 +30,24 @@ internal static partial class Metric /// /// /// This is the status code returned by the target dependency service. In case of exceptions, when - /// no status code is available, this will be set to InternalServerError i.e. 500. + /// no status code is available, this will be set to 500. /// internal const string RspResultCode = "rsp_resultCode"; + /// + /// The response status category for the outgoing request. + /// + /// + /// This is the response cantegory returned by the target dependency service. It will return one of 3 + /// statuses: success, failure or expectedfailure. + /// + internal const string RspResultCategory = "rsp_resultCategory"; + /// /// Creates a new histogram instrument for an outgoing HTTP request. /// /// Meter object. /// - [Histogram(ReqHost, DependencyName, ReqName, RspResultCode, Name = OutgoingRequestMetricName)] + [Histogram(ReqHost, DependencyName, ReqName, RspResultCode, RspResultCategory, Name = OutgoingRequestMetricName)] public static partial OutgoingMetric CreateHistogram(Meter meter); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Microsoft.Extensions.Http.Telemetry.csproj b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Microsoft.Extensions.Http.Telemetry.csproj index 737f31fcdd8..8af37499861 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Telemetry/Microsoft.Extensions.Http.Telemetry.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Telemetry/Microsoft.Extensions.Http.Telemetry.csproj @@ -6,6 +6,7 @@ + true true true true @@ -34,10 +35,12 @@ + + diff --git a/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/HttpMeteringHandlerTests.Ext.cs b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/HttpMeteringHandlerTests.Ext.cs new file mode 100644 index 00000000000..70b2ee0f6d1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/HttpMeteringHandlerTests.Ext.cs @@ -0,0 +1,136 @@ +// 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.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Http.Telemetry.Metering.Internal; +using Microsoft.Extensions.Http.Telemetry.Metering.Test.Internal; +using Microsoft.Extensions.Telemetry; +using Microsoft.Extensions.Telemetry.Metering; +using Microsoft.Extensions.Telemetry.Testing.Metering; +using Xunit; + +namespace Microsoft.Extensions.Http.Telemetry.Metering.Test; + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits +public sealed partial class HttpMeteringHandlerTests : IDisposable +{ + [Fact] + public void SendAsync_expectedFailure_EmptyOutgoingRequestMetricEnricher() + { + using var meter = new Meter(); + using var metricCollector = new MetricCollector(meter); + using var client = CreateClientWithHandler(meter); + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _expectedFailureUri); + + using var _ = client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token).Result; + + var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(latest); + Assert.Equal("www.example-expectedfailure.com", latest.GetDimension(Metric.ReqHost)); + Assert.Equal(TelemetryConstants.Unknown, latest.GetDimension(Metric.DependencyName)); + Assert.Equal($"GET {TelemetryConstants.Unknown}", latest.GetDimension(Metric.ReqName)); + Assert.Equal(400, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.ExpectedFailure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); + } + + [Fact] + public async Task SendAsync_TaskCanceledException() + { + using var meter = new Meter(); + using var metricCollector = new MetricCollector(meter); + using var client = CreateClientWithHandler(meter); + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failure1Uri); + + await Assert.ThrowsAsync(async () => await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token)); + + var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(latest); + Assert.Equal("www.example-failure1.com", latest.GetDimension(Metric.ReqHost)); + Assert.Equal(TelemetryConstants.Unknown, latest.GetDimension(Metric.DependencyName)); + Assert.Equal($"POST {TelemetryConstants.Unknown}", latest.GetDimension(Metric.ReqName)); + Assert.Equal((int)HttpStatusCode.GatewayTimeout, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Failure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); + } + + [Fact] + public async Task SendAsync_InvalidOperationException() + { + using var meter = new Meter(); + using var metricCollector = new MetricCollector(meter); + using var client = CreateClientWithHandler(meter); + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failure2Uri); + + await Assert.ThrowsAsync(async () => await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token)); + + var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(latest); + Assert.Equal("www.example-failure2.com", latest.GetDimension(Metric.ReqHost)); + Assert.Equal(TelemetryConstants.Unknown, latest.GetDimension(Metric.DependencyName)); + Assert.Equal($"POST {TelemetryConstants.Unknown}", latest.GetDimension(Metric.ReqName)); + Assert.Equal((int)HttpStatusCode.InternalServerError, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Failure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); + } + + [Fact] + public async Task SendAsync_Exception_OutgoingRequestMetricEnricherOnly() + { + using var meter = new Meter(); + using var metricCollector = new MetricCollector(meter); + using var client = CreateClientWithHandler(meter, + new List + { + new TestEnricher(1), + }); + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failureUri); + httpRequestMessage.SetRequestMetadata(new RequestMetadata + { + DependencyName = "failure_service", + RequestRoute = "/foo/failure", + RequestName = "TestRequestName" + }); + + await Assert.ThrowsAsync(async () => await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token)); + + var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(latest); + Assert.Equal("www.example-failure.com", latest.GetDimension(Metric.ReqHost)); + Assert.Equal("failure_service", latest.GetDimension(Metric.DependencyName)); + Assert.Equal("POST TestRequestName", latest.GetDimension(Metric.ReqName)); + Assert.Equal((int)HttpStatusCode.ServiceUnavailable, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Failure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); + Assert.Equal("test_value_1", latest.GetDimension("test_property_1")); + } + + [Fact] + public async Task SendAsync_HttpRequestException() + { + using var meter = new Meter(); + using var metricCollector = new MetricCollector(meter); + using var client = CreateClientWithHandler(meter); + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failure3Uri); + + await Assert.ThrowsAsync(async () => await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token)); + + var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(latest); + Assert.Equal("www.example-failure3.com", latest.GetDimension(Metric.ReqHost)); + Assert.Equal(TelemetryConstants.Unknown, latest.GetDimension(Metric.DependencyName)); + Assert.Equal($"POST {TelemetryConstants.Unknown}", latest.GetDimension(Metric.ReqName)); +#if NET6_0_OR_GREATER + Assert.Equal((int)HttpStatusCode.BadGateway, latest.GetDimension(Metric.RspResultCode)); +#else + Assert.Equal((int)HttpStatusCode.ServiceUnavailable, latest.GetDimension(Metric.RspResultCode)); +#endif + Assert.Equal(HttpRequestResultType.Failure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); + } +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits +} diff --git a/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/HttpMeteringHandlerTests.cs b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/HttpMeteringHandlerTests.cs index e8e7576ff0f..ae110356624 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/HttpMeteringHandlerTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/HttpMeteringHandlerTests.cs @@ -3,11 +3,21 @@ using System; using System.Collections.Generic; +#if !NETFRAMEWORK +using System.Diagnostics; +#endif +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +#if !NETFRAMEWORK +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.Testing; +#endif using Microsoft.Extensions.Http.Telemetry.Metering.Internal; using Microsoft.Extensions.Http.Telemetry.Metering.Test.Internal; using Microsoft.Extensions.Telemetry; @@ -19,15 +29,22 @@ using Xunit; namespace Microsoft.Extensions.Http.Telemetry.Metering.Test; + #pragma warning disable CA2000 // Not necessary to dispose all resources in test class. #pragma warning disable VSTHRD002 // Avoid problematic synchronous waits -public sealed class HttpMeteringHandlerTests : IDisposable + +public sealed partial class HttpMeteringHandlerTests : IDisposable { private const long DefaultClockAdvanceMs = 200; private const string DelayPropertyName = nameof(DelayPropertyName); private static readonly Uri _failureUri = new("https://www.example-failure.com/foo?bar"); - private static readonly Uri _successfullUri = new("https://www.example-success.com/foo?bar"); + private static readonly Uri _failure1Uri = new("https://www.example-failure1.com/foo?bar"); + private static readonly Uri _failure2Uri = new("https://www.example-failure2.com/foo?bar"); + private static readonly Uri _failure3Uri = new("https://www.example-failure3.com/foo?bar"); + private static readonly Uri _internalServerErrorUri = new("https://www.example-failure.com/internalServererror"); + private static readonly Uri _expectedFailureUri = new("https://www.example-expectedfailure.com/foo?bar"); + private static readonly Uri _successfulUri = new("https://www.example-success.com/foo?bar"); private readonly CancellationTokenSource _cancellationTokenSource; private readonly FakeTimeProvider _fakeTimeProvider = new(); @@ -45,17 +62,73 @@ public void Dispose() _cancellationTokenSource.Dispose(); } + [Fact] + public void Handler_DoesntDisposeUnderlyingMeter_WhenMeterAndDisposeFalsePassed() + { + using var meter = new Meter(); + using var handler = new OverriddenHttpMeteringHandler(meter, Array.Empty()); + var disposed = CheckHandlerUnderlyingMeterDisposed(counterName => + { + var counter = meter.CreateCounter(counterName); + counter.Add(100_500); + handler.ExternalDispose(false); + }); + + Assert.False(disposed, "The underlying Meter in the handler should not be disposed"); + } + + [Fact] + public void Handler_DoesntDisposeUnderlyingMeter_WhenMeterPassed() + { + using var meter = new Meter(); + var disposed = CheckHandlerUnderlyingMeterDisposed(counterName => + { + using var handler = new HttpMeteringHandler(meter, Array.Empty()); + var counter = meter.CreateCounter(counterName); + counter.Add(100_500); + }); + + Assert.False(disposed, "The underlying Meter in the handler should not be disposed"); + } + + private static bool CheckHandlerUnderlyingMeterDisposed(Action testAction) + { + var counterName = Guid.NewGuid().ToString(); + using var meterListener = new MeterListener(); + meterListener.InstrumentPublished += (instrument, listener) => + { + if (instrument.Name == counterName) + { + listener.EnableMeasurementEvents(instrument); + } + }; + + var disposed = false; + meterListener.MeasurementsCompleted += (instrument, _) => + { + if (instrument.Name == counterName) + { + disposed = true; + } + }; + + meterListener.Start(); + testAction(counterName); + + return disposed; + } + [Fact] public void SendAsync_Success_NoNamesSet() { using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); - var client = CreateClientWithHandler(meter); + using var client = CreateClientWithHandler(meter); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, _successfullUri); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, _successfulUri); - _ = client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token).Result; + using var _ = client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token).Result; var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; Assert.NotNull(latest); @@ -63,6 +136,7 @@ public void SendAsync_Success_NoNamesSet() Assert.Equal(TelemetryConstants.Unknown, latest.GetDimension(Metric.DependencyName)); Assert.Equal($"PUT {TelemetryConstants.Unknown}", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] @@ -70,16 +144,16 @@ public void SendAsync_Success() { using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); - var client = CreateClientWithHandler(meter); + using var client = CreateClientWithHandler(meter); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfullUri); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfulUri); httpRequestMessage.SetRequestMetadata(new RequestMetadata { DependencyName = "success_service", RequestRoute = "/foo" }); - _ = client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token).Result; + using var _ = client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token).Result; var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; Assert.NotNull(latest); @@ -87,27 +161,29 @@ public void SendAsync_Success() Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal($"GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] - public void PerfStopwatch_ReturnsTotalMiliseconds_InsteadOfFraction() + public void PerfStopwatch_ReturnsTotalMilliseconds_InsteadOfFraction() { const long TimeAdvanceMs = 1500L; // We need to use any value greater than 1000 (1 second) const string ServiceName = "success_service"; using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); - var client = CreateClientWithHandler(meter); + using var client = CreateClientWithHandler(meter); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfullUri); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfulUri); httpRequestMessage.SetRequestMetadata(new RequestMetadata { DependencyName = ServiceName, RequestRoute = "/foo" }); + _clockAdvanceMs = TimeAdvanceMs; - _ = client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token).Result; + using var _ = client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token).Result; var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -116,6 +192,7 @@ public void PerfStopwatch_ReturnsTotalMiliseconds_InsteadOfFraction() Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] @@ -123,9 +200,9 @@ public async Task SendAsync_Exception() { using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); - var client = CreateClientWithHandler(meter); + using var client = CreateClientWithHandler(meter); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failureUri); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failureUri); httpRequestMessage.SetRequestMetadata(new RequestMetadata { DependencyName = "failure_service", @@ -133,7 +210,10 @@ public async Task SendAsync_Exception() RequestName = "TestRequestName" }); - await Assert.ThrowsAsync(async () => await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token)); + await Assert.ThrowsAsync(async () => + { + using var _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); + }); var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -141,7 +221,8 @@ public async Task SendAsync_Exception() Assert.Equal("www.example-failure.com", latest.GetDimension(Metric.ReqHost)); Assert.Equal("failure_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("POST TestRequestName", latest.GetDimension(Metric.ReqName)); - Assert.Equal(500, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal((int)HttpStatusCode.ServiceUnavailable, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Failure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] @@ -150,7 +231,7 @@ public void SendAsync_SetReqMetadata_OnAsyncContext_Success() using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); var requestMetadataContextMock = new Mock(); - var client = CreateClientWithHandler(meter, requestMetadataContextMock.Object); + using var client = CreateClientWithHandler(meter, requestMetadataContextMock.Object); var requestMetadata = new RequestMetadata { @@ -159,7 +240,7 @@ public void SendAsync_SetReqMetadata_OnAsyncContext_Success() }; requestMetadataContextMock.Setup(m => m.RequestMetadata).Returns(requestMetadata); - _ = client.GetAsync(_successfullUri, _cancellationTokenSource.Token).Result; + using var _ = client.GetAsync(_successfulUri, _cancellationTokenSource.Token).Result; var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -168,10 +249,11 @@ public void SendAsync_SetReqMetadata_OnAsyncContext_Success() Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] - public void PerfStopwatch_SetReqMetadata_OnAsyncContext_ReturnsTotalMiliseconds_InsteadOfFraction() + public void PerfStopwatch_SetReqMetadata_OnAsyncContext_ReturnsTotalMilliseconds_InsteadOfFraction() { const long TimeAdvanceMs = 1500L; // We need to use any value greater than 1000 (1 second) const string ServiceName = "success_service"; @@ -179,7 +261,7 @@ public void PerfStopwatch_SetReqMetadata_OnAsyncContext_ReturnsTotalMiliseconds_ using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); var requestMetadataContextMock = new Mock(); - var client = CreateClientWithHandler(meter, requestMetadataContextMock.Object); + using var client = CreateClientWithHandler(meter, requestMetadataContextMock.Object); var requestMetadata = new RequestMetadata { @@ -191,7 +273,7 @@ public void PerfStopwatch_SetReqMetadata_OnAsyncContext_ReturnsTotalMiliseconds_ _clockAdvanceMs = TimeAdvanceMs; - _ = client.GetAsync(_successfullUri, _cancellationTokenSource.Token).Result; + using var _ = client.GetAsync(_successfulUri, _cancellationTokenSource.Token).Result; var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -200,6 +282,7 @@ public void PerfStopwatch_SetReqMetadata_OnAsyncContext_ReturnsTotalMiliseconds_ Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] @@ -208,7 +291,7 @@ public async Task SendAsync_SetReqMetadata_OnAsyncContext_Exception() using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); var requestMetadataContextMock = new Mock(); - var client = CreateClientWithHandler(meter, requestMetadataContextMock.Object); + using var client = CreateClientWithHandler(meter, requestMetadataContextMock.Object); var requestMetadata = new RequestMetadata { @@ -216,10 +299,14 @@ public async Task SendAsync_SetReqMetadata_OnAsyncContext_Exception() RequestRoute = "/foo/failure", RequestName = "TestRequestName" }; + requestMetadataContextMock.Setup(m => m.RequestMetadata).Returns(requestMetadata); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failureUri); - await Assert.ThrowsAsync(async () => await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token)); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failureUri); + await Assert.ThrowsAsync(async () => + { + using var _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); + }); var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -227,7 +314,8 @@ public async Task SendAsync_SetReqMetadata_OnAsyncContext_Exception() Assert.Equal("www.example-failure.com", latest.GetDimension(Metric.ReqHost)); Assert.Equal("failure_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("POST TestRequestName", latest.GetDimension(Metric.ReqName)); - Assert.Equal(500, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal((int)HttpStatusCode.ServiceUnavailable, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Failure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] @@ -236,16 +324,19 @@ public void SendAsync_WithDownstreamDependencyMetadata_OnAsyncContext_Success() using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); var downstreamDependencyMetadataManagerMock = new Mock(); - var client = CreateClientWithHandler(meter, downstreamDependencyMetadataManager: downstreamDependencyMetadataManagerMock.Object); + using var client = CreateClientWithHandler(meter, downstreamDependencyMetadataManager: downstreamDependencyMetadataManagerMock.Object); var requestMetadata = new RequestMetadata { DependencyName = "success_service", RequestRoute = "/foo" }; - downstreamDependencyMetadataManagerMock.Setup(m => m.GetRequestMetadata(It.IsAny())).Returns(requestMetadata); - _ = client.GetAsync(_successfullUri, _cancellationTokenSource.Token).Result; + downstreamDependencyMetadataManagerMock + .Setup(m => m.GetRequestMetadata(It.IsAny())) + .Returns(requestMetadata); + + using var _ = client.GetAsync(_successfulUri, _cancellationTokenSource.Token).Result; var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -254,10 +345,11 @@ public void SendAsync_WithDownstreamDependencyMetadata_OnAsyncContext_Success() Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] - public void PerfStopwatch_WithDownstreamDependencyMetadata_OnAsyncContext_ReturnsTotalMiliseconds_InsteadOfFraction() + public void PerfStopwatch_WithDownstreamDependencyMetadata_OnAsyncContext_ReturnsTotalMilliseconds_InsteadOfFraction() { const long TimeAdvanceMs = 1500L; // We need to use any value greater than 1000 (1 second) const string ServiceName = "success_service"; @@ -265,7 +357,7 @@ public void PerfStopwatch_WithDownstreamDependencyMetadata_OnAsyncContext_Return using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); var downstreamDependencyMetadataManagerMock = new Mock(); - var client = CreateClientWithHandler(meter, downstreamDependencyMetadataManager: downstreamDependencyMetadataManagerMock.Object); + using var client = CreateClientWithHandler(meter, downstreamDependencyMetadataManager: downstreamDependencyMetadataManagerMock.Object); var requestMetadata = new RequestMetadata { @@ -277,7 +369,7 @@ public void PerfStopwatch_WithDownstreamDependencyMetadata_OnAsyncContext_Return _clockAdvanceMs = TimeAdvanceMs; - _ = client.GetAsync(_successfullUri, _cancellationTokenSource.Token).Result; + using var _ = client.GetAsync(_successfulUri, _cancellationTokenSource.Token).Result; var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -286,6 +378,7 @@ public void PerfStopwatch_WithDownstreamDependencyMetadata_OnAsyncContext_Return Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] @@ -294,7 +387,7 @@ public async Task SendAsync_WithDownstreamDependencyMetadata_OnAsyncContext_Exce using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); var dependencyDataManagerMock = new Mock(); - var client = CreateClientWithHandler(meter, downstreamDependencyMetadataManager: dependencyDataManagerMock.Object); + using var client = CreateClientWithHandler(meter, downstreamDependencyMetadataManager: dependencyDataManagerMock.Object); var requestMetadata = new RequestMetadata { @@ -304,8 +397,11 @@ public async Task SendAsync_WithDownstreamDependencyMetadata_OnAsyncContext_Exce }; dependencyDataManagerMock.Setup(m => m.GetRequestMetadata(It.IsAny())).Returns(requestMetadata); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failureUri); - await Assert.ThrowsAsync(async () => await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token)); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _failureUri); + await Assert.ThrowsAsync(async () => + { + using var _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); + }); var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -313,13 +409,15 @@ public async Task SendAsync_WithDownstreamDependencyMetadata_OnAsyncContext_Exce Assert.Equal("www.example-failure.com", latest.GetDimension(Metric.ReqHost)); Assert.Equal("failure_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("POST TestRequestName", latest.GetDimension(Metric.ReqName)); - Assert.Equal(500, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal((int)HttpStatusCode.ServiceUnavailable, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Failure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); } [Fact] public void GetHostName_Returns_Unknown_For_Empty_RequestUri() { - var c = HttpMeteringHandler.GetHostName(new HttpRequestMessage()); + using var requestMessage = new HttpRequestMessage(); + var c = HttpMeteringHandler.GetHostName(requestMessage); Assert.Equal(TelemetryConstants.Unknown, c); } @@ -331,19 +429,19 @@ public async Task SendAsync_MultiEnrich() { using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); - var client = CreateClientWithHandler(meter, new List + using var client = CreateClientWithHandler(meter, new List { new TestEnricher(i), }); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfullUri); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfulUri); httpRequestMessage.SetRequestMetadata(new RequestMetadata { DependencyName = "success_service", RequestRoute = "/foo" }); - _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); + using var _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -352,6 +450,7 @@ public async Task SendAsync_MultiEnrich() Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); for (int j = 0; j < i; j++) { @@ -361,30 +460,27 @@ public async Task SendAsync_MultiEnrich() } [Fact] - public async Task SendAsync_MultiEnrich_UsingIMeter() + public async Task SendAsync_MultiEnrich_UsingMeter() { for (int i = 1; i <= 14; i++) { using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); - var handler = new HttpMeteringHandler(meter, new List - { - new TestEnricher(i), - }) + using var handler = new HttpMeteringHandler(meter, new[] { new TestEnricher(i) }) { InnerHandler = new TestHandlerStub(InnerHandlerFunction) }; - var client = new System.Net.Http.HttpClient(handler); + using var client = new System.Net.Http.HttpClient(handler); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfullUri); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfulUri); httpRequestMessage.SetRequestMetadata(new RequestMetadata { DependencyName = "success_service", RequestRoute = "/foo" }); - _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); + using var _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; @@ -406,20 +502,20 @@ public async Task InvokeAsync_HttpMeteringHandler_MultipleEnrichers() { using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); - var client = CreateClientWithHandler(meter, new List + using var client = CreateClientWithHandler(meter, new List { new TestEnricher(2), new TestEnricher(2, "2"), }); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfullUri); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfulUri); httpRequestMessage.SetRequestMetadata(new RequestMetadata { DependencyName = "success_service", RequestRoute = "/foo" }); - _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); + using var _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; Assert.NotNull(latest); @@ -427,6 +523,7 @@ public async Task InvokeAsync_HttpMeteringHandler_MultipleEnrichers() Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); Assert.Equal("test_value_1", latest.GetDimension("test_property_1")); Assert.Equal("test_value_2", latest.GetDimension("test_property_2")); Assert.Equal("test_value_21", latest.GetDimension("test_property_21")); @@ -438,19 +535,19 @@ public async Task InvokeAsync_HttpMeteringHandler_PropertyBagEdgeCase() { using var meter = new Meter(); using var metricCollector = new MetricCollector(meter); - var client = CreateClientWithHandler(meter, new List + using var client = CreateClientWithHandler(meter, new List { new PropertyBagEdgeCaseEnricher(), }); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfullUri); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _successfulUri); httpRequestMessage.SetRequestMetadata(new RequestMetadata { DependencyName = "success_service", RequestRoute = "/foo" }); - _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); + using var _ = await client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token); var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; Assert.NotNull(latest); @@ -458,6 +555,7 @@ public async Task InvokeAsync_HttpMeteringHandler_PropertyBagEdgeCase() Assert.Equal("success_service", latest.GetDimension(Metric.DependencyName)); Assert.Equal("GET /foo", latest.GetDimension(Metric.ReqName)); Assert.Equal(201, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Success.ToString(), latest.GetDimension(Metric.RspResultCategory)); Assert.Equal("test_val", latest.GetDimension("non_null_object_property")); } @@ -466,10 +564,12 @@ public void HttpMeteringHandler_Fail_16DEnrich() { using var meter = new Meter(); Assert.Throws(() => - CreateClientWithHandler(meter, new List + { + using var _ = CreateClientWithHandler(meter, new List { - new TestEnricher(16) - })); + new TestEnricher(16) + }); + }); } [Fact] @@ -477,11 +577,13 @@ public void HttpMeteringHandler_Fail_RepeatCustomDimensions() { using var meter = new Meter(); Assert.Throws(() => - CreateClientWithHandler(meter, new List + { + using var _ = CreateClientWithHandler(meter, new List { - new TestEnricher(1), - new TestEnricher(1), - })); + new TestEnricher(1), + new TestEnricher(1), + }); + }); } [Fact] @@ -489,10 +591,12 @@ public void HttpMeteringHandler_Fail_RepeatDefaultDimensions() { using var meter = new Meter(); Assert.Throws(() => - CreateClientWithHandler(meter, new List + { + using var _ = CreateClientWithHandler(meter, new List { - new SameDefaultDimEnricher(), - })); + new SameDefaultDimEnricher(), + }); + }); } [Fact] @@ -520,15 +624,10 @@ public void ServiceCollection_AddMultipleOutgoingRequestEnrichersSuccessfully() using var provider = services.BuildServiceProvider(); var enrichersCollection = provider.GetServices(); - var enricherCount = 0; - foreach (var enricher in enrichersCollection) - { - enricherCount++; - } - - Assert.Equal(2, enricherCount); + Assert.Equal(2, enrichersCollection.Count()); } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Handler will be disposed with HttpClient")] private System.Net.Http.HttpClient CreateClientWithHandler( Meter meter, IEnumerable outgoingRequestMetricEnrichers) @@ -542,6 +641,7 @@ private System.Net.Http.HttpClient CreateClientWithHandler( return client; } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Handler will be disposed with HttpClient")] private System.Net.Http.HttpClient CreateClientWithHandler( Meter meter, IOutgoingRequestContext? requestMetadataContext = null, @@ -550,7 +650,7 @@ private System.Net.Http.HttpClient CreateClientWithHandler( var handler = new HttpMeteringHandler(meter, Array.Empty(), requestMetadataContext, downstreamDependencyMetadataManager) { InnerHandler = new TestHandlerStub(InnerHandlerFunction), - TimeProvider = _fakeTimeProvider + TimeProvider = _fakeTimeProvider, }; var client = new System.Net.Http.HttpClient(handler); @@ -565,8 +665,37 @@ private Task InnerHandlerFunction(HttpRequestMessage reques { throw new HttpRequestException("Something went wrong"); } + else if (request.RequestUri == _failure1Uri) + { + throw new TaskCanceledException("Timeout"); + } + else if (request.RequestUri == _failure2Uri) + { + throw new InvalidOperationException("Invalid Operation"); + } + else if (request.RequestUri == _failure3Uri) + { +#if NET6_0_OR_GREATER + throw new HttpRequestException("Something went wrong", null, HttpStatusCode.BadGateway); +#else + throw new HttpRequestException("Something went wrong"); +#endif + } + + HttpResponseMessage response; + if (request.RequestUri == _expectedFailureUri) + { + response = new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest }; + } + else if (request.RequestUri == _internalServerErrorUri) + { + response = new HttpResponseMessage { StatusCode = HttpStatusCode.InternalServerError }; + } + else + { + response = new HttpResponseMessage { StatusCode = HttpStatusCode.Created }; + } - var response = new HttpResponseMessage { StatusCode = HttpStatusCode.Created }; return Task.FromResult(response); } @@ -595,7 +724,7 @@ public static void AddHttpClientMetering_CreatesClientSuccessfully() var httpClientFactory = sp.GetRequiredService(); - using var httpClient = httpClientFactory?.CreateClient(); + using var httpClient = httpClientFactory.CreateClient(); Assert.NotNull(httpClient); } @@ -663,16 +792,13 @@ public static void AddDefaultHttpClientMetering_RequestMetadataSetSuccessfully() DependencyName = "success_service", RequestRoute = "/foo" }; - requestMetadataContext?.SetRequestMetadata(requestMetadata); - Assert.NotNull(requestMetadataContext); } [Fact] public static async Task AddDefaultHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt() { - var dependencyMetadata = new TestDownstreamDependencyMetadata(); var downstreamDependencyMetadataManagerMock = new Mock(); downstreamDependencyMetadataManagerMock .Setup(m => m.GetRequestMetadata(It.IsAny())) @@ -691,13 +817,13 @@ public static async Task AddDefaultHttpClientMetering_WithDownstreamDependencyMe .GetRequiredService() .CreateClient(nameof(AddDefaultHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); - _ = await client.GetAsync("https://contoso.com"); + using var _ = await client.GetAsync("https://www.bing.com"); downstreamDependencyMetadataManagerMock.Verify(m => m.GetRequestMetadata(It.IsAny()), Times.Once); } [Fact] - public static async Task AddtHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt() + public static async Task AddHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt() { var downstreamDependencyMetadataManagerMock = new Mock(); downstreamDependencyMetadataManagerMock @@ -706,7 +832,7 @@ public static async Task AddtHttpClientMetering_WithDownstreamDependencyMetadata using var sp = new ServiceCollection() .RegisterMetering() - .AddHttpClient(nameof(AddtHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)) + .AddHttpClient(nameof(AddHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)) .AddHttpClientMetering() .Services .AddDownstreamDependencyMetadata() @@ -716,11 +842,334 @@ public static async Task AddtHttpClientMetering_WithDownstreamDependencyMetadata var client = sp .GetRequiredService() - .CreateClient(nameof(AddtHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); + .CreateClient(nameof(AddHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); - _ = await client.GetAsync("https://contoso.com"); + using var _ = await client.GetAsync("https://www.bing.com"); downstreamDependencyMetadataManagerMock.Verify(m => m.GetRequestMetadata(It.IsAny()), Times.Once); } + +#if !NETFRAMEWORK + [Fact] + public static void AddHttpClientMeteringForAllHttpClients_NullServiceCollection_Throws() + { + IServiceCollection services = null!; + Assert.Throws(() => services.AddHttpClientMeteringForAllHttpClients()); + } + + [Fact] + public static void AddHttpClientMeteringForAllHttpClients_WithDownstreamDependencyMetadata_UsesIt() + { + var downstreamDependencyMetadataManagerMock = new Mock(); + downstreamDependencyMetadataManagerMock + .Setup(m => m.GetRequestMetadata(It.IsAny())) + .Returns(It.IsAny()); + + using var host = FakeHost.CreateBuilder() + .ConfigureServices((_, services) => services + .RegisterMetering() + .AddHttpClient() + .AddHttpClientMeteringForAllHttpClients() + .AddDownstreamDependencyMetadata() + .AddSingleton(downstreamDependencyMetadataManagerMock.Object)) + .Build(); + + host.Start(); + + var client = host.Services + .GetRequiredService() + .CreateClient(nameof(AddDefaultHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); + + using var _ = client.GetAsync("https://www.bing.com").Result; + HttpClientMeteringListener.UsingDiagnosticsSource = false; + + downstreamDependencyMetadataManagerMock.Verify(m => m.GetRequestMetadata(It.IsAny()), Times.AtLeastOnce); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + } + + [Fact] + public static void AddHttpClientMeteringForAllHttpClients_EmitMetrics_OnException() + { + var downstreamDependencyMetadataManagerMock = new Mock(); + downstreamDependencyMetadataManagerMock + .Setup(m => m.GetRequestMetadata(It.IsAny())) + .Returns(It.IsAny()); + + using var host = FakeHost.CreateBuilder() + .ConfigureServices((_, services) => services + .AddHttpClient() + .AddHttpClientMeteringForAllHttpClients() + .AddDownstreamDependencyMetadata() + .AddSingleton(downstreamDependencyMetadataManagerMock.Object)) + .Build(); + + host.Start(); + var client = host.Services + .GetRequiredService() + .CreateClient(nameof(AddDefaultHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); + + var meter = host.Services.GetRequiredService>(); + using var meterCollector = new MetricCollector(meter); + + DateTimeOffset startTime = DateTimeOffset.UtcNow; + Assert.Throws(() => client.GetAsync("https://localhost:12345").Result); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + + var record = meterCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(record); + Assert.True(record.Value > 0); + Assert.True(record.Value <= (DateTimeOffset.UtcNow - startTime).TotalMilliseconds); + Assert.Equal((int)HttpStatusCode.ServiceUnavailable, record.GetDimension(Metric.RspResultCode)); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + } + + [Fact] + public static void AddHttpClientMeteringForAllHttpClients_EmitMetrics_OnTaskCancelledException() + { + var downstreamDependencyMetadataManagerMock = new Mock(); + downstreamDependencyMetadataManagerMock + .Setup(m => m.GetRequestMetadata(It.IsAny())) + .Returns(It.IsAny()); + + using var host = FakeHost.CreateBuilder() + .ConfigureServices((_, services) => services + .AddHttpClient() + .AddHttpClientMeteringForAllHttpClients() + .AddDownstreamDependencyMetadata() + .AddSingleton(downstreamDependencyMetadataManagerMock.Object)) + .Build(); + + host.Start(); + var client = host.Services + .GetRequiredService() + .CreateClient(nameof(AddDefaultHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); + + var meter = host.Services.GetRequiredService>(); + using var meterCollector = new MetricCollector(meter); + + DateTimeOffset startTime = DateTimeOffset.UtcNow; + + var cts = new CancellationTokenSource(); + using var testObserver = new RequestCancellationTestObserver(cts); + + Assert.Throws(() => client.GetAsync("https://www.bing.com", cts.Token).Result); + + HttpClientMeteringListener.UsingDiagnosticsSource = false; + + var record = meterCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(record); + Assert.True(record.Value >= 0); + Assert.True(record.Value <= (DateTimeOffset.UtcNow - startTime).TotalMilliseconds); + Assert.Equal((int)HttpStatusCode.GatewayTimeout, record.GetDimension(Metric.RspResultCode)); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + } + + [Fact] + public static void AddHttpClientMeteringForAllHttpClients_EmitMetrics_OnError() + { + var downstreamDependencyMetadataManagerMock = new Mock(); + downstreamDependencyMetadataManagerMock + .Setup(m => m.GetRequestMetadata(It.IsAny())) + .Returns(It.IsAny()); + + using var host = FakeHost.CreateBuilder() + .ConfigureServices((_, services) => services + .AddHttpClient() + .AddHttpClientMeteringForAllHttpClients() + .AddDownstreamDependencyMetadata() + .AddSingleton(downstreamDependencyMetadataManagerMock.Object)) + .Build(); + + host.Start(); + var client = host.Services + .GetRequiredService() + .CreateClient(nameof(AddDefaultHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); + + var meter = host.Services.GetRequiredService>(); + using var meterCollector = new MetricCollector(meter); + + DateTimeOffset startTime = DateTimeOffset.UtcNow; + _ = client.GetAsync("https://www.bing.com/request").Result; + HttpClientMeteringListener.UsingDiagnosticsSource = false; + + var record = meterCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(record); + Assert.True(record.Value > 0); + Assert.True(record.Value <= (DateTimeOffset.UtcNow - startTime).TotalMilliseconds); + Assert.Equal((int)HttpStatusCode.NotFound, record.GetDimension(Metric.RspResultCode)); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + } + + [Fact] + public static void AddHttpClientMeteringForAllHttpClients_EmitMetrics_OnSuccessResponse() + { + var downstreamDependencyMetadataManagerMock = new Mock(); + downstreamDependencyMetadataManagerMock + .Setup(m => m.GetRequestMetadata(It.IsAny())) + .Returns(It.IsAny()); + + using var host = FakeHost.CreateBuilder() + .ConfigureServices((_, services) => services + .AddHttpClient() + .AddHttpClientMeteringForAllHttpClients() + .AddDownstreamDependencyMetadata() + .AddSingleton(downstreamDependencyMetadataManagerMock.Object)) + .Build(); + + host.Start(); + var client = host.Services + .GetRequiredService() + .CreateClient(nameof(AddDefaultHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); + + var meter = host.Services.GetRequiredService>(); + using var meterCollector = new MetricCollector(meter); + + DateTimeOffset startTime = DateTimeOffset.UtcNow; + using var _ = client.GetAsync("https://www.bing.com").Result; + HttpClientMeteringListener.UsingDiagnosticsSource = false; + + var record = meterCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(record); + Assert.True(record.Value > 0); + Assert.True(record.Value <= (DateTimeOffset.UtcNow - startTime).TotalMilliseconds); + Assert.Equal(200, record.GetDimension(Metric.RspResultCode)); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + } + + [Fact] + public static void AddHttpClientMeteringForAllHttpClients_CaptureMetrics_ForNonHttpClientFactoryClients() + { + var downstreamDependencyMetadataManagerMock = new Mock(); + downstreamDependencyMetadataManagerMock + .Setup(m => m.GetRequestMetadata(It.IsAny())) + .Returns(It.IsAny()); + + using var host = FakeHost.CreateBuilder() + .ConfigureServices((_, services) => services + .AddHttpClientMeteringForAllHttpClients()) + .Build(); + + host.Start(); + var meter = host.Services.GetRequiredService>(); + using var meterCollector = new MetricCollector(meter); + + using var client = new System.Net.Http.HttpClient(); + + DateTimeOffset startTime = DateTimeOffset.UtcNow; + using var _ = client.GetAsync("https://www.bing.com").Result; + HttpClientMeteringListener.UsingDiagnosticsSource = false; + + var record = meterCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(record); + Assert.True(record.Value > 0); + Assert.True(record.Value <= (DateTimeOffset.UtcNow - startTime).TotalMilliseconds); + Assert.Equal(200, record.GetDimension(Metric.RspResultCode)); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + } + + [Fact] + public static void When_DiagSourceAndDelegatingHandler_BothConfigured_MetricsOnlyEmittedOnce() + { + using var host = FakeHost.CreateBuilder() + .ConfigureServices((_, services) => services + .AddHttpClient() + .AddDefaultHttpClientMetering() + .AddHttpClientMeteringForAllHttpClients()) + .Build(); + + host.Start(); + var client = host.Services + .GetRequiredService() + .CreateClient(nameof(AddDefaultHttpClientMetering_WithDownstreamDependencyMetadata_UsesIt)); + + var meter = host.Services.GetRequiredService>(); + using var meterCollector = new MetricCollector(meter); + + DateTimeOffset startTime = DateTimeOffset.UtcNow; + using var _ = client.GetAsync("https://www.bing.com").Result; + HttpClientMeteringListener.UsingDiagnosticsSource = false; + + var records = meterCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!; + Assert.NotNull(records); + Assert.Equal(1, records.AllValues.Count); + + var record = records.LatestWritten!; + Assert.True(record.Value > 0); + Assert.True(record.Value <= (DateTimeOffset.UtcNow - startTime).TotalMilliseconds); + Assert.Equal(200, record.GetDimension(Metric.RspResultCode)); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + } + + [Fact] + public void HttpClientDiagnosticsObserver_OnError_DoesNotThrow() + { + using var meter = new Meter(); + using var httpMeteringHandler = new HttpMeteringHandler(meter, new List()); + using var httpClientDiagnosticsObserver = new HttpClientDiagnosticObserver(new HttpClientRequestAdapter(httpMeteringHandler)); + + Assert.NotNull(httpClientDiagnosticsObserver); + httpClientDiagnosticsObserver.OnError(new NotSupportedException()); + } + + [Fact] + public void HttpClientDiagnosticsObserver_OnNext_MultipleCalls_DoesNotThrow() + { + using var meter = new Meter(); + using var httpMeteringHandler = new HttpMeteringHandler(meter, new List()); + using var httpClientDiagnosticsObserver = new HttpClientDiagnosticObserver(new HttpClientRequestAdapter(httpMeteringHandler)); + + Assert.NotNull(httpClientDiagnosticsObserver); + + using var diagnosticsListener = new DiagnosticListener("HttpHandlerDiagnosticListener"); + httpClientDiagnosticsObserver.OnNext(diagnosticsListener); + httpClientDiagnosticsObserver.OnNext(diagnosticsListener); + } + + [Fact] + public void HttpClientRequestAdapter_OnRequestStop_TaskStatusNotFaultedOrCancelled_ResponseNull_EmitInternalServerError() + { + using var meter = new Meter(); + using var httpMeteringHandler = new HttpMeteringHandler(meter, new List()); + var httpClientRequestAdapter = new HttpClientRequestAdapter(httpMeteringHandler); + + Assert.NotNull(httpClientRequestAdapter); + + using var httpRequestMessage = new HttpRequestMessage(); + httpClientRequestAdapter.HttpClientListenerSubscribed(httpRequestMessage); + httpClientRequestAdapter.OnRequestStart(httpRequestMessage); + + using var meterCollector = new MetricCollector(meter); + + httpClientRequestAdapter.OnRequestStop(null, httpRequestMessage, TaskStatus.RanToCompletion); + + var records = meterCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!; + Assert.NotNull(records); + Assert.Equal(1, records.AllValues.Count); + + var record = records.LatestWritten!; + Assert.Equal((int)HttpStatusCode.InternalServerError, record.GetDimension(Metric.RspResultCode)); + HttpClientMeteringListener.UsingDiagnosticsSource = false; + } +#endif + + [Fact] + public void SendAsync_Failure_NoExceptionThrown() + { + using var meter = new Meter(); + using var metricCollector = new MetricCollector(meter); + using var client = CreateClientWithHandler(meter); + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, _internalServerErrorUri); + + using var _ = client.SendAsync(httpRequestMessage, _cancellationTokenSource.Token).Result; + + var latest = metricCollector.GetHistogramValues(Metric.OutgoingRequestMetricName)!.LatestWritten!; + Assert.NotNull(latest); + Assert.Equal("www.example-failure.com", latest.GetDimension(Metric.ReqHost)); + Assert.Equal(TelemetryConstants.Unknown, latest.GetDimension(Metric.DependencyName)); + Assert.Equal($"GET {TelemetryConstants.Unknown}", latest.GetDimension(Metric.ReqName)); + Assert.Equal((int)HttpStatusCode.InternalServerError, latest.GetDimension(Metric.RspResultCode)); + Assert.Equal(HttpRequestResultType.Failure.ToInvariantString(), latest.GetDimension(Metric.RspResultCategory)); + } #pragma warning restore VSTHRD002 // Avoid problematic synchronous waits } diff --git a/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/Internal/OverriddenHttpMeteringHandler.cs b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/Internal/OverriddenHttpMeteringHandler.cs new file mode 100644 index 00000000000..a7e9d678d06 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/Internal/OverriddenHttpMeteringHandler.cs @@ -0,0 +1,22 @@ +// 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 Microsoft.Extensions.Telemetry.Metering; + +namespace Microsoft.Extensions.Http.Telemetry.Metering.Test.Internal; + +internal sealed class OverriddenHttpMeteringHandler : HttpMeteringHandler +{ + public OverriddenHttpMeteringHandler( + Meter meter, + IEnumerable enrichers) + : base(meter, enrichers) + { + } + + public void ExternalDispose(bool dispose) + { + Dispose(dispose); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/Internal/RequestCancellationTestObserver.cs b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/Internal/RequestCancellationTestObserver.cs new file mode 100644 index 00000000000..74dea72949c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Metering/Internal/RequestCancellationTestObserver.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NETFRAMEWORK +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Threading; +using Microsoft.Extensions.DiagnosticAdapter; + +namespace Microsoft.Extensions.Http.Telemetry.Metering.Test.Internal; + +internal class RequestCancellationTestObserver : IObserver, IDisposable +{ + private const string ListenerName = "HttpHandlerDiagnosticListener"; + private readonly CancellationTokenSource _cts; + private IDisposable? _observer; + private IDisposable? _subscription; + + public RequestCancellationTestObserver(CancellationTokenSource cts) + { + _cts = cts; + Initialize(); + } + + public void OnNext(DiagnosticListener value) + { + if (value.Name == ListenerName) + { + _observer?.Dispose(); + _observer = value.SubscribeWithAdapter(this); + } + } + + public void OnCompleted() + { + // Method intentionally left empty. + } + + public void OnError(Exception error) + { + // Method intentionally left empty. + } + + public void Dispose() + { + _observer?.Dispose(); + _subscription?.Dispose(); + } + + [DiagnosticName("System.Net.Http.HttpRequestOut.Start")] + public virtual void OnRequestStart(HttpRequestMessage request) + { + _cts.Cancel(); + } + + private void Initialize() + { + _subscription = DiagnosticListener.AllListeners.Subscribe(this); + } +} +#endif diff --git a/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Microsoft.Extensions.Http.Telemetry.Tests.csproj b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Microsoft.Extensions.Http.Telemetry.Tests.csproj index 1905330b7f9..5d1202d1058 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Microsoft.Extensions.Http.Telemetry.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Http.Telemetry.Tests/Microsoft.Extensions.Http.Telemetry.Tests.csproj @@ -14,6 +14,7 @@ +