diff --git a/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj b/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj index 9b5d4268cb21..4a53a07a61b4 100644 --- a/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj +++ b/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj @@ -11,6 +11,28 @@ true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Hosting/Hosting/src/GenericHost/GenericWebHostBuilder.cs b/src/Hosting/Hosting/src/GenericHost/GenericWebHostBuilder.cs index ee232b610ad8..c4c5488e958e 100644 --- a/src/Hosting/Hosting/src/GenericHost/GenericWebHostBuilder.cs +++ b/src/Hosting/Hosting/src/GenericHost/GenericWebHostBuilder.cs @@ -62,12 +62,12 @@ public GenericWebHostBuilder(IHostBuilder builder, WebHostBuilderOptions options #pragma warning restore CS0618 // Type or member is obsolete services.Configure(options => - { - // Set the options - options.WebHostOptions = webHostOptions; - // Store and forward any startup errors - options.HostingStartupExceptions = _hostingStartupErrors; - }); + { + // Set the options + options.WebHostOptions = webHostOptions; + // Store and forward any startup errors + options.HostingStartupExceptions = _hostingStartupErrors; + }); // REVIEW: This is bad since we don't own this type. Anybody could add one of these and it would mess things up // We need to flow this differently @@ -80,6 +80,9 @@ public GenericWebHostBuilder(IHostBuilder builder, WebHostBuilderOptions options services.TryAddScoped(); services.TryAddSingleton(); + services.AddMetrics(); + services.TryAddSingleton(); + // IMPORTANT: This needs to run *before* direct calls on the builder (like UseStartup) _hostingStartupWebHostBuilder?.ConfigureServices(webhostContext, services); diff --git a/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs b/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs index d281484cc048..9ac3001c9b0f 100644 --- a/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs +++ b/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs @@ -26,7 +26,8 @@ public GenericWebHostService(IOptions options, IApplicationBuilderFactory applicationBuilderFactory, IEnumerable startupFilters, IConfiguration configuration, - IWebHostEnvironment hostingEnvironment) + IWebHostEnvironment hostingEnvironment, + HostingMetrics hostingMetrics) { Options = options.Value; Server = server; @@ -40,6 +41,7 @@ public GenericWebHostService(IOptions options, StartupFilters = startupFilters; Configuration = configuration; HostingEnvironment = hostingEnvironment; + HostingMetrics = hostingMetrics; } public GenericWebHostServiceOptions Options { get; } @@ -55,6 +57,7 @@ public GenericWebHostService(IOptions options, public IEnumerable StartupFilters { get; } public IConfiguration Configuration { get; } public IWebHostEnvironment HostingEnvironment { get; } + public HostingMetrics HostingMetrics { get; } public async Task StartAsync(CancellationToken cancellationToken) { @@ -153,7 +156,7 @@ static string ExpandPorts(string ports, string scheme) application = ErrorPageBuilder.BuildErrorPageApplication(HostingEnvironment.ContentRootFileProvider, Logger, showDetailedErrors, ex); } - var httpApplication = new HostingApplication(application, Logger, DiagnosticListener, ActivitySource, Propagator, HttpContextFactory); + var httpApplication = new HostingApplication(application, Logger, DiagnosticListener, ActivitySource, Propagator, HttpContextFactory, HostingEventSource.Log, HostingMetrics); await Server.StartAsync(httpApplication, cancellationToken); HostingEventSource.Log.ServerReady(); diff --git a/src/Hosting/Hosting/src/GenericHost/SlimWebHostBuilder.cs b/src/Hosting/Hosting/src/GenericHost/SlimWebHostBuilder.cs index 3ddeeba68fed..ff99aed202a7 100644 --- a/src/Hosting/Hosting/src/GenericHost/SlimWebHostBuilder.cs +++ b/src/Hosting/Hosting/src/GenericHost/SlimWebHostBuilder.cs @@ -53,6 +53,9 @@ public SlimWebHostBuilder(IHostBuilder builder, WebHostBuilderOptions options) services.TryAddSingleton(); services.TryAddScoped(); services.TryAddSingleton(); + + services.AddMetrics(); + services.TryAddSingleton(); }); } diff --git a/src/Hosting/Hosting/src/Internal/HostingApplication.cs b/src/Hosting/Hosting/src/Internal/HostingApplication.cs index a011cf6ab6b9..b880a7d3106b 100644 --- a/src/Hosting/Hosting/src/Internal/HostingApplication.cs +++ b/src/Hosting/Hosting/src/Internal/HostingApplication.cs @@ -23,10 +23,12 @@ public HostingApplication( DiagnosticListener diagnosticSource, ActivitySource activitySource, DistributedContextPropagator propagator, - IHttpContextFactory httpContextFactory) + IHttpContextFactory httpContextFactory, + HostingEventSource eventSource, + HostingMetrics metrics) { _application = application; - _diagnostics = new HostingApplicationDiagnostics(logger, diagnosticSource, activitySource, propagator); + _diagnostics = new HostingApplicationDiagnostics(logger, diagnosticSource, activitySource, propagator, eventSource, metrics); if (httpContextFactory is DefaultHttpContextFactory factory) { _defaultHttpContextFactory = factory; @@ -110,7 +112,7 @@ public void DisposeContext(Context context, Exception? exception) _httpContextFactory!.Dispose(httpContext); } - HostingApplicationDiagnostics.ContextDisposed(context); + _diagnostics.ContextDisposed(context); // Reset the context as it may be pooled context.Reset(); @@ -139,9 +141,10 @@ public Activity? Activity public long StartTimestamp { get; set; } internal bool HasDiagnosticListener { get; set; } - public bool EventLogEnabled { get; set; } + public bool EventLogOrMetricsEnabled { get; set; } internal IHttpActivityFeature? HttpActivityFeature; + internal HttpMetricsTagsFeature? MetricsTagsFeature; public void Reset() { @@ -153,7 +156,8 @@ public void Reset() StartTimestamp = 0; HasDiagnosticListener = false; - EventLogEnabled = false; + EventLogOrMetricsEnabled = false; + MetricsTagsFeature?.Tags.Clear(); } } } diff --git a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs index 381a63847b23..fb24de39de1c 100644 --- a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs +++ b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs @@ -8,14 +8,13 @@ using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Hosting; internal sealed class HostingApplicationDiagnostics { - private static readonly double TimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; - // internal so it can be used in tests internal const string ActivityName = "Microsoft.AspNetCore.Hosting.HttpRequestIn"; private const string ActivityStartKey = ActivityName + ".Start"; @@ -28,18 +27,24 @@ internal sealed class HostingApplicationDiagnostics private readonly ActivitySource _activitySource; private readonly DiagnosticListener _diagnosticListener; private readonly DistributedContextPropagator _propagator; + private readonly HostingEventSource _eventSource; + private readonly HostingMetrics _metrics; private readonly ILogger _logger; public HostingApplicationDiagnostics( ILogger logger, DiagnosticListener diagnosticListener, ActivitySource activitySource, - DistributedContextPropagator propagator) + DistributedContextPropagator propagator, + HostingEventSource eventSource, + HostingMetrics metrics) { _logger = logger; _diagnosticListener = diagnosticListener; _activitySource = activitySource; _propagator = propagator; + _eventSource = eventSource; + _metrics = metrics; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -47,9 +52,21 @@ public void BeginRequest(HttpContext httpContext, HostingApplication.Context con { long startTimestamp = 0; - if (HostingEventSource.Log.IsEnabled()) + if (_eventSource.IsEnabled() || _metrics.IsEnabled()) { - context.EventLogEnabled = true; + context.EventLogOrMetricsEnabled = true; + if (httpContext.Features.Get() is HttpMetricsTagsFeature feature) + { + context.MetricsTagsFeature = feature; + } + else + { + context.MetricsTagsFeature ??= new HttpMetricsTagsFeature(); + httpContext.Features.Set(context.MetricsTagsFeature); + } + + startTimestamp = Stopwatch.GetTimestamp(); + // To keep the hot path short we defer logging in this function to non-inlines RecordRequestStartEventLog(httpContext); } @@ -80,7 +97,11 @@ public void BeginRequest(HttpContext httpContext, HostingApplication.Context con { if (_diagnosticListener.IsEnabled(DeprecatedDiagnosticsBeginRequestKey)) { - startTimestamp = Stopwatch.GetTimestamp(); + if (startTimestamp == 0) + { + startTimestamp = Stopwatch.GetTimestamp(); + } + RecordBeginRequestDiagnostics(httpContext, startTimestamp); } } @@ -121,6 +142,25 @@ public void RequestEnd(HttpContext httpContext, Exception? exception, HostingApp currentTimestamp = Stopwatch.GetTimestamp(); // Non-inline LogRequestFinished(context, startTimestamp, currentTimestamp); + + if (context.EventLogOrMetricsEnabled) + { + var route = httpContext.GetEndpoint()?.Metadata.GetMetadata()?.Route; + var customTags = context.MetricsTagsFeature?.TagsList; + + _metrics.RequestEnd( + httpContext.Request.Protocol, + httpContext.Request.IsHttps, + httpContext.Request.Scheme, + httpContext.Request.Method, + httpContext.Request.Host, + route, + httpContext.Response.StatusCode, + exception, + customTags, + startTimestamp, + currentTimestamp); + } } if (_diagnosticListener.IsEnabled()) @@ -159,18 +199,18 @@ public void RequestEnd(HttpContext httpContext, Exception? exception, HostingApp StopActivity(httpContext, activity, context.HasDiagnosticListener); } - if (context.EventLogEnabled) + if (context.EventLogOrMetricsEnabled) { if (exception != null) { // Non-inline - HostingEventSource.Log.UnhandledException(); + _eventSource.UnhandledException(); } // Count 500 as failed requests if (httpContext.Response.StatusCode >= 500) { - HostingEventSource.Log.RequestFailed(); + _eventSource.RequestFailed(); } } @@ -179,12 +219,11 @@ public void RequestEnd(HttpContext httpContext, Exception? exception, HostingApp } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ContextDisposed(HostingApplication.Context context) + public void ContextDisposed(HostingApplication.Context context) { - if (context.EventLogEnabled) + if (context.EventLogOrMetricsEnabled) { - // Non-inline - HostingEventSource.Log.RequestStop(); + _eventSource.RequestStop(); } } @@ -211,7 +250,7 @@ private void LogRequestFinished(HostingApplication.Context context, long startTi // so check if we logged the start event if (context.StartLog != null) { - var elapsed = new TimeSpan((long)(TimestampToTicks * (currentTimestamp - startTimestamp))); + var elapsed = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); _logger.Log( logLevel: LogLevel.Information, @@ -302,9 +341,10 @@ internal UnhandledExceptionData(HttpContext httpContext, long timestamp, Excepti } [MethodImpl(MethodImplOptions.NoInlining)] - private static void RecordRequestStartEventLog(HttpContext httpContext) + private void RecordRequestStartEventLog(HttpContext httpContext) { - HostingEventSource.Log.RequestStart(httpContext.Request.Method, httpContext.Request.Path); + _metrics.RequestStart(httpContext.Request.IsHttps, httpContext.Request.Scheme, httpContext.Request.Method, httpContext.Request.Host); + _eventSource.RequestStart(httpContext.Request.Method, httpContext.Request.Path); } [MethodImpl(MethodImplOptions.NoInlining)] diff --git a/src/Hosting/Hosting/src/Internal/HostingEventSource.cs b/src/Hosting/Hosting/src/Internal/HostingEventSource.cs index 1a501f871680..40a8f17a651f 100644 --- a/src/Hosting/Hosting/src/Internal/HostingEventSource.cs +++ b/src/Hosting/Hosting/src/Internal/HostingEventSource.cs @@ -20,7 +20,7 @@ internal sealed class HostingEventSource : EventSource private long _failedRequests; internal HostingEventSource() - : this("Microsoft.AspNetCore.Hosting") + : base("Microsoft.AspNetCore.Hosting", EventSourceSettings.EtwManifestEventFormat) { } @@ -78,6 +78,7 @@ public void ServerReady() WriteEvent(6); } + [NonEvent] internal void RequestFailed() { Interlocked.Increment(ref _failedRequests); diff --git a/src/Hosting/Hosting/src/Internal/HostingMetrics.cs b/src/Hosting/Hosting/src/Internal/HostingMetrics.cs new file mode 100644 index 000000000000..87d3ddf796e5 --- /dev/null +++ b/src/Hosting/Hosting/src/Internal/HostingMetrics.cs @@ -0,0 +1,191 @@ +// 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.Frozen; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Metrics; + +namespace Microsoft.AspNetCore.Hosting; + +internal sealed class HostingMetrics : IDisposable +{ + public const string MeterName = "Microsoft.AspNetCore.Hosting"; + + private readonly Meter _meter; + private readonly UpDownCounter _currentRequestsCounter; + private readonly Histogram _requestDuration; + + public HostingMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.CreateMeter(MeterName); + + _currentRequestsCounter = _meter.CreateUpDownCounter( + "current-requests", + description: "Number of HTTP requests that are currently active on the server."); + + _requestDuration = _meter.CreateHistogram( + "request-duration", + unit: "s", + description: "The duration of HTTP requests on the server."); + } + + // Note: Calling code checks whether counter is enabled. + public void RequestStart(bool isHttps, string scheme, string method, HostString host) + { + // Tags must match request end. + var tags = new TagList(); + InitializeRequestTags(ref tags, isHttps, scheme, method, host); + _currentRequestsCounter.Add(1, tags); + } + + public void RequestEnd(string protocol, bool isHttps, string scheme, string method, HostString host, string? route, int statusCode, Exception? exception, List>? customTags, long startTimestamp, long currentTimestamp) + { + var tags = new TagList(); + InitializeRequestTags(ref tags, isHttps, scheme, method, host); + + // Tags must match request start. + if (_currentRequestsCounter.Enabled) + { + _currentRequestsCounter.Add(-1, tags); + } + + if (_requestDuration.Enabled) + { + tags.Add("protocol", protocol); + + // Add information gathered during request. + tags.Add("status-code", GetBoxedStatusCode(statusCode)); + if (route != null) + { + tags.Add("route", route); + } + // This exception is only present if there is an unhandled exception. + // An exception caught by ExceptionHandlerMiddleware and DeveloperExceptionMiddleware isn't thrown to here. Instead, those middleware add exception-name to custom tags. + if (exception != null) + { + tags.Add("exception-name", exception.GetType().FullName); + } + if (customTags != null) + { + for (var i = 0; i < customTags.Count; i++) + { + tags.Add(customTags[i]); + } + } + + var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); + _requestDuration.Record(duration.TotalSeconds, tags); + } + } + + public void Dispose() + { + _meter.Dispose(); + } + + public bool IsEnabled() => _currentRequestsCounter.Enabled || _requestDuration.Enabled; + + private static void InitializeRequestTags(ref TagList tags, bool isHttps, string scheme, string method, HostString host) + { + tags.Add("scheme", scheme); + tags.Add("method", method); + if (host.HasValue) + { + tags.Add("host", host.Host); + + // Port is parsed each time it's accessed. Store part in local variable. + if (host.Port is { } port) + { + // Add port tag when not the default value for the current scheme + if ((isHttps && port != 443) || (!isHttps && port != 80)) + { + tags.Add("port", port); + } + } + } + } + + // Status Codes listed at http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + private static readonly FrozenDictionary BoxedStatusCodes = FrozenDictionary.ToFrozenDictionary(new[] + { + KeyValuePair.Create(100, 100), + KeyValuePair.Create(101, 101), + KeyValuePair.Create(102, 102), + + KeyValuePair.Create(200, 200), + KeyValuePair.Create(201, 201), + KeyValuePair.Create(202, 202), + KeyValuePair.Create(203, 203), + KeyValuePair.Create(204, 204), + KeyValuePair.Create(205, 205), + KeyValuePair.Create(206, 206), + KeyValuePair.Create(207, 207), + KeyValuePair.Create(208, 208), + KeyValuePair.Create(226, 226), + + KeyValuePair.Create(300, 300), + KeyValuePair.Create(301, 301), + KeyValuePair.Create(302, 302), + KeyValuePair.Create(303, 303), + KeyValuePair.Create(304, 304), + KeyValuePair.Create(305, 305), + KeyValuePair.Create(306, 306), + KeyValuePair.Create(307, 307), + KeyValuePair.Create(308, 308), + + KeyValuePair.Create(400, 400), + KeyValuePair.Create(401, 401), + KeyValuePair.Create(402, 402), + KeyValuePair.Create(403, 403), + KeyValuePair.Create(404, 404), + KeyValuePair.Create(405, 405), + KeyValuePair.Create(406, 406), + KeyValuePair.Create(407, 407), + KeyValuePair.Create(408, 408), + KeyValuePair.Create(409, 409), + KeyValuePair.Create(410, 410), + KeyValuePair.Create(411, 411), + KeyValuePair.Create(412, 412), + KeyValuePair.Create(413, 413), + KeyValuePair.Create(414, 414), + KeyValuePair.Create(415, 415), + KeyValuePair.Create(416, 416), + KeyValuePair.Create(417, 417), + KeyValuePair.Create(418, 418), + KeyValuePair.Create(419, 419), + KeyValuePair.Create(421, 421), + KeyValuePair.Create(422, 422), + KeyValuePair.Create(423, 423), + KeyValuePair.Create(424, 424), + KeyValuePair.Create(426, 426), + KeyValuePair.Create(428, 428), + KeyValuePair.Create(429, 429), + KeyValuePair.Create(431, 431), + KeyValuePair.Create(451, 451), + KeyValuePair.Create(499, 499), + + KeyValuePair.Create(500, 500), + KeyValuePair.Create(501, 501), + KeyValuePair.Create(502, 502), + KeyValuePair.Create(503, 503), + KeyValuePair.Create(504, 504), + KeyValuePair.Create(505, 505), + KeyValuePair.Create(506, 506), + KeyValuePair.Create(507, 507), + KeyValuePair.Create(508, 508), + KeyValuePair.Create(510, 510), + KeyValuePair.Create(511, 511) + }, optimizeForReading: true); + + private static object GetBoxedStatusCode(int statusCode) + { + if (BoxedStatusCodes.TryGetValue(statusCode, out var result)) + { + return result; + } + + return statusCode; + } +} diff --git a/src/Hosting/Hosting/src/Internal/HttpMetricsTagsFeature.cs b/src/Hosting/Hosting/src/Internal/HttpMetricsTagsFeature.cs new file mode 100644 index 000000000000..9b11265e5db2 --- /dev/null +++ b/src/Hosting/Hosting/src/Internal/HttpMetricsTagsFeature.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http.Features; + +namespace Microsoft.AspNetCore.Hosting; + +internal sealed class HttpMetricsTagsFeature : IHttpMetricsTagsFeature +{ + public ICollection> Tags => TagsList; + + public List> TagsList { get; } = new List>(); +} diff --git a/src/Hosting/Hosting/src/Internal/WebHost.cs b/src/Hosting/Hosting/src/Internal/WebHost.cs index 548f2f506373..08fa768a7f82 100644 --- a/src/Hosting/Hosting/src/Internal/WebHost.cs +++ b/src/Hosting/Hosting/src/Internal/WebHost.cs @@ -141,7 +141,8 @@ public async Task StartAsync(CancellationToken cancellationToken = default) var activitySource = _applicationServices.GetRequiredService(); var propagator = _applicationServices.GetRequiredService(); var httpContextFactory = _applicationServices.GetRequiredService(); - var hostingApp = new HostingApplication(application, _logger, diagnosticSource, activitySource, propagator, httpContextFactory); + var hostingMetrics = _applicationServices.GetRequiredService(); + var hostingApp = new HostingApplication(application, _logger, diagnosticSource, activitySource, propagator, httpContextFactory, HostingEventSource.Log, hostingMetrics); await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false); _startedServer = true; diff --git a/src/Hosting/Hosting/src/WebHostBuilder.cs b/src/Hosting/Hosting/src/WebHostBuilder.cs index 0bcf9f15db84..3e00fa1af49d 100644 --- a/src/Hosting/Hosting/src/WebHostBuilder.cs +++ b/src/Hosting/Hosting/src/WebHostBuilder.cs @@ -294,6 +294,9 @@ private IServiceCollection BuildCommonServices(out AggregateException? hostingSt services.AddOptions(); services.AddLogging(); + services.AddMetrics(); + services.TryAddSingleton(); + services.AddTransient, DefaultServiceProviderFactory>(); if (!string.IsNullOrEmpty(_options.StartupAssembly)) diff --git a/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs b/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs index 56da9f6d59f8..b42bb72e1bce 100644 --- a/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs +++ b/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs @@ -2,16 +2,126 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Diagnostics.Tracing; using System.Reflection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Internal; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Metrics; using Moq; namespace Microsoft.AspNetCore.Hosting.Tests; public class HostingApplicationDiagnosticsTests { + [Fact] + public async Task EventCountersAndMetricsValues() + { + // Arrange + var hostingEventSource = new HostingEventSource(Guid.NewGuid().ToString()); + + var eventListener = new TestCounterListener(new[] + { + "requests-per-second", + "total-requests", + "current-requests", + "failed-requests" + }); + + var timeout = !Debugger.IsAttached ? TimeSpan.FromSeconds(30) : Timeout.InfiniteTimeSpan; + using CancellationTokenSource timeoutTokenSource = new CancellationTokenSource(timeout); + + var rpsValues = eventListener.GetCounterValues("requests-per-second", timeoutTokenSource.Token).GetAsyncEnumerator(); + var totalRequestValues = eventListener.GetCounterValues("total-requests", timeoutTokenSource.Token).GetAsyncEnumerator(); + var currentRequestValues = eventListener.GetCounterValues("current-requests", timeoutTokenSource.Token).GetAsyncEnumerator(); + var failedRequestValues = eventListener.GetCounterValues("failed-requests", timeoutTokenSource.Token).GetAsyncEnumerator(); + + eventListener.EnableEvents(hostingEventSource, EventLevel.Informational, EventKeywords.None, + new Dictionary + { + { "EventCounterIntervalSec", "1" } + }); + + var testMeterFactory1 = new TestMeterFactory(); + var testMeterRegister1 = new TestMeterRegistry(testMeterFactory1.Meters); + var testMeterFactory2 = new TestMeterFactory(); + var testMeterRegister2 = new TestMeterRegistry(testMeterFactory2.Meters); + + var hostingApplication1 = CreateApplication(out var features1, eventSource: hostingEventSource, meterFactory: testMeterFactory1); + var hostingApplication2 = CreateApplication(out var features2, eventSource: hostingEventSource, meterFactory: testMeterFactory2); + + using var currentRequestsRecorder1 = new InstrumentRecorder(testMeterRegister1, HostingMetrics.MeterName, "current-requests"); + using var currentRequestsRecorder2 = new InstrumentRecorder(testMeterRegister2, HostingMetrics.MeterName, "current-requests"); + using var requestDurationRecorder1 = new InstrumentRecorder(testMeterRegister1, HostingMetrics.MeterName, "request-duration"); + using var requestDurationRecorder2 = new InstrumentRecorder(testMeterRegister2, HostingMetrics.MeterName, "request-duration"); + + // Act/Assert 1 + var context1 = hostingApplication1.CreateContext(features1); + var context2 = hostingApplication2.CreateContext(features2); + + Assert.Equal(2, await totalRequestValues.FirstOrDefault(v => v == 2)); + Assert.Equal(2, await rpsValues.FirstOrDefault(v => v == 2)); + Assert.Equal(2, await currentRequestValues.FirstOrDefault(v => v == 2)); + Assert.Equal(0, await failedRequestValues.FirstOrDefault(v => v == 0)); + + hostingApplication1.DisposeContext(context1, null); + hostingApplication2.DisposeContext(context2, null); + + Assert.Equal(2, await totalRequestValues.FirstOrDefault(v => v == 2)); + Assert.Equal(0, await rpsValues.FirstOrDefault(v => v == 0)); + Assert.Equal(0, await currentRequestValues.FirstOrDefault(v => v == 0)); + Assert.Equal(0, await failedRequestValues.FirstOrDefault(v => v == 0)); + + Assert.Collection(currentRequestsRecorder1.GetMeasurements(), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value)); + Assert.Collection(currentRequestsRecorder2.GetMeasurements(), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value)); + Assert.Collection(requestDurationRecorder1.GetMeasurements(), + m => Assert.True(m.Value > 0)); + Assert.Collection(requestDurationRecorder2.GetMeasurements(), + m => Assert.True(m.Value > 0)); + + // Act/Assert 2 + context1 = hostingApplication1.CreateContext(features1); + context2 = hostingApplication2.CreateContext(features2); + + Assert.Equal(4, await totalRequestValues.FirstOrDefault(v => v == 4)); + Assert.Equal(2, await rpsValues.FirstOrDefault(v => v == 2)); + Assert.Equal(2, await currentRequestValues.FirstOrDefault(v => v == 2)); + Assert.Equal(0, await failedRequestValues.FirstOrDefault(v => v == 0)); + + context1.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + context2.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + + hostingApplication1.DisposeContext(context1, null); + hostingApplication2.DisposeContext(context2, null); + + Assert.Equal(4, await totalRequestValues.FirstOrDefault(v => v == 4)); + Assert.Equal(0, await rpsValues.FirstOrDefault(v => v == 0)); + Assert.Equal(0, await currentRequestValues.FirstOrDefault(v => v == 0)); + Assert.Equal(2, await failedRequestValues.FirstOrDefault(v => v == 2)); + + Assert.Collection(currentRequestsRecorder1.GetMeasurements(), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value)); + Assert.Collection(currentRequestsRecorder2.GetMeasurements(), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value)); + Assert.Collection(requestDurationRecorder1.GetMeasurements(), + m => Assert.True(m.Value > 0), + m => Assert.True(m.Value > 0)); + Assert.Collection(requestDurationRecorder2.GetMeasurements(), + m => Assert.True(m.Value > 0), + m => Assert.True(m.Value > 0)); + } + [Fact] public void DisposeContextDoesNotThrowWhenContextScopeIsNull() { @@ -573,12 +683,14 @@ private static void AssertProperty(object o, string name) } private static HostingApplication CreateApplication(out FeatureCollection features, - DiagnosticListener diagnosticListener = null, ActivitySource activitySource = null, ILogger logger = null, Action configure = null) + DiagnosticListener diagnosticListener = null, ActivitySource activitySource = null, ILogger logger = null, + Action configure = null, HostingEventSource eventSource = null, IMeterFactory meterFactory = null) { var httpContextFactory = new Mock(); features = new FeatureCollection(); features.Set(new HttpRequestFeature()); + features.Set(new HttpResponseFeature()); var context = new DefaultHttpContext(features); configure?.Invoke(context); httpContextFactory.Setup(s => s.Create(It.IsAny())).Returns(context); @@ -590,7 +702,9 @@ private static HostingApplication CreateApplication(out FeatureCollection featur diagnosticListener ?? new NoopDiagnosticListener(), activitySource ?? new ActivitySource("Microsoft.AspNetCore"), DistributedContextPropagator.CreateDefaultPropagator(), - httpContextFactory.Object); + httpContextFactory.Object, + eventSource ?? HostingEventSource.Log, + new HostingMetrics(meterFactory ?? new TestMeterFactory())); return hostingApplication; } diff --git a/src/Hosting/Hosting/test/HostingApplicationTests.cs b/src/Hosting/Hosting/test/HostingApplicationTests.cs index 3d263e0948b2..a0154713d137 100644 --- a/src/Hosting/Hosting/test/HostingApplicationTests.cs +++ b/src/Hosting/Hosting/test/HostingApplicationTests.cs @@ -3,12 +3,15 @@ using System.Collections; using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Hosting.Fakes; using Microsoft.AspNetCore.Hosting.Server.Abstractions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Metrics; using Moq; using static Microsoft.AspNetCore.Hosting.HostingApplication; @@ -16,6 +19,95 @@ namespace Microsoft.AspNetCore.Hosting.Tests; public class HostingApplicationTests { + [Fact] + public void Metrics() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var meterRegistry = new TestMeterRegistry(meterFactory.Meters); + var hostingApplication = CreateApplication(meterFactory: meterFactory); + var httpContext = new DefaultHttpContext(); + var meter = meterFactory.Meters.Single(); + + using var requestDurationRecorder = new InstrumentRecorder(meterRegistry, HostingMetrics.MeterName, "request-duration"); + using var currentRequestsRecorder = new InstrumentRecorder(meterRegistry, HostingMetrics.MeterName, "current-requests"); + + // Act/Assert + Assert.Equal(HostingMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + // Request 1 (after success) + httpContext.Request.Protocol = HttpProtocol.Http11; + var context1 = hostingApplication.CreateContext(httpContext.Features); + context1.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + hostingApplication.DisposeContext(context1, null); + + Assert.Collection(currentRequestsRecorder.GetMeasurements(), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value)); + Assert.Collection(requestDurationRecorder.GetMeasurements(), + m => AssertRequestDuration(m, HttpProtocol.Http11, StatusCodes.Status200OK)); + + // Request 2 (after failure) + httpContext.Request.Protocol = HttpProtocol.Http2; + var context2 = hostingApplication.CreateContext(httpContext.Features); + context2.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + hostingApplication.DisposeContext(context2, new InvalidOperationException("Test error")); + + Assert.Collection(currentRequestsRecorder.GetMeasurements(), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value)); + Assert.Collection(requestDurationRecorder.GetMeasurements(), + m => AssertRequestDuration(m, HttpProtocol.Http11, StatusCodes.Status200OK), + m => AssertRequestDuration(m, HttpProtocol.Http2, StatusCodes.Status500InternalServerError, exceptionName: "System.InvalidOperationException")); + + // Request 3 + httpContext.Request.Protocol = HttpProtocol.Http3; + var context3 = hostingApplication.CreateContext(httpContext.Features); + context3.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + + Assert.Collection(currentRequestsRecorder.GetMeasurements(), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value), + m => Assert.Equal(1, m.Value)); + Assert.Collection(requestDurationRecorder.GetMeasurements(), + m => AssertRequestDuration(m, HttpProtocol.Http11, StatusCodes.Status200OK), + m => AssertRequestDuration(m, HttpProtocol.Http2, StatusCodes.Status500InternalServerError, exceptionName: "System.InvalidOperationException")); + + hostingApplication.DisposeContext(context3, null); + + Assert.Collection(currentRequestsRecorder.GetMeasurements(), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value), + m => Assert.Equal(1, m.Value), + m => Assert.Equal(-1, m.Value)); + Assert.Collection(requestDurationRecorder.GetMeasurements(), + m => AssertRequestDuration(m, HttpProtocol.Http11, StatusCodes.Status200OK), + m => AssertRequestDuration(m, HttpProtocol.Http2, StatusCodes.Status500InternalServerError, exceptionName: "System.InvalidOperationException"), + m => AssertRequestDuration(m, HttpProtocol.Http3, StatusCodes.Status200OK)); + + static void AssertRequestDuration(Measurement measurement, string protocol, int statusCode, string exceptionName = null) + { + Assert.True(measurement.Value > 0); + Assert.Equal(protocol, (string)measurement.Tags.ToArray().Single(t => t.Key == "protocol").Value); + Assert.Equal(statusCode, (int)measurement.Tags.ToArray().Single(t => t.Key == "status-code").Value); + if (exceptionName == null) + { + Assert.DoesNotContain(measurement.Tags.ToArray(), t => t.Key == "exception-name"); + } + else + { + Assert.Equal(exceptionName, (string)measurement.Tags.ToArray().Single(t => t.Key == "exception-name").Value); + } + } + } + [Fact] public void DisposeContextDoesNotClearHttpContextIfDefaultHttpContextFactoryUsed() { @@ -183,7 +275,7 @@ public void IHttpActivityFeatureIsNotPopulatedWithoutAListener() } private static HostingApplication CreateApplication(IHttpContextFactory httpContextFactory = null, bool useHttpContextAccessor = false, - ActivitySource activitySource = null) + ActivitySource activitySource = null, IMeterFactory meterFactory = null) { var services = new ServiceCollection(); services.AddOptions(); @@ -200,7 +292,9 @@ private static HostingApplication CreateApplication(IHttpContextFactory httpCont new DiagnosticListener("Microsoft.AspNetCore"), activitySource ?? new ActivitySource("Microsoft.AspNetCore"), DistributedContextPropagator.CreateDefaultPropagator(), - httpContextFactory); + httpContextFactory, + HostingEventSource.Log, + new HostingMetrics(meterFactory ?? new TestMeterFactory())); return hostingApplication; } diff --git a/src/Hosting/Hosting/test/Internal/HostingEventSourceTests.cs b/src/Hosting/Hosting/test/Internal/HostingEventSourceTests.cs index 6b3398bf239d..6c3e96453842 100644 --- a/src/Hosting/Hosting/test/Internal/HostingEventSourceTests.cs +++ b/src/Hosting/Hosting/test/Internal/HostingEventSourceTests.cs @@ -1,8 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Globalization; using System.Diagnostics.Tracing; +using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Internal; @@ -80,8 +80,8 @@ public static TheoryData RequestStartData context, new string[] { - "GET", - "/Home/Index" + "GET", + "/Home/Index" }); context = new DefaultHttpContext(); @@ -91,8 +91,8 @@ public static TheoryData RequestStartData context, new string[] { - "POST", - "/" + "POST", + "/" }); return variations; @@ -177,12 +177,13 @@ public void UnhandledException() public async Task VerifyCountersFireWithCorrectValues() { // Arrange - var eventListener = new TestCounterListener(new[] { - "requests-per-second", - "total-requests", - "current-requests", - "failed-requests" - }); + var eventListener = new TestCounterListener(new[] + { + "requests-per-second", + "total-requests", + "current-requests", + "failed-requests" + }); var hostingEventSource = GetHostingEventSource(); diff --git a/src/Http/Http.Abstractions/src/Metadata/IRouteDiagnosticsMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IRouteDiagnosticsMetadata.cs new file mode 100644 index 000000000000..b1def2762869 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IRouteDiagnosticsMetadata.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Metadata; + +/// +/// Interface for specifing diagnostics text for a route. +/// +public interface IRouteDiagnosticsMetadata +{ + /// + /// Gets diagnostics text for a route. + /// + string Route { get; } +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index f7c76295dc16..207268dc85fc 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -6,6 +6,8 @@ Microsoft.AspNetCore.Http.HttpResults.EmptyHttpResult Microsoft.AspNetCore.Http.HttpResults.EmptyHttpResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Http.HttpValidationProblemDetails.Errors.set -> void Microsoft.AspNetCore.Http.IProblemDetailsService.TryWriteAsync(Microsoft.AspNetCore.Http.ProblemDetailsContext! context) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.Http.Metadata.IRouteDiagnosticsMetadata +Microsoft.AspNetCore.Http.Metadata.IRouteDiagnosticsMetadata.Route.get -> string! Microsoft.AspNetCore.Mvc.ProblemDetails.Extensions.set -> void static Microsoft.AspNetCore.Http.EndpointFilterInvocationContext.Create(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> Microsoft.AspNetCore.Http.EndpointFilterInvocationContext! static Microsoft.AspNetCore.Http.EndpointFilterInvocationContext.Create(Microsoft.AspNetCore.Http.HttpContext! httpContext, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8) -> Microsoft.AspNetCore.Http.EndpointFilterInvocationContext! diff --git a/src/Http/Http.Features/src/IHttpMetricsTagsFeature.cs b/src/Http/Http.Features/src/IHttpMetricsTagsFeature.cs new file mode 100644 index 000000000000..b7c556f75f1b --- /dev/null +++ b/src/Http/Http.Features/src/IHttpMetricsTagsFeature.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Features; + +/// +/// Provides access to tags added to the metrics HTTP request counter. This feature isn't set if the counter isn't enabled. +/// +public interface IHttpMetricsTagsFeature +{ + /// + /// Gets the tag collection. + /// + ICollection> Tags { get; } +} diff --git a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..d87843c45303 100644 --- a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Http.Features.IHttpMetricsTagsFeature +Microsoft.AspNetCore.Http.Features.IHttpMetricsTagsFeature.Tags.get -> System.Collections.Generic.ICollection>! diff --git a/src/Http/Routing/src/Patterns/RoutePattern.cs b/src/Http/Routing/src/Patterns/RoutePattern.cs index a4018b432baf..cb0930b3cace 100644 --- a/src/Http/Routing/src/Patterns/RoutePattern.cs +++ b/src/Http/Routing/src/Patterns/RoutePattern.cs @@ -149,6 +149,10 @@ internal RoutePattern( return null; } + // Used for: + // 1. RoutePattern debug string. + // 2. Default IRouteDiagnosticsMetadata value. + // 3. RouteEndpoint display name. internal string DebuggerToString() { return RawText ?? string.Join(SeparatorString, PathSegments.Select(s => s.DebuggerToString())); diff --git a/src/Http/Routing/src/RouteEndpointBuilder.cs b/src/Http/Routing/src/RouteEndpointBuilder.cs index 8c37f1047991..62908a14d174 100644 --- a/src/Http/Routing/src/RouteEndpointBuilder.cs +++ b/src/Http/Routing/src/RouteEndpointBuilder.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Routing; @@ -53,12 +54,14 @@ public override Endpoint Build() RequestDelegate, RoutePattern, Order, - CreateMetadataCollection(Metadata), + CreateMetadataCollection(Metadata, RoutePattern), DisplayName); } - private static EndpointMetadataCollection CreateMetadataCollection(IList metadata) + private static EndpointMetadataCollection CreateMetadataCollection(IList metadata, RoutePattern routePattern) { + var hasRouteDiagnosticsMetadata = false; + if (metadata.Count > 0) { var hasCorsMetadata = false; @@ -85,6 +88,11 @@ private static EndpointMetadataCollection CreateMetadataCollection(IList // are ICorsMetadata hasCorsMetadata = true; } + + if (!hasRouteDiagnosticsMetadata && metadata[i] is IRouteDiagnosticsMetadata) + { + hasRouteDiagnosticsMetadata = true; + } } if (hasCorsMetadata && httpMethodMetadata is not null && !httpMethodMetadata.AcceptCorsPreflight) @@ -95,6 +103,22 @@ private static EndpointMetadataCollection CreateMetadataCollection(IList } } + // No route diagnostics metadata provided so automatically add one based on the route pattern string. + if (!hasRouteDiagnosticsMetadata) + { + metadata.Add(new RouteDiagnosticsMetadata(routePattern.DebuggerToString())); + } + return new EndpointMetadataCollection(metadata); } + + private sealed class RouteDiagnosticsMetadata : IRouteDiagnosticsMetadata + { + public string Route { get; } + + public RouteDiagnosticsMetadata(string route) + { + Route = route; + } + } } diff --git a/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs index c4924422a748..451c408d004b 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs @@ -411,10 +411,11 @@ public void MapEndpoint_PrecedenceOfMetadata_BuilderMetadataReturned() // As with the Delegate Map method overloads for route handlers, the attributes on the RequestDelegate // can override the HttpMethodMetadata. Extension methods could already do this. - Assert.Equal(3, endpoint.Metadata.Count); + Assert.Equal(4, endpoint.Metadata.Count); Assert.Equal("METHOD", GetMethod(endpoint.Metadata[0])); Assert.Equal("ATTRIBUTE", GetMethod(endpoint.Metadata[1])); Assert.Equal("BUILDER", GetMethod(endpoint.Metadata[2])); + Assert.IsAssignableFrom(endpoint.Metadata[3]); Assert.Equal("BUILDER", endpoint.Metadata.GetMetadata()?.HttpMethods.Single()); @@ -499,7 +500,8 @@ public void Map_AddsMetadata_InCorrectOrder() }, m => Assert.Equal("System.Runtime.CompilerServices.NullableContextAttribute", m.ToString()), m => Assert.IsAssignableFrom(m), - m => Assert.IsAssignableFrom(m)); + m => Assert.IsAssignableFrom(m), + m => Assert.IsAssignableFrom(m)); } [Fact] diff --git a/src/Http/Routing/test/UnitTests/CompositeEndpointDataSourceTest.cs b/src/Http/Routing/test/UnitTests/CompositeEndpointDataSourceTest.cs index 4e6b5677ea58..0e5fb5d5d255 100644 --- a/src/Http/Routing/test/UnitTests/CompositeEndpointDataSourceTest.cs +++ b/src/Http/Routing/test/UnitTests/CompositeEndpointDataSourceTest.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.AspNetCore.Routing.TestObjects; using Microsoft.Extensions.DependencyInjection; @@ -307,8 +308,9 @@ public void GetGroupedEndpoints_ForwardedToChildDataSources() var resolvedEndpoint = Assert.IsType(Assert.Single(groupedEndpoints)); Assert.Equal("/prefix/a", resolvedEndpoint.RoutePattern.RawText); - var resolvedMetadata = Assert.Single(resolvedEndpoint.Metadata); - Assert.Same(metadata, resolvedMetadata); + Assert.Collection(resolvedEndpoint.Metadata, + m => Assert.Same(metadata, m), + m => Assert.IsAssignableFrom(m)); } [Fact] diff --git a/src/Http/Routing/test/UnitTests/RouteEndpointBuilderTest.cs b/src/Http/Routing/test/UnitTests/RouteEndpointBuilderTest.cs index 669962719644..58010bdfa32c 100644 --- a/src/Http/Routing/test/UnitTests/RouteEndpointBuilderTest.cs +++ b/src/Http/Routing/test/UnitTests/RouteEndpointBuilderTest.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Routing; @@ -41,7 +42,37 @@ public void Build_AllValuesSet_EndpointCreated() Assert.Equal(defaultOrder, endpoint.Order); Assert.Equal(requestDelegate, endpoint.RequestDelegate); Assert.Equal("/", endpoint.RoutePattern.RawText); - Assert.Equal(metadata, Assert.Single(endpoint.Metadata)); + Assert.Collection(endpoint.Metadata, + m => Assert.Same(metadata, m), + m => Assert.IsAssignableFrom(m)); + } + + [Fact] + public void Build_CustomRouteDiagnosticsMetadata_EndpointCreated() + { + const int defaultOrder = 0; + var metadata = new object(); + RequestDelegate requestDelegate = (d) => null; + + var builder = new RouteEndpointBuilder(requestDelegate, RoutePatternFactory.Parse("/"), defaultOrder) + { + Metadata = { new TestRouteDiaganosticsMetadata { Route = "Test" }, metadata } + }; + + var endpoint = Assert.IsType(builder.Build()); + Assert.Equal("/", endpoint.RoutePattern.RawText); + Assert.Collection(endpoint.Metadata, + m => + { + var metadata = Assert.IsAssignableFrom(m); + Assert.Equal("Test", metadata.Route); + }, + m => Assert.Equal(metadata, m)); + } + + private sealed class TestRouteDiaganosticsMetadata : IRouteDiagnosticsMetadata + { + public string Route { get; init; } } [Fact] diff --git a/src/Http/samples/MinimalSample/Program.cs b/src/Http/samples/MinimalSample/Program.cs index 0aa262f38887..c8daec6d2031 100644 --- a/src/Http/samples/MinimalSample/Program.cs +++ b/src/Http/samples/MinimalSample/Program.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Reflection; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Metadata; @@ -9,6 +10,8 @@ var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); +app.Logger.LogInformation($"Current process ID: {Process.GetCurrentProcess().Id}"); + string Plaintext() => "Hello, World!"; app.MapGet("/plaintext", Plaintext); diff --git a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs index 44c489e27cc6..6c4e375a075f 100644 --- a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs +++ b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs @@ -129,7 +129,7 @@ public async Task Invoke(HttpContext context) return; } - _logger.UnhandledException(ex); + DiagnosticsTelemetry.ReportUnhandledException(_logger, context, ex); if (context.Response.HasStarted) { diff --git a/src/Middleware/Diagnostics/src/DiagnosticsTelemetry.cs b/src/Middleware/Diagnostics/src/DiagnosticsTelemetry.cs new file mode 100644 index 000000000000..28e0db455fd9 --- /dev/null +++ b/src/Middleware/Diagnostics/src/DiagnosticsTelemetry.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Diagnostics; + +internal static class DiagnosticsTelemetry +{ + public static void ReportUnhandledException(ILogger logger, HttpContext context, Exception ex) + { + logger.UnhandledException(ex); + + if (context.Features.Get() is { } tagsFeature) + { + tagsFeature.Tags.Add(new KeyValuePair("exception-name", ex.GetType().FullName)); + } + } +} diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs index 79c5125a22f1..25c6f23aaaaf 100644 --- a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs @@ -124,7 +124,8 @@ private async Task HandleException(HttpContext context, ExceptionDispatchInfo ed return; } - _logger.UnhandledException(edi.SourceException); + DiagnosticsTelemetry.ReportUnhandledException(_logger, context, edi.SourceException); + // We can't do anything if the response has already started, just abort. if (context.Response.HasStarted) { diff --git a/src/Middleware/Diagnostics/test/UnitTests/DeveloperExceptionPageMiddlewareTest.cs b/src/Middleware/Diagnostics/test/UnitTests/DeveloperExceptionPageMiddlewareTest.cs index 7ae5afb0d2c2..7c7a0f2e2b18 100644 --- a/src/Middleware/Diagnostics/test/UnitTests/DeveloperExceptionPageMiddlewareTest.cs +++ b/src/Middleware/Diagnostics/test/UnitTests/DeveloperExceptionPageMiddlewareTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; @@ -11,12 +12,14 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Metrics; namespace Microsoft.AspNetCore.Diagnostics; -public class DeveloperExceptionPageMiddlewareTest +public class DeveloperExceptionPageMiddlewareTest : LoggedTest { [Fact] public async Task ExceptionHandlerFeatureIsAvailableInCustomizeProblemDetailsWhenUsingExceptionPage() @@ -482,6 +485,61 @@ public async Task NullInfoInCompilationException_ShouldNotThrowExceptionGenerati Assert.Null(listener.DiagnosticHandledException?.Exception); } + [Fact] + public async Task UnhandledError_ExceptionNameTagAdded() + { + // Arrange + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var meterFactory = new TestMeterFactory(); + var meterRegistry = new TestMeterRegistry(meterFactory.Meters); + var instrumentRecorder = new InstrumentRecorder(meterRegistry, "Microsoft.AspNetCore.Hosting", "request-duration"); + instrumentRecorder.Register(m => + { + tcs.SetResult(); + }); + + using var host = new HostBuilder() + .ConfigureServices(s => + { + s.AddSingleton(meterFactory); + s.AddSingleton(LoggerFactory); + }) + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .Configure(app => + { + app.UseDeveloperExceptionPage(); + app.Run(context => + { + throw new Exception("Test exception"); + }); + }); + }).Build(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + + // Act + var response = await server.CreateClient().GetAsync("/path"); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + + await tcs.Task.DefaultTimeout(); + + // Assert + Assert.Collection( + instrumentRecorder.GetMeasurements(), + m => + { + Assert.True(m.Value > 0); + Assert.Equal(500, (int)m.Tags.ToArray().Single(t => t.Key == "status-code").Value); + Assert.Equal("System.Exception", (string)m.Tags.ToArray().Single(t => t.Key == "exception-name").Value); + }); + } + public class CustomCompilationException : Exception, ICompilationException { public CustomCompilationException(IEnumerable compilationFailures) diff --git a/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs b/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs index 9b7053d4c050..6b9cc6f9e42b 100644 --- a/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs +++ b/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs @@ -7,10 +7,12 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Metrics; namespace Microsoft.AspNetCore.Diagnostics; @@ -907,4 +909,65 @@ public async Task ExceptionHandlerWithExceptionHandlerNotReplacedWithGlobalRoute Assert.Equal("Custom handler", await response.Content.ReadAsStringAsync()); } } + + [Fact] + public async Task UnhandledError_ExceptionNameTagAdded() + { + // Arrange + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var meterFactory = new TestMeterFactory(); + var meterRegistry = new TestMeterRegistry(meterFactory.Meters); + var instrumentRecorder = new InstrumentRecorder(meterRegistry, "Microsoft.AspNetCore.Hosting", "request-duration"); + instrumentRecorder.Register(m => + { + tcs.SetResult(); + }); + + using var host = new HostBuilder() + .ConfigureServices(s => + { + s.AddSingleton(meterFactory); + }) + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .Configure(app => + { + app.UseExceptionHandler(new ExceptionHandlerOptions() + { + ExceptionHandler = httpContext => + { + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + return httpContext.Response.WriteAsync("Custom handler"); + } + }); + app.Run(context => + { + throw new Exception("Test exception"); + }); + }); + }).Build(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + + // Act + var response = await server.CreateClient().GetAsync("/path"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + await tcs.Task.DefaultTimeout(); + + // Assert + Assert.Collection( + instrumentRecorder.GetMeasurements(), + m => + { + Assert.True(m.Value > 0); + Assert.Equal(404, (int)m.Tags.ToArray().Single(t => t.Key == "status-code").Value); + Assert.Equal("System.Exception", (string)m.Tags.ToArray().Single(t => t.Key == "exception-name").Value); + }); + } } diff --git a/src/Middleware/Middleware.slnf b/src/Middleware/Middleware.slnf index a67efb48ac1a..fd9d1ba17db3 100644 --- a/src/Middleware/Middleware.slnf +++ b/src/Middleware/Middleware.slnf @@ -2,16 +2,26 @@ "solution": { "path": "..\\..\\AspNetCore.sln", "projects": [ + "src\\Antiforgery\\src\\Microsoft.AspNetCore.Antiforgery.csproj", + "src\\Components\\Authorization\\src\\Microsoft.AspNetCore.Components.Authorization.csproj", + "src\\Components\\Components\\src\\Microsoft.AspNetCore.Components.csproj", + "src\\Components\\Endpoints\\src\\Microsoft.AspNetCore.Components.Endpoints.csproj", + "src\\Components\\Forms\\src\\Microsoft.AspNetCore.Components.Forms.csproj", + "src\\Components\\Web\\src\\Microsoft.AspNetCore.Components.Web.csproj", "src\\DataProtection\\Abstractions\\src\\Microsoft.AspNetCore.DataProtection.Abstractions.csproj", "src\\DataProtection\\Cryptography.Internal\\src\\Microsoft.AspNetCore.Cryptography.Internal.csproj", "src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj", + "src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj", "src\\DefaultBuilder\\src\\Microsoft.AspNetCore.csproj", "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj", + "src\\FileProviders\\Embedded\\src\\Microsoft.Extensions.FileProviders.Embedded.csproj", + "src\\FileProviders\\Manifest.MSBuildTask\\src\\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.csproj", "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj", "src\\Hosting\\Server.Abstractions\\src\\Microsoft.AspNetCore.Hosting.Server.Abstractions.csproj", "src\\Hosting\\Server.IntegrationTesting\\src\\Microsoft.AspNetCore.Server.IntegrationTesting.csproj", "src\\Hosting\\TestHost\\src\\Microsoft.AspNetCore.TestHost.csproj", + "src\\Html.Abstractions\\src\\Microsoft.AspNetCore.Html.Abstractions.csproj", "src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj", "src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj", "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj", @@ -23,6 +33,9 @@ "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj", + "src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj", + "src\\Localization\\Abstractions\\src\\Microsoft.Extensions.Localization.Abstractions.csproj", + "src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj", "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", "src\\Middleware\\CORS\\test\\UnitTests\\Microsoft.AspNetCore.Cors.Test.csproj", "src\\Middleware\\CORS\\test\\testassets\\CorsMiddlewareWebSite\\CorsMiddlewareWebSite.csproj", @@ -114,8 +127,14 @@ "src\\Middleware\\WebSockets\\test\\UnitTests\\Microsoft.AspNetCore.WebSockets.Tests.csproj", "src\\Middleware\\perf\\Microbenchmarks\\Microsoft.AspNetCore.WebSockets.Microbenchmarks.csproj", "src\\Middleware\\perf\\ResponseCaching.Microbenchmarks\\Microsoft.AspNetCore.ResponseCaching.Microbenchmarks.csproj", + "src\\Mvc\\Mvc.Abstractions\\src\\Microsoft.AspNetCore.Mvc.Abstractions.csproj", + "src\\Mvc\\Mvc.Core\\src\\Microsoft.AspNetCore.Mvc.Core.csproj", + "src\\Mvc\\Mvc.DataAnnotations\\src\\Microsoft.AspNetCore.Mvc.DataAnnotations.csproj", + "src\\Mvc\\Mvc.ViewFeatures\\src\\Microsoft.AspNetCore.Mvc.ViewFeatures.csproj", "src\\ObjectPool\\src\\Microsoft.Extensions.ObjectPool.csproj", + "src\\Security\\Authentication\\Core\\src\\Microsoft.AspNetCore.Authentication.csproj", "src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj", + "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", "src\\Servers\\HttpSys\\src\\Microsoft.AspNetCore.Server.HttpSys.csproj", "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", diff --git a/src/Middleware/RateLimiting/samples/RateLimitingSample/RateLimitingSample.csproj b/src/Middleware/RateLimiting/samples/RateLimitingSample/RateLimitingSample.csproj index 80fe67ee1e4b..fcf9060144b6 100644 --- a/src/Middleware/RateLimiting/samples/RateLimitingSample/RateLimitingSample.csproj +++ b/src/Middleware/RateLimiting/samples/RateLimitingSample/RateLimitingSample.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataProviderTest.cs index 84e43647a399..2272b3e453a5 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataProviderTest.cs @@ -29,7 +29,7 @@ public class EndpointMetadataProviderTest [InlineData(typeof(TestController), nameof(TestController.ActionWithMetadataInActionResult))] public void DiscoversEndpointMetadata_FromReturnTypeImplementingIEndpointMetadataProvider(Type controllerType, string actionName) { - // Act + // Act var endpoint = GetEndpoint(controllerType, actionName); // Assert @@ -123,7 +123,8 @@ public void DiscoversMetadata_CorrectOrder() m => Assert.True(m is ControllerActionDescriptor), m => Assert.True(m is RouteNameMetadata), m => Assert.True(m is SuppressLinkGenerationMetadata), - m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Finally })); + m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Finally }), + m => Assert.True(m is IRouteDiagnosticsMetadata { Route: "/{controller}/{action}/{id?}" })); } [Theory] diff --git a/src/Mvc/Mvc.Razor.RuntimeCompilation/src/RuntimeViewCompiler.cs b/src/Mvc/Mvc.Razor.RuntimeCompilation/src/RuntimeViewCompiler.cs index 11a1da7d41ef..b291f0444105 100644 --- a/src/Mvc/Mvc.Razor.RuntimeCompilation/src/RuntimeViewCompiler.cs +++ b/src/Mvc/Mvc.Razor.RuntimeCompilation/src/RuntimeViewCompiler.cs @@ -420,13 +420,11 @@ public static void GeneratedCodeToAssemblyCompilationEnd(ILogger logger, string if (startTimestamp != 0) { var currentTimestamp = Stopwatch.GetTimestamp(); - var elapsed = new TimeSpan((long)(TimestampToTicks * (currentTimestamp - startTimestamp))); + var elapsed = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); GeneratedCodeToAssemblyCompilationEnd(logger, filePath, elapsed.TotalMilliseconds); } } - private static readonly double TimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; - [LoggerMessage(3, LogLevel.Debug, "Initializing Razor view compiler with compiled view: '{ViewName}'.")] public static partial void ViewCompilerLocatedCompiledView(ILogger logger, string viewName); diff --git a/src/Servers/Connections.Abstractions/src/Features/IConnectionMetricsTagsFeature.cs b/src/Servers/Connections.Abstractions/src/Features/IConnectionMetricsTagsFeature.cs new file mode 100644 index 000000000000..845fa8289be2 --- /dev/null +++ b/src/Servers/Connections.Abstractions/src/Features/IConnectionMetricsTagsFeature.cs @@ -0,0 +1,17 @@ +// 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; + +namespace Microsoft.AspNetCore.Connections.Features; + +/// +/// Provides access to tags added to the metrics connection counter. This feature isn't set if the counter isn't enabled. +/// +public interface IConnectionMetricsTagsFeature +{ + /// + /// Gets the tag collection. + /// + ICollection> Tags { get; } +} diff --git a/src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt b/src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt index 4a75aa0ff2d0..4aa74b35f977 100644 --- a/src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt +++ b/src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Connections.Features.IConnectionMetricsTagsFeature +Microsoft.AspNetCore.Connections.Features.IConnectionMetricsTagsFeature.Tags.get -> System.Collections.Generic.ICollection>! Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature.NamedPipe.get -> System.IO.Pipes.NamedPipeServerStream! Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector @@ -10,4 +12,4 @@ Microsoft.AspNetCore.Connections.NamedPipeEndPoint.PipeName.get -> string! Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ServerName.get -> string! override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.Equals(object? obj) -> bool override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.GetHashCode() -> int -override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! \ No newline at end of file +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! diff --git a/src/Servers/Connections.Abstractions/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Servers/Connections.Abstractions/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 3ddff4e1b1ec..681cb955826e 100644 --- a/src/Servers/Connections.Abstractions/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/Servers/Connections.Abstractions/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Connections.Features.IConnectionMetricsTagsFeature +Microsoft.AspNetCore.Connections.Features.IConnectionMetricsTagsFeature.Tags.get -> System.Collections.Generic.ICollection>! Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature.NamedPipe.get -> System.IO.Pipes.NamedPipeServerStream! Microsoft.AspNetCore.Connections.Features.ITlsHandshakeFeature.NegotiatedCipherSuite.get -> System.Net.Security.TlsCipherSuite? diff --git a/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 4a75aa0ff2d0..4aa74b35f977 100644 --- a/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Connections.Features.IConnectionMetricsTagsFeature +Microsoft.AspNetCore.Connections.Features.IConnectionMetricsTagsFeature.Tags.get -> System.Collections.Generic.ICollection>! Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature.NamedPipe.get -> System.IO.Pipes.NamedPipeServerStream! Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector @@ -10,4 +12,4 @@ Microsoft.AspNetCore.Connections.NamedPipeEndPoint.PipeName.get -> string! Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ServerName.get -> string! override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.Equals(object? obj) -> bool override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.GetHashCode() -> int -override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! \ No newline at end of file +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! diff --git a/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt b/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt index 4a75aa0ff2d0..4aa74b35f977 100644 --- a/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt +++ b/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Connections.Features.IConnectionMetricsTagsFeature +Microsoft.AspNetCore.Connections.Features.IConnectionMetricsTagsFeature.Tags.get -> System.Collections.Generic.ICollection>! Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature.NamedPipe.get -> System.IO.Pipes.NamedPipeServerStream! Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector @@ -10,4 +12,4 @@ Microsoft.AspNetCore.Connections.NamedPipeEndPoint.PipeName.get -> string! Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ServerName.get -> string! override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.Equals(object? obj) -> bool override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.GetHashCode() -> int -override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! \ No newline at end of file +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! diff --git a/src/Servers/Kestrel/Core/src/Internal/ConnectionDispatcher.cs b/src/Servers/Kestrel/Core/src/Internal/ConnectionDispatcher.cs index f48a8973481a..c9407633064b 100644 --- a/src/Servers/Kestrel/Core/src/Internal/ConnectionDispatcher.cs +++ b/src/Servers/Kestrel/Core/src/Internal/ConnectionDispatcher.cs @@ -57,6 +57,7 @@ async Task AcceptConnectionsAsync() Log.ConnectionAccepted(connection.ConnectionId); KestrelEventSource.Log.ConnectionQueuedStart(connection); + _serviceContext.Metrics.ConnectionQueuedStart(connection); ThreadPool.UnsafeQueueUserWorkItem(kestrelConnection, preferLocal: false); } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs index 1a5a908bd2ed..4e7b87c3f9ff 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs @@ -86,6 +86,7 @@ protected override void OnRequestProcessingEnded() if (IsUpgraded) { KestrelEventSource.Log.RequestUpgradedStop(this); + ServiceContext.Metrics.RequestUpgradedStop(_context.ConnectionContext); ServiceContext.ConnectionManager.UpgradedConnectionCount.ReleaseOne(); } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs index b5dec2e9497f..713a8ad97209 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs @@ -276,6 +276,7 @@ async Task IHttpUpgradeFeature.UpgradeAsync() IsUpgraded = true; KestrelEventSource.Log.RequestUpgradedStart(this); + ServiceContext.Metrics.RequestUpgradedStart(_context.ConnectionContext); ConnectionFeatures.Get()?.ReleaseConnection(); diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs index 05b40bb6e497..2ca859dff472 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs @@ -59,6 +59,7 @@ internal partial class HttpProtocol : IFeatureCollection, // Other reserved feature slots internal protected IServiceProvidersFeature? _currentIServiceProvidersFeature; internal protected IHttpActivityFeature? _currentIHttpActivityFeature; + internal protected IHttpMetricsTagsFeature? _currentIHttpMetricsTagsFeature; internal protected IItemsFeature? _currentIItemsFeature; internal protected IQueryFeature? _currentIQueryFeature; internal protected IFormFeature? _currentIFormFeature; @@ -101,6 +102,7 @@ private void FastReset() _currentIServiceProvidersFeature = null; _currentIHttpActivityFeature = null; + _currentIHttpMetricsTagsFeature = null; _currentIItemsFeature = null; _currentIQueryFeature = null; _currentIFormFeature = null; @@ -215,6 +217,10 @@ private void ExtraFeatureSet(Type key, object? value) { feature = _currentIHttpActivityFeature; } + else if (key == typeof(IHttpMetricsTagsFeature)) + { + feature = _currentIHttpMetricsTagsFeature; + } else if (key == typeof(IItemsFeature)) { feature = _currentIItemsFeature; @@ -363,6 +369,10 @@ private void ExtraFeatureSet(Type key, object? value) { _currentIHttpActivityFeature = (IHttpActivityFeature?)value; } + else if (key == typeof(IHttpMetricsTagsFeature)) + { + _currentIHttpMetricsTagsFeature = (IHttpMetricsTagsFeature?)value; + } else if (key == typeof(IItemsFeature)) { _currentIItemsFeature = (IItemsFeature?)value; @@ -513,6 +523,10 @@ private void ExtraFeatureSet(Type key, object? value) { feature = Unsafe.As(ref _currentIHttpActivityFeature); } + else if (typeof(TFeature) == typeof(IHttpMetricsTagsFeature)) + { + feature = Unsafe.As(ref _currentIHttpMetricsTagsFeature); + } else if (typeof(TFeature) == typeof(IItemsFeature)) { feature = Unsafe.As(ref _currentIItemsFeature); @@ -669,6 +683,10 @@ private void ExtraFeatureSet(Type key, object? value) { _currentIHttpActivityFeature = Unsafe.As(ref feature); } + else if (typeof(TFeature) == typeof(IHttpMetricsTagsFeature)) + { + _currentIHttpMetricsTagsFeature = Unsafe.As(ref feature); + } else if (typeof(TFeature) == typeof(IItemsFeature)) { _currentIItemsFeature = Unsafe.As(ref feature); @@ -813,6 +831,10 @@ private IEnumerable> FastEnumerable() { yield return new KeyValuePair(typeof(IHttpActivityFeature), _currentIHttpActivityFeature); } + if (_currentIHttpMetricsTagsFeature != null) + { + yield return new KeyValuePair(typeof(IHttpMetricsTagsFeature), _currentIHttpMetricsTagsFeature); + } if (_currentIItemsFeature != null) { yield return new KeyValuePair(typeof(IItemsFeature), _currentIItemsFeature); diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs index d194ed19c61b..846e751870ca 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs @@ -761,6 +761,7 @@ private Http2StreamContext CreateHttp2StreamContext() ConnectionId, protocols: default, _context.AltSvcHeader, + _context.ConnectionContext, _context.ServiceContext, _context.ConnectionFeatures, _context.MemoryPool, @@ -1188,6 +1189,7 @@ private void StartStream() } KestrelEventSource.Log.RequestQueuedStart(_currentHeadersStream, AspNetCore.Http.HttpProtocol.Http2); + _context.ServiceContext.Metrics.RequestQueuedStart(_context.ConnectionContext, AspNetCore.Http.HttpProtocol.Http2); // _scheduleInline is only true in tests if (!_scheduleInline) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs index 9e9ff968a3a7..1ffa495f2f3d 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs @@ -82,6 +82,7 @@ public void InitializeWithExistingContext(int streamId) } public int StreamId => _context.StreamId; + public BaseConnectionContext ConnectionContext => _context.ConnectionContext; public long? InputRemaining { get; internal set; } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2StreamContext.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2StreamContext.cs index 506a3d5fa835..154b916886ef 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2StreamContext.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2StreamContext.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.Net; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.FlowControl; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; @@ -15,6 +16,7 @@ public Http2StreamContext( string connectionId, HttpProtocols protocols, AltSvcHeader? altSvcHeader, + BaseConnectionContext connectionContext, ServiceContext serviceContext, IFeatureCollection connectionFeatures, MemoryPool memoryPool, @@ -25,7 +27,7 @@ public Http2StreamContext( Http2PeerSettings clientPeerSettings, Http2PeerSettings serverPeerSettings, Http2FrameWriter frameWriter, - InputFlowControl connectionInputFlowControl) : base(connectionId, protocols, altSvcHeader, connectionContext: null!, serviceContext, connectionFeatures, memoryPool, localEndPoint, remoteEndPoint) + InputFlowControl connectionInputFlowControl) : base(connectionId, protocols, altSvcHeader, connectionContext, serviceContext, connectionFeatures, memoryPool, localEndPoint, remoteEndPoint) { StreamId = streamId; StreamLifetimeHandler = streamLifetimeHandler; diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2StreamOfT.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2StreamOfT.cs index b6337c82ba70..03e957eaf56e 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2StreamOfT.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2StreamOfT.cs @@ -20,6 +20,8 @@ public Http2Stream(IHttpApplication application, Http2StreamContext co public override void Execute() { KestrelEventSource.Log.RequestQueuedStop(this, AspNetCore.Http.HttpProtocol.Http2); + ServiceContext.Metrics.RequestQueuedStop(ConnectionContext, AspNetCore.Http.HttpProtocol.Http2); + // REVIEW: Should we store this in a field for easy debugging? _ = ProcessRequestsAsync(_application); } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs index 65003cce3682..d6e43c6644f8 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs @@ -594,6 +594,8 @@ private async Task CreateHttp3Stream(ConnectionContext streamContext, _streamLifetimeHandler.OnStreamCreated(stream); KestrelEventSource.Log.RequestQueuedStart(stream, AspNetCore.Http.HttpProtocol.Http3); + _context.ServiceContext.Metrics.RequestQueuedStart(_multiplexedContext, AspNetCore.Http.HttpProtocol.Http3); + ThreadPool.UnsafeQueueUserWorkItem(stream, preferLocal: false); } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs index 8b933e195c99..87a99b21304c 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs @@ -77,6 +77,7 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS public bool IsReceivingHeader => _requestHeaderParsingState <= RequestHeaderParsingState.Headers; // Assigned once headers are received public bool IsDraining => _appCompletedTaskSource.GetStatus() != ValueTaskSourceStatus.Pending; // Draining starts once app is complete public bool IsRequestStream => true; + public BaseConnectionContext ConnectionContext => _context.ConnectionContext; public void Initialize(Http3StreamContext context) { diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamOfT.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamOfT.cs index 406f30eebb2f..3daca086d764 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamOfT.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamOfT.cs @@ -20,6 +20,7 @@ public Http3Stream(IHttpApplication application, Http3StreamContext co public override void Execute() { KestrelEventSource.Log.RequestQueuedStop(this, AspNetCore.Http.HttpProtocol.Http3); + ServiceContext.Metrics.RequestQueuedStop(ConnectionContext, AspNetCore.Http.HttpProtocol.Http3); if (_requestHeaderParsingState == RequestHeaderParsingState.Ready) { diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelConnectionOfT.cs b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelConnectionOfT.cs index 86eb944f96ee..22c84ae704ce 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelConnectionOfT.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelConnectionOfT.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Connections.Features; using Microsoft.Extensions.Logging; @@ -37,13 +38,27 @@ void IThreadPoolWorkItem.Execute() internal async Task ExecuteAsync() { var connectionContext = _transportConnection; + var metricsConnectionDurationEnabled = _serviceContext.Metrics.IsConnectionDurationEnabled(); + var startTimestamp = 0L; + ConnectionMetricsTagsFeature? metricsTagsFeature = null; + Exception? unhandledException = null; + + if (metricsConnectionDurationEnabled) + { + startTimestamp = Stopwatch.GetTimestamp(); + + metricsTagsFeature = new ConnectionMetricsTagsFeature(); + connectionContext.Features.Set(metricsTagsFeature); + } try { KestrelEventSource.Log.ConnectionQueuedStop(connectionContext); + _serviceContext.Metrics.ConnectionQueuedStop(connectionContext); Logger.ConnectionStart(connectionContext.ConnectionId); KestrelEventSource.Log.ConnectionStart(connectionContext); + _serviceContext.Metrics.ConnectionStart(connectionContext); using (BeginConnectionScope(connectionContext)) { @@ -53,6 +68,7 @@ internal async Task ExecuteAsync() } catch (Exception ex) { + unhandledException = ex; Logger.LogError(0, ex, "Unhandled exception while processing {ConnectionId}.", connectionContext.ConnectionId); } } @@ -61,8 +77,15 @@ internal async Task ExecuteAsync() { await FireOnCompletedAsync(); + var currentTimestamp = 0L; + if (metricsConnectionDurationEnabled) + { + currentTimestamp = Stopwatch.GetTimestamp(); + } + Logger.ConnectionStop(connectionContext.ConnectionId); KestrelEventSource.Log.ConnectionStop(connectionContext); + _serviceContext.Metrics.ConnectionStop(connectionContext, unhandledException, metricsTagsFeature?.TagsList, startTimestamp, currentTimestamp); // Dispose the transport connection, this needs to happen before removing it from the // connection manager so that we only signal completion of this connection after the transport @@ -72,4 +95,10 @@ internal async Task ExecuteAsync() _transportConnectionManager.RemoveConnection(_id); } } + + private sealed class ConnectionMetricsTagsFeature : IConnectionMetricsTagsFeature + { + public ICollection> Tags => TagsList; + public List> TagsList { get; } = new List>(); + } } diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelEventSource.cs b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelEventSource.cs index d70f295af75d..a52720dd2a6f 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelEventSource.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelEventSource.cs @@ -55,10 +55,10 @@ private KestrelEventSource() [NonEvent] public void ConnectionStart(BaseConnectionContext connection) { - // avoid allocating strings unless this event source is enabled Interlocked.Increment(ref _totalConnections); Interlocked.Increment(ref _currentConnections); + // avoid allocating strings unless this event source is enabled if (IsEnabled(EventLevel.Informational, EventKeywords.None)) { ConnectionStart( diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelMetrics.cs b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelMetrics.cs new file mode 100644 index 000000000000..9bd1ab2a5969 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelMetrics.cs @@ -0,0 +1,291 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Metrics; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using System.Security.Authentication; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; + +internal sealed class KestrelMetrics +{ + // Note: Dot separated instead of dash. + public const string MeterName = "Microsoft.AspNetCore.Server.Kestrel"; + + private readonly Meter _meter; + private readonly UpDownCounter _currentConnectionsCounter; + private readonly Histogram _connectionDuration; + private readonly Counter _rejectedConnectionsCounter; + private readonly UpDownCounter _queuedConnectionsCounter; + private readonly UpDownCounter _queuedRequestsCounter; + private readonly UpDownCounter _currentUpgradedRequestsCounter; + private readonly Histogram _tlsHandshakeDuration; + private readonly UpDownCounter _currentTlsHandshakesCounter; + + public KestrelMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.CreateMeter(MeterName); + + _currentConnectionsCounter = _meter.CreateUpDownCounter( + "current-connections", + description: "Number of connections that are currently active on the server."); + + _connectionDuration = _meter.CreateHistogram( + "connection-duration", + unit: "s", + description: "The duration of connections on the server."); + + _rejectedConnectionsCounter = _meter.CreateCounter( + "rejected-connections", + description: "Number of connections rejected by the server. Connections are rejected when the currently active count exceeds the value configured with MaxConcurrentConnections."); + + _queuedConnectionsCounter = _meter.CreateUpDownCounter( + "queued-connections", + description: "Number of connections that are currently queued and are waiting to start."); + + _queuedRequestsCounter = _meter.CreateUpDownCounter( + "queued-requests", + description: "Number of HTTP requests on multiplexed connections (HTTP/2 and HTTP/3) that are currently queued and are waiting to start."); + + _currentUpgradedRequestsCounter = _meter.CreateUpDownCounter( + "current-upgraded-connections", + description: "Number of HTTP connections that are currently upgraded (WebSockets). The number only tracks HTTP/1.1 connections."); + + _tlsHandshakeDuration = _meter.CreateHistogram( + "tls-handshake-duration", + unit: "s", + description: "The duration of TLS handshakes on the server."); + + _currentTlsHandshakesCounter = _meter.CreateUpDownCounter( + "current-tls-handshakes", + description: "Number of TLS handshakes that are currently in progress on the server."); + } + + public void ConnectionStart(BaseConnectionContext connection) + { + if (_currentConnectionsCounter.Enabled) + { + ConnectionStartCore(connection); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ConnectionStartCore(BaseConnectionContext connection) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + _currentConnectionsCounter.Add(1, tags); + } + + public void ConnectionStop(BaseConnectionContext connection, Exception? exception, List>? customTags, long startTimestamp, long currentTimestamp) + { + if (_currentConnectionsCounter.Enabled || _connectionDuration.Enabled) + { + ConnectionStopCore(connection, exception, customTags, startTimestamp, currentTimestamp); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ConnectionStopCore(BaseConnectionContext connection, Exception? exception, List>? customTags, long startTimestamp, long currentTimestamp) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + + // Decrease in connections counter must match tags from increase. No custom tags. + _currentConnectionsCounter.Add(-1, tags); + + if (exception != null) + { + tags.Add("exception-name", exception.GetType().FullName); + } + + // Add custom tags for duration. + if (customTags != null) + { + for (var i = 0; i < customTags.Count; i++) + { + tags.Add(customTags[i]); + } + } + + var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); + _connectionDuration.Record(duration.TotalSeconds, tags); + } + + public void ConnectionRejected(BaseConnectionContext connection) + { + if (_rejectedConnectionsCounter.Enabled) + { + ConnectionRejectedCore(connection); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ConnectionRejectedCore(BaseConnectionContext connection) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + _rejectedConnectionsCounter.Add(1, tags); + } + + public void ConnectionQueuedStart(BaseConnectionContext connection) + { + if (_queuedConnectionsCounter.Enabled) + { + ConnectionQueuedStartCore(connection); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ConnectionQueuedStartCore(BaseConnectionContext connection) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + _queuedConnectionsCounter.Add(1, tags); + } + + public void ConnectionQueuedStop(BaseConnectionContext connection) + { + if (_queuedConnectionsCounter.Enabled) + { + ConnectionQueuedStopCore(connection); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ConnectionQueuedStopCore(BaseConnectionContext connection) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + _queuedConnectionsCounter.Add(-1, tags); + } + + public void RequestQueuedStart(BaseConnectionContext connection, string httpVersion) + { + if (_queuedRequestsCounter.Enabled) + { + RequestQueuedStartCore(connection, httpVersion); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void RequestQueuedStartCore(BaseConnectionContext connection, string httpVersion) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + tags.Add("version", httpVersion); + _queuedRequestsCounter.Add(1, tags); + } + + public void RequestQueuedStop(BaseConnectionContext connection, string httpVersion) + { + if (_queuedRequestsCounter.Enabled) + { + RequestQueuedStopCore(connection, httpVersion); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void RequestQueuedStopCore(BaseConnectionContext connection, string httpVersion) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + tags.Add("version", httpVersion); + _queuedRequestsCounter.Add(-1, tags); + } + + public void RequestUpgradedStart(BaseConnectionContext connection) + { + if (_currentUpgradedRequestsCounter.Enabled) + { + RequestUpgradedStartCore(connection); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void RequestUpgradedStartCore(BaseConnectionContext connection) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + _currentUpgradedRequestsCounter.Add(1, tags); + } + + public void RequestUpgradedStop(BaseConnectionContext connection) + { + if (_currentUpgradedRequestsCounter.Enabled) + { + RequestUpgradedStopCore(connection); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void RequestUpgradedStopCore(BaseConnectionContext connection) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + _currentUpgradedRequestsCounter.Add(-1, tags); + } + + public void TlsHandshakeStart(BaseConnectionContext connection) + { + if (_currentTlsHandshakesCounter.Enabled) + { + TlsHandshakeStartCore(connection); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void TlsHandshakeStartCore(BaseConnectionContext connection) + { + // Tags must match TLS handshake end. + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + _currentTlsHandshakesCounter.Add(1, tags); + } + + public void TlsHandshakeStop(BaseConnectionContext connection, long startTimestamp, long currentTimestamp, SslProtocols? protocol = null, Exception? exception = null) + { + if (_currentTlsHandshakesCounter.Enabled || _tlsHandshakeDuration.Enabled) + { + TlsHandshakeStopCore(connection, startTimestamp, currentTimestamp, protocol, exception); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void TlsHandshakeStopCore(BaseConnectionContext connection, long startTimestamp, long currentTimestamp, SslProtocols? protocol = null, Exception? exception = null) + { + var tags = new TagList(); + InitializeConnectionTags(ref tags, connection); + + // Tags must match TLS handshake start. + _currentTlsHandshakesCounter.Add(-1, tags); + + if (protocol != null) + { + tags.Add("protocol", protocol.ToString()); + } + if (exception != null) + { + tags.Add("exception-name", exception.GetType().FullName); + } + + var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); + _tlsHandshakeDuration.Record(duration.TotalSeconds, tags); + } + + private static void InitializeConnectionTags(ref TagList tags, BaseConnectionContext connection) + { + if (connection.LocalEndPoint is { } localEndpoint) + { + // TODO: Improve getting string allocation for endpoint. Currently allocates. + // Possible solution is to cache in the endpoint: https://github.com/dotnet/runtime/issues/84515 + // Alternatively, add cache to ConnectionContext. + tags.Add("endpoint", localEndpoint.ToString()); + } + } + + public bool IsConnectionDurationEnabled() => _connectionDuration.Enabled; +} diff --git a/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs b/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs index 1861d46434f3..b8f095bf6454 100644 --- a/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs +++ b/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs @@ -32,30 +32,13 @@ internal sealed class KestrelServerImpl : IServer private IDisposable? _configChangedRegistration; - public KestrelServerImpl( - IOptions options, - IEnumerable transportFactories, - ILoggerFactory loggerFactory) - : this(transportFactories, Array.Empty(), CreateServiceContext(options, loggerFactory, null)) - { - } - - public KestrelServerImpl( - IOptions options, - IEnumerable transportFactories, - IEnumerable multiplexedFactories, - ILoggerFactory loggerFactory) - : this(transportFactories, multiplexedFactories, CreateServiceContext(options, loggerFactory, null)) - { - } - public KestrelServerImpl( IOptions options, IEnumerable transportFactories, IEnumerable multiplexedFactories, ILoggerFactory loggerFactory, - DiagnosticSource diagnosticSource) - : this(transportFactories, multiplexedFactories, CreateServiceContext(options, loggerFactory, diagnosticSource)) + KestrelMetrics metrics) + : this(transportFactories, multiplexedFactories, CreateServiceContext(options, loggerFactory, diagnosticSource: null, metrics)) { } @@ -85,7 +68,7 @@ internal KestrelServerImpl( _transportManager = new TransportManager(_transportFactories, _multiplexedTransportFactories, ServiceContext); } - private static ServiceContext CreateServiceContext(IOptions options, ILoggerFactory loggerFactory, DiagnosticSource? diagnosticSource) + private static ServiceContext CreateServiceContext(IOptions options, ILoggerFactory loggerFactory, DiagnosticSource? diagnosticSource, KestrelMetrics metrics) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(loggerFactory); @@ -115,7 +98,8 @@ private static ServiceContext CreateServiceContext(IOptions(c => innerDelegate(c), connectionLimit.Value, trace).OnConnectionAsync; + return new ConnectionLimitMiddleware(c => innerDelegate(c), connectionLimit.Value, trace, metrics).OnConnectionAsync; } - private static MultiplexedConnectionDelegate EnforceConnectionLimit(MultiplexedConnectionDelegate innerDelegate, long? connectionLimit, KestrelTrace trace) + private static MultiplexedConnectionDelegate EnforceConnectionLimit(MultiplexedConnectionDelegate innerDelegate, long? connectionLimit, KestrelTrace trace, KestrelMetrics metrics) { if (!connectionLimit.HasValue) { return innerDelegate; } - return new ConnectionLimitMiddleware(c => innerDelegate(c), connectionLimit.Value, trace).OnConnectionAsync; + return new ConnectionLimitMiddleware(c => innerDelegate(c), connectionLimit.Value, trace, metrics).OnConnectionAsync; } } diff --git a/src/Servers/Kestrel/Core/src/Internal/ServiceContext.cs b/src/Servers/Kestrel/Core/src/Internal/ServiceContext.cs index 689ece3dbf8c..fb7021187c76 100644 --- a/src/Servers/Kestrel/Core/src/Internal/ServiceContext.cs +++ b/src/Servers/Kestrel/Core/src/Internal/ServiceContext.cs @@ -32,4 +32,6 @@ internal class ServiceContext public KestrelServerOptions ServerOptions { get; set; } = default!; public DiagnosticSource? DiagnosticSource { get; set; } + + public KestrelMetrics Metrics { get; set; } = default!; } diff --git a/src/Servers/Kestrel/Core/src/KestrelServer.cs b/src/Servers/Kestrel/Core/src/KestrelServer.cs index f403143c2569..1a31cf04d35f 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServer.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServer.cs @@ -1,10 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.Metrics; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Metrics; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Server.Kestrel.Core; @@ -27,7 +30,9 @@ public KestrelServer(IOptions options, IConnectionListener _innerKestrelServer = new KestrelServerImpl( options, new[] { transportFactory ?? throw new ArgumentNullException(nameof(transportFactory)) }, - loggerFactory); + Array.Empty(), + loggerFactory, + new KestrelMetrics(new DummyMeterFactory())); } /// @@ -57,4 +62,12 @@ public void Dispose() { _innerKestrelServer.Dispose(); } + + // This factory used when type is created without DI. For example, via KestrelServer. + private sealed class DummyMeterFactory : IMeterFactory + { + public Meter CreateMeter(string name) => new Meter(name); + + public Meter CreateMeter(MeterOptions options) => new Meter(options.Name, options.Version); + } } diff --git a/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs b/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs index 3463a56828e7..c69c37c71b13 100644 --- a/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs +++ b/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs @@ -4,12 +4,12 @@ using System.Net.Security; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.AspNetCore.Hosting; @@ -201,7 +201,8 @@ internal static bool TryUseHttps(this ListenOptions listenOptions) /// The . public static ListenOptions UseHttps(this ListenOptions listenOptions, HttpsConnectionAdapterOptions httpsOptions) { - var loggerFactory = listenOptions.KestrelServerOptions?.ApplicationServices.GetRequiredService() ?? NullLoggerFactory.Instance; + var loggerFactory = listenOptions.KestrelServerOptions.ApplicationServices.GetRequiredService(); + var metrics = listenOptions.KestrelServerOptions.ApplicationServices.GetRequiredService(); listenOptions.IsTls = true; listenOptions.HttpsOptions = httpsOptions; @@ -210,7 +211,7 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, HttpsConn { // Set the list of protocols from listen options httpsOptions.HttpProtocols = listenOptions.Protocols; - var middleware = new HttpsConnectionMiddleware(next, httpsOptions, loggerFactory); + var middleware = new HttpsConnectionMiddleware(next, httpsOptions, loggerFactory, metrics); return middleware.OnConnectionAsync; }); @@ -265,7 +266,8 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, TlsHandsh throw new ArgumentException($"{nameof(TlsHandshakeCallbackOptions.OnConnection)} must not be null."); } - var loggerFactory = listenOptions.KestrelServerOptions?.ApplicationServices.GetRequiredService() ?? NullLoggerFactory.Instance; + var loggerFactory = listenOptions.KestrelServerOptions.ApplicationServices.GetRequiredService(); + var metrics = listenOptions.KestrelServerOptions.ApplicationServices.GetRequiredService(); listenOptions.IsTls = true; listenOptions.HttpsCallbackOptions = callbackOptions; @@ -276,7 +278,7 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, TlsHandsh // Set it inside Use delegate so Protocols and UseHttps can be called out of order. callbackOptions.HttpProtocols = listenOptions.Protocols; - var middleware = new HttpsConnectionMiddleware(next, callbackOptions, loggerFactory); + var middleware = new HttpsConnectionMiddleware(next, callbackOptions, loggerFactory, metrics); return middleware.OnConnectionAsync; }); diff --git a/src/Servers/Kestrel/Core/src/Middleware/ConnectionLimitMiddleware.cs b/src/Servers/Kestrel/Core/src/Middleware/ConnectionLimitMiddleware.cs index d717a7717db1..674eb8a10bb0 100644 --- a/src/Servers/Kestrel/Core/src/Middleware/ConnectionLimitMiddleware.cs +++ b/src/Servers/Kestrel/Core/src/Middleware/ConnectionLimitMiddleware.cs @@ -12,18 +12,20 @@ internal sealed class ConnectionLimitMiddleware where T : BaseConnectionConte private readonly Func _next; private readonly ResourceCounter _concurrentConnectionCounter; private readonly KestrelTrace _trace; + private readonly KestrelMetrics _metrics; - public ConnectionLimitMiddleware(Func next, long connectionLimit, KestrelTrace trace) - : this(next, ResourceCounter.Quota(connectionLimit), trace) + public ConnectionLimitMiddleware(Func next, long connectionLimit, KestrelTrace trace, KestrelMetrics metrics) + : this(next, ResourceCounter.Quota(connectionLimit), trace, metrics) { } // For Testing - internal ConnectionLimitMiddleware(Func next, ResourceCounter concurrentConnectionCounter, KestrelTrace trace) + internal ConnectionLimitMiddleware(Func next, ResourceCounter concurrentConnectionCounter, KestrelTrace trace, KestrelMetrics metrics) { _next = next; _concurrentConnectionCounter = concurrentConnectionCounter; _trace = trace; + _metrics = metrics; } public async Task OnConnectionAsync(T connection) @@ -32,6 +34,7 @@ public async Task OnConnectionAsync(T connection) { KestrelEventSource.Log.ConnectionRejected(connection.ConnectionId); _trace.ConnectionRejected(connection.ConnectionId); + _metrics.ConnectionRejected(connection); await connection.DisposeAsync(); return; } diff --git a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs index 6b70b379e191..de065dc3e9be 100644 --- a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs +++ b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs @@ -35,6 +35,7 @@ internal sealed class HttpsConnectionMiddleware // The following fields are only set by HttpsConnectionAdapterOptions ctor. private readonly HttpsConnectionAdapterOptions? _options; + private readonly KestrelMetrics _metrics; private readonly SslStreamCertificateContext? _serverCertificateContext; private readonly X509Certificate2? _serverCertificate; private readonly Func? _serverCertificateSelector; @@ -47,12 +48,12 @@ internal sealed class HttpsConnectionMiddleware // Pool for cancellation tokens that cancel the handshake private readonly CancellationTokenSourcePool _ctsPool = new(); - public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapterOptions options) - : this(next, options, loggerFactory: NullLoggerFactory.Instance) + public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapterOptions options, KestrelMetrics metrics) + : this(next, options, loggerFactory: NullLoggerFactory.Instance, metrics: metrics) { } - public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapterOptions options, ILoggerFactory loggerFactory) + public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapterOptions options, ILoggerFactory loggerFactory, KestrelMetrics metrics) { ArgumentNullException.ThrowIfNull(options); @@ -64,6 +65,7 @@ public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapter _next = next; _handshakeTimeout = options.HandshakeTimeout; _logger = loggerFactory.CreateLogger(); + _metrics = metrics; // Something similar to the following could allow us to remove more duplicate logic, but we need https://github.com/dotnet/runtime/issues/40402 to be fixed first. //var sniOptionsSelector = new SniOptionsSelector("", new Dictionary { { "*", new SniConfig() } }, new NoopCertificateConfigLoader(), options, options.HttpProtocols, _logger); @@ -113,11 +115,13 @@ public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapter internal HttpsConnectionMiddleware( ConnectionDelegate next, TlsHandshakeCallbackOptions tlsCallbackOptions, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + KestrelMetrics metrics) { _next = next; _handshakeTimeout = tlsCallbackOptions.HandshakeTimeout; _logger = loggerFactory.CreateLogger(); + _metrics = metrics; _tlsCallbackOptions = tlsCallbackOptions.OnConnection; _tlsCallbackOptionsState = tlsCallbackOptions.OnConnectionState; @@ -148,6 +152,7 @@ public async Task OnConnectionAsync(ConnectionContext context) context.Features.Set(feature); context.Features.Set(sslStream); // Anti-pattern, but retain for back compat + var startTimestamp = Stopwatch.GetTimestamp(); try { using var cancellationTokenSource = _ctsPool.Rent(); @@ -163,10 +168,9 @@ public async Task OnConnectionAsync(ConnectionContext context) await sslStream.AuthenticateAsServerAsync(ServerOptionsCallback, state, cancellationTokenSource.Token); } } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); - KestrelEventSource.Log.TlsHandshakeStop(context, null); + RecordHandshakeFailed(_metrics, startTimestamp, Stopwatch.GetTimestamp(), context, ex); _logger.AuthenticationTimedOut(); await sslStream.DisposeAsync(); @@ -174,8 +178,7 @@ public async Task OnConnectionAsync(ConnectionContext context) } catch (IOException ex) { - KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); - KestrelEventSource.Log.TlsHandshakeStop(context, null); + RecordHandshakeFailed(_metrics, startTimestamp, Stopwatch.GetTimestamp(), context, ex); _logger.AuthenticationFailed(ex); await sslStream.DisposeAsync(); @@ -183,8 +186,7 @@ public async Task OnConnectionAsync(ConnectionContext context) } catch (AuthenticationException ex) { - KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); - KestrelEventSource.Log.TlsHandshakeStop(context, null); + RecordHandshakeFailed(_metrics, startTimestamp, Stopwatch.GetTimestamp(), context, ex); _logger.AuthenticationFailed(ex); @@ -193,6 +195,7 @@ public async Task OnConnectionAsync(ConnectionContext context) } KestrelEventSource.Log.TlsHandshakeStop(context, feature); + _metrics.TlsHandshakeStop(context, startTimestamp, Stopwatch.GetTimestamp(), protocol: sslStream.SslProtocol); _logger.HttpsConnectionEstablished(context.ConnectionId, sslStream.SslProtocol); @@ -216,6 +219,13 @@ public async Task OnConnectionAsync(ConnectionContext context) // Restore the original so that it gets closed appropriately context.Transport = originalTransport; } + + static void RecordHandshakeFailed(KestrelMetrics metrics, long startTimestamp, long currentTimestamp, ConnectionContext context, Exception ex) + { + KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); + KestrelEventSource.Log.TlsHandshakeStop(context, null); + metrics.TlsHandshakeStop(context, startTimestamp, currentTimestamp, exception: ex); + } } // This logic is replicated from https://github.com/dotnet/runtime/blob/02b24db7cada5d5806c5cc513e61e44fb2a41944/src/libraries/System.Net.Security/src/System/Net/Security/SecureChannel.cs#L195-L262 @@ -326,6 +336,7 @@ private Task DoOptionsBasedHandshakeAsync(ConnectionContext context, SslStream s _options.OnAuthenticate?.Invoke(context, sslOptions); KestrelEventSource.Log.TlsHandshakeStart(context, sslOptions); + _metrics.TlsHandshakeStart(context); return sslStream.AuthenticateAsServerAsync(sslOptions, cancellationToken); } @@ -418,7 +429,7 @@ private static async ValueTask ServerOptionsCall feature.HostName = clientHelloInfo.ServerName; context.Features.Set(sslStream); - var callbackContext = new TlsHandshakeCallbackContext() + var callbackContext = new TlsHandshakeCallbackContext { Connection = context, SslStream = sslStream, @@ -434,7 +445,9 @@ private static async ValueTask ServerOptionsCall { ConfigureAlpn(sslOptions, middleware._httpProtocols); } + KestrelEventSource.Log.TlsHandshakeStart(context, sslOptions); + middleware._metrics.TlsHandshakeStart(context); return sslOptions; } diff --git a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs index 911fc0524109..cd296073337a 100644 --- a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs +++ b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Metrics; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; @@ -29,6 +30,7 @@ private KestrelServerOptions CreateServerOptions() { var serverOptions = new KestrelServerOptions(); serverOptions.ApplicationServices = new ServiceCollection() + .AddSingleton(new KestrelMetrics(new TestMeterFactory())) .AddLogging() .BuildServiceProvider(); return serverOptions; @@ -278,14 +280,29 @@ public void ConstructorWithNullTransportFactoryThrows() Assert.Equal("transportFactory", exception.ParamName); } + private static KestrelServerImpl CreateKestrelServer( + KestrelServerOptions options, + IEnumerable transportFactories, + IEnumerable multiplexedFactories, + ILoggerFactory loggerFactory = null, + KestrelMetrics metrics = null) + { + return new KestrelServerImpl( + Options.Create(options), + transportFactories, + multiplexedFactories, + loggerFactory ?? new LoggerFactory(new[] { new KestrelTestLoggerProvider() }), + metrics ?? new KestrelMetrics(new TestMeterFactory())); + } + [Fact] public void ConstructorWithNoTransportFactoriesThrows() { var exception = Assert.Throws(() => - new KestrelServerImpl( - Options.Create(null), + CreateKestrelServer( + options: null, new List(), - new LoggerFactory(new[] { new KestrelTestLoggerProvider() }))); + Array.Empty())); Assert.Equal(CoreStrings.TransportNotFound, exception.Message); } @@ -293,10 +310,10 @@ public void ConstructorWithNoTransportFactoriesThrows() [Fact] public void StartWithMultipleTransportFactoriesDoesNotThrow() { - using var server = new KestrelServerImpl( - Options.Create(CreateServerOptions()), + using var server = CreateKestrelServer( + CreateServerOptions(), new List() { new ThrowingTransportFactory(), new MockTransportFactory() }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + Array.Empty()); StartDummyApplication(server); } @@ -307,10 +324,10 @@ public async Task StartWithNoValidTransportFactoryThrows() var serverOptions = CreateServerOptions(); serverOptions.Listen(new IPEndPoint(IPAddress.Loopback, 0)); - var server = new KestrelServerImpl( - Options.Create(serverOptions), + using var server = CreateKestrelServer( + serverOptions, new List { new NonBindableTransportFactory() }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + Array.Empty()); var exception = await Assert.ThrowsAsync( async () => await server.StartAsync(new DummyApplication(context => Task.CompletedTask), CancellationToken.None)); @@ -327,10 +344,10 @@ public async Task StartWithMultipleTransportFactories_UseSupported() var transportFactory = new MockTransportFactory(); - var server = new KestrelServerImpl( - Options.Create(serverOptions), + using var server = CreateKestrelServer( + serverOptions, new List { transportFactory, new NonBindableTransportFactory() }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + Array.Empty()); await server.StartAsync(new DummyApplication(context => Task.CompletedTask), CancellationToken.None); @@ -348,11 +365,10 @@ public async Task StartWithNoValidTransportFactoryThrows_Http3() c.UseHttps(TestResources.GetTestCertificate()); }); - var server = new KestrelServerImpl( - Options.Create(serverOptions), + using var server = CreateKestrelServer( + serverOptions, new List(), - new List { new NonBindableMultiplexedTransportFactory() }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + new List { new NonBindableMultiplexedTransportFactory() }); var exception = await Assert.ThrowsAsync( async () => await server.StartAsync(new DummyApplication(context => Task.CompletedTask), CancellationToken.None)); @@ -373,11 +389,10 @@ public async Task StartWithMultipleTransportFactories_Http3_UseSupported() var transportFactory = new MockMultiplexedTransportFactory(); - var server = new KestrelServerImpl( - Options.Create(serverOptions), + using var server = CreateKestrelServer( + serverOptions, new List(), - new List { transportFactory, new NonBindableMultiplexedTransportFactory() }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + new List { transportFactory, new NonBindableMultiplexedTransportFactory() }); await server.StartAsync(new DummyApplication(context => Task.CompletedTask), CancellationToken.None); @@ -388,10 +403,7 @@ public async Task StartWithMultipleTransportFactories_Http3_UseSupported() [Fact] public async Task ListenWithCustomEndpoint_DoesNotThrow() { - var options = new KestrelServerOptions(); - options.ApplicationServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(); + var options = CreateServerOptions(); var customEndpoint = new UriEndPoint(new("http://localhost:5000")); options.Listen(customEndpoint, options => @@ -403,11 +415,10 @@ public async Task ListenWithCustomEndpoint_DoesNotThrow() var mockTransportFactory = new MockTransportFactory(); var mockMultiplexedTransportFactory = new MockMultiplexedTransportFactory(); - using var server = new KestrelServerImpl( - Options.Create(options), + using var server = CreateKestrelServer( + options, new List() { mockTransportFactory }, - new List() { mockMultiplexedTransportFactory }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + new List() { mockMultiplexedTransportFactory }); await server.StartAsync(new DummyApplication(context => Task.CompletedTask), CancellationToken.None); @@ -421,10 +432,7 @@ public async Task ListenWithCustomEndpoint_DoesNotThrow() [Fact] public async Task ListenIPWithStaticPort_TransportsGetIPv6Any() { - var options = new KestrelServerOptions(); - options.ApplicationServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(); + var options = CreateServerOptions(); options.ListenAnyIP(5000, options => { options.UseHttps(TestResources.GetTestCertificate()); @@ -434,11 +442,10 @@ public async Task ListenIPWithStaticPort_TransportsGetIPv6Any() var mockTransportFactory = new MockTransportFactory(); var mockMultiplexedTransportFactory = new MockMultiplexedTransportFactory(); - using var server = new KestrelServerImpl( - Options.Create(options), + using var server = CreateKestrelServer( + options, new List() { mockTransportFactory }, - new List() { mockMultiplexedTransportFactory }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + new List() { mockMultiplexedTransportFactory }); await server.StartAsync(new DummyApplication(context => Task.CompletedTask), CancellationToken.None); @@ -456,10 +463,7 @@ public async Task ListenIPWithStaticPort_TransportsGetIPv6Any() [Fact] public async Task ListenIPWithEphemeralPort_TransportsGetIPv6Any() { - var options = new KestrelServerOptions(); - options.ApplicationServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(); + var options = CreateServerOptions(); options.ListenAnyIP(0, options => { options.UseHttps(TestResources.GetTestCertificate()); @@ -469,11 +473,10 @@ public async Task ListenIPWithEphemeralPort_TransportsGetIPv6Any() var mockTransportFactory = new MockTransportFactory(); var mockMultiplexedTransportFactory = new MockMultiplexedTransportFactory(); - using var server = new KestrelServerImpl( - Options.Create(options), + using var server = CreateKestrelServer( + options, new List() { mockTransportFactory }, - new List() { mockMultiplexedTransportFactory }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + new List() { mockMultiplexedTransportFactory }); await server.StartAsync(new DummyApplication(context => Task.CompletedTask), CancellationToken.None); @@ -488,10 +491,7 @@ public async Task ListenIPWithEphemeralPort_TransportsGetIPv6Any() [Fact] public async Task ListenIPWithEphemeralPort_MultiplexedTransportsGetIPv6Any() { - var options = new KestrelServerOptions(); - options.ApplicationServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(); + var options = CreateServerOptions(); options.ListenAnyIP(0, options => { options.UseHttps(TestResources.GetTestCertificate()); @@ -501,11 +501,10 @@ public async Task ListenIPWithEphemeralPort_MultiplexedTransportsGetIPv6Any() var mockTransportFactory = new MockTransportFactory(); var mockMultiplexedTransportFactory = new MockMultiplexedTransportFactory(); - using var server = new KestrelServerImpl( - Options.Create(options), + using var server = CreateKestrelServer( + options, new List() { mockTransportFactory }, - new List() { mockMultiplexedTransportFactory }, - new LoggerFactory(new[] { new KestrelTestLoggerProvider() })); + new List() { mockMultiplexedTransportFactory }); await server.StartAsync(new DummyApplication(context => Task.CompletedTask), CancellationToken.None); diff --git a/src/Servers/Kestrel/Core/test/PooledStreamStackTests.cs b/src/Servers/Kestrel/Core/test/PooledStreamStackTests.cs index af2d924c23f2..8616f35f19d6 100644 --- a/src/Servers/Kestrel/Core/test/PooledStreamStackTests.cs +++ b/src/Servers/Kestrel/Core/test/PooledStreamStackTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; @@ -100,6 +101,7 @@ private static Http2Stream CreateStream(int streamId, long expirati connectionId: "TestConnectionId", protocols: HttpProtocols.Http2, altSvcHeader: null, + connectionContext: new DefaultConnectionContext(), serviceContext: TestContextFactory.CreateServiceContext(serverOptions: new KestrelServerOptions()), connectionFeatures: new FeatureCollection(), memoryPool: MemoryPool.Shared, diff --git a/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs b/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs index 7d2b4d5bfb7e..f4563b222809 100644 --- a/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs +++ b/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -36,6 +37,7 @@ public static IWebHostBuilder UseKestrel(this IWebHostBuilder hostBuilder) services.AddTransient, KestrelServerOptionsSetup>(); services.AddSingleton(); + services.AddSingleton(); }); hostBuilder.UseQuic(options => diff --git a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs index c6a373cf7f88..8f1f535d381d 100644 --- a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs +++ b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs @@ -6,12 +6,14 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Metrics; namespace Microsoft.AspNetCore.Server.Kestrel.Tests; @@ -24,6 +26,7 @@ private KestrelServerOptions CreateServerOptions() serverOptions.ApplicationServices = new ServiceCollection() .AddLogging() .AddSingleton(env) + .AddSingleton(new KestrelMetrics(new TestMeterFactory())) .BuildServiceProvider(); return serverOptions; } diff --git a/src/Servers/Kestrel/shared/TransportConnection.Generated.cs b/src/Servers/Kestrel/shared/TransportConnection.Generated.cs index 090fa212e305..303811649ac4 100644 --- a/src/Servers/Kestrel/shared/TransportConnection.Generated.cs +++ b/src/Servers/Kestrel/shared/TransportConnection.Generated.cs @@ -35,6 +35,7 @@ internal partial class TransportConnection : IFeatureCollection, internal protected IStreamIdFeature? _currentIStreamIdFeature; internal protected IStreamAbortFeature? _currentIStreamAbortFeature; internal protected IStreamClosedFeature? _currentIStreamClosedFeature; + internal protected IConnectionMetricsTagsFeature? _currentIConnectionMetricsTagsFeature; private int _featureRevision; @@ -55,6 +56,7 @@ private void FastReset() _currentIStreamIdFeature = null; _currentIStreamAbortFeature = null; _currentIStreamClosedFeature = null; + _currentIConnectionMetricsTagsFeature = null; } // Internal for testing @@ -174,6 +176,10 @@ private void ExtraFeatureSet(Type key, object? value) { feature = _currentIStreamClosedFeature; } + else if (key == typeof(IConnectionMetricsTagsFeature)) + { + feature = _currentIConnectionMetricsTagsFeature; + } else if (MaybeExtra != null) { feature = ExtraFeatureGet(key); @@ -234,6 +240,10 @@ private void ExtraFeatureSet(Type key, object? value) { _currentIStreamClosedFeature = (IStreamClosedFeature?)value; } + else if (key == typeof(IConnectionMetricsTagsFeature)) + { + _currentIConnectionMetricsTagsFeature = (IConnectionMetricsTagsFeature?)value; + } else { ExtraFeatureSet(key, value); @@ -296,6 +306,10 @@ private void ExtraFeatureSet(Type key, object? value) { feature = Unsafe.As(ref _currentIStreamClosedFeature); } + else if (typeof(TFeature) == typeof(IConnectionMetricsTagsFeature)) + { + feature = Unsafe.As(ref _currentIConnectionMetricsTagsFeature); + } else if (MaybeExtra != null) { feature = (TFeature?)(ExtraFeatureGet(typeof(TFeature))); @@ -364,6 +378,10 @@ private void ExtraFeatureSet(Type key, object? value) { _currentIStreamClosedFeature = Unsafe.As(ref feature); } + else if (typeof(TFeature) == typeof(IConnectionMetricsTagsFeature)) + { + _currentIConnectionMetricsTagsFeature = Unsafe.As(ref feature); + } else { ExtraFeatureSet(typeof(TFeature), feature); @@ -420,6 +438,10 @@ private IEnumerable> FastEnumerable() { yield return new KeyValuePair(typeof(IStreamClosedFeature), _currentIStreamClosedFeature); } + if (_currentIConnectionMetricsTagsFeature != null) + { + yield return new KeyValuePair(typeof(IConnectionMetricsTagsFeature), _currentIConnectionMetricsTagsFeature); + } if (MaybeExtra != null) { diff --git a/src/Servers/Kestrel/shared/test/TestContextFactory.cs b/src/Servers/Kestrel/shared/test/TestContextFactory.cs index c6da71957b14..6a9caffff83a 100644 --- a/src/Servers/Kestrel/shared/test/TestContextFactory.cs +++ b/src/Servers/Kestrel/shared/test/TestContextFactory.cs @@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Metrics; namespace Microsoft.AspNetCore.Testing; @@ -42,7 +43,8 @@ public static ServiceContext CreateServiceContext( DateHeaderValueManager = dateHeaderValueManager, ConnectionManager = connectionManager, Heartbeat = heartbeat, - ServerOptions = serverOptions + ServerOptions = serverOptions, + Metrics = new KestrelMetrics(new TestMeterFactory()) }; return context; @@ -150,6 +152,7 @@ public static Http2StreamContext CreateHttp2StreamContext( connectionId: connectionId ?? "TestConnectionId", protocols: HttpProtocols.Http2, altSvcHeader: null, + connectionContext: new DefaultConnectionContext(), serviceContext: serviceContext ?? CreateServiceContext(new KestrelServerOptions()), connectionFeatures: connectionFeatures ?? new FeatureCollection(), memoryPool: memoryPool ?? MemoryPool.Shared, diff --git a/src/Servers/Kestrel/shared/test/TestServiceContext.cs b/src/Servers/Kestrel/shared/test/TestServiceContext.cs index 119a13020b91..9b0b37741ef5 100644 --- a/src/Servers/Kestrel/shared/test/TestServiceContext.cs +++ b/src/Servers/Kestrel/shared/test/TestServiceContext.cs @@ -3,31 +3,32 @@ using System; using System.Buffers; +using System.Diagnostics.Metrics; using System.IO.Pipelines; +using System.Xml.Linq; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Metrics; namespace Microsoft.AspNetCore.Testing; internal class TestServiceContext : ServiceContext { - public TestServiceContext() + public TestServiceContext() : this(disableHttp1LineFeedTerminators: true) { - Initialize(NullLoggerFactory.Instance, CreateLoggingTrace(NullLoggerFactory.Instance), false); } - public TestServiceContext(ILoggerFactory loggerFactory, bool disableHttp1LineFeedTerminators = true) + public TestServiceContext(ILoggerFactory loggerFactory = null, KestrelTrace kestrelTrace = null, bool disableHttp1LineFeedTerminators = true, KestrelMetrics metrics = null) { - Initialize(loggerFactory, CreateLoggingTrace(loggerFactory), disableHttp1LineFeedTerminators); - } + loggerFactory ??= NullLoggerFactory.Instance; + kestrelTrace ??= CreateLoggingTrace(loggerFactory); + metrics ??= new KestrelMetrics(new TestMeterFactory()); - public TestServiceContext(ILoggerFactory loggerFactory, KestrelTrace kestrelTrace, bool disableHttp1LineFeedTerminators = true) - { - Initialize(loggerFactory, kestrelTrace, disableHttp1LineFeedTerminators); + Initialize(loggerFactory, kestrelTrace, disableHttp1LineFeedTerminators, metrics); } private static KestrelTrace CreateLoggingTrace(ILoggerFactory loggerFactory) @@ -49,7 +50,7 @@ public void InitializeHeartbeat() SystemClock = heartbeatManager; } - private void Initialize(ILoggerFactory loggerFactory, KestrelTrace kestrelTrace, bool disableHttp1LineFeedTerminators) + private void Initialize(ILoggerFactory loggerFactory, KestrelTrace kestrelTrace, bool disableHttp1LineFeedTerminators, KestrelMetrics metrics) { LoggerFactory = loggerFactory; Log = kestrelTrace; @@ -65,6 +66,7 @@ private void Initialize(ILoggerFactory loggerFactory, KestrelTrace kestrelTrace, }; DateHeaderValueManager.OnHeartbeat(SystemClock.UtcNow); + Metrics = metrics; } public ILoggerFactory LoggerFactory { get; set; } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/ConnectionLimitTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/ConnectionLimitTests.cs index 92816ed4e2d8..15393c8950e1 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/ConnectionLimitTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/ConnectionLimitTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.Metrics; using System.Net; using System.Threading; using System.Threading.Tasks; @@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests.TestTransport; using Microsoft.AspNetCore.Server.Kestrel.Tests; using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Metrics; using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests; @@ -99,6 +101,9 @@ public async Task UpgradedConnectionsCountsAgainstDifferentLimit() [Fact] public async Task RejectsConnectionsWhenLimitReached() { + var testMeterFactory = new TestMeterFactory(); + using var rejectedConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "rejected-connections"); + const int max = 10; var requestTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -106,7 +111,7 @@ public async Task RejectsConnectionsWhenLimitReached() { await context.Response.WriteAsync("Hello"); await requestTcs.Task; - }, max)) + }, max, meterFactory: testMeterFactory)) { using (var disposables = new DisposableStack()) { @@ -134,11 +139,16 @@ public async Task RejectsConnectionsWhenLimitReached() // connection should close without sending any data await connection.WaitForConnectionClose(); } + + var actions = Enumerable.Repeat(AssertCounter, i + 1).ToArray(); + Assert.Collection(rejectedConnections.GetMeasurements(), actions); } requestTcs.TrySetResult(); } } + + static void AssertCounter(Measurement measurement) => Assert.Equal(1, measurement.Value); } [Fact] @@ -196,21 +206,21 @@ public async Task ConnectionCountingReturnsToZero() } } - private TestServer CreateServerWithMaxConnections(RequestDelegate app, long max) + private TestServer CreateServerWithMaxConnections(RequestDelegate app, long max, IMeterFactory meterFactory = null) { - var serviceContext = new TestServiceContext(LoggerFactory); + var serviceContext = new TestServiceContext(LoggerFactory, metrics: new KestrelMetrics(meterFactory ?? new TestMeterFactory())); serviceContext.ServerOptions.Limits.MaxConcurrentConnections = max; return new TestServer(app, serviceContext); } - private TestServer CreateServerWithMaxConnections(RequestDelegate app, ResourceCounter concurrentConnectionCounter) + private TestServer CreateServerWithMaxConnections(RequestDelegate app, ResourceCounter concurrentConnectionCounter, IMeterFactory meterFactory = null) { - var serviceContext = new TestServiceContext(LoggerFactory); + var serviceContext = new TestServiceContext(LoggerFactory, metrics: new KestrelMetrics(meterFactory ?? new TestMeterFactory())); var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)); listenOptions.Use(next => { - var middleware = new ConnectionLimitMiddleware(c => next(c), concurrentConnectionCounter, serviceContext.Log); + var middleware = new ConnectionLimitMiddleware(c => next(c), concurrentConnectionCounter, serviceContext.Log, metrics: serviceContext.Metrics); return middleware.OnConnectionAsync; }); diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/EventSourceTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/EventSourceTests.cs index 7f8f4f23e7ed..0e0f9de218a8 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/EventSourceTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/EventSourceTests.cs @@ -1,19 +1,13 @@ // 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.Tracing; -using System.IO; -using System.Linq; using System.Net.Http; using System.Net.Security; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.Features; @@ -23,7 +17,6 @@ using Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests.TestTransport; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Logging; -using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests; @@ -380,7 +373,7 @@ public async Task ConnectionLimitExceeded_EmitsStartAndStopEventsWithActivityIds listenOptions.Use(next => { - return new ConnectionLimitMiddleware(c => next(c), connectionLimit: 0, serviceContext.Log).OnConnectionAsync; + return new ConnectionLimitMiddleware(c => next(c), connectionLimit: 0, serviceContext.Log, metrics: null).OnConnectionAsync; }); })) { diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs index 749f06be5fe4..4557297b8a67 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests.TestTransport; @@ -22,6 +23,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Metrics; using Moq; namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests; @@ -31,6 +33,16 @@ public class HttpsConnectionMiddlewareTests : LoggedTest private static readonly X509Certificate2 _x509Certificate2 = TestResources.GetTestCertificate(); private static readonly X509Certificate2 _x509Certificate2NoExt = TestResources.GetTestCertificate("no_extensions.pfx"); + private static KestrelServerOptions CreateServerOptions() + { + var serverOptions = new KestrelServerOptions(); + serverOptions.ApplicationServices = new ServiceCollection() + .AddLogging() + .AddSingleton(new KestrelMetrics(new TestMeterFactory())) + .BuildServiceProvider(); + return serverOptions; + } + [Fact] public async Task CanReadAndWriteWithHttpsConnectionMiddleware() { @@ -61,15 +73,13 @@ public async Task CanReadAndWriteWithHttpsConnectionMiddlewareWithPemCertificate ["Certificates:Default:Password"] = "aspnetcore", }).Build(); - var options = new KestrelServerOptions(); var env = new Mock(); env.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory()); - var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); - options.ApplicationServices = serviceProvider; + var options = CreateServerOptions(); - var logger = serviceProvider.GetRequiredService>(); - var httpsLogger = serviceProvider.GetRequiredService>(); + var logger = options.ApplicationServices.GetRequiredService>(); + var httpsLogger = options.ApplicationServices.GetRequiredService>(); var loader = new KestrelConfigurationLoader(options, configuration, env.Object, reloadOnChange: false, logger, httpsLogger); options.ConfigurationLoader = loader; // Since we're constructing it explicitly, we have to hook it up explicitly loader.Load(); @@ -182,14 +192,14 @@ void ConfigureListenOptions(ListenOptions listenOptions) [Fact] public async Task RequireCertificateFailsWhenNoCertificate() { - var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)); - listenOptions.UseHttps(new HttpsConnectionAdapterOptions + await using (var server = new TestServer(App, new TestServiceContext(LoggerFactory), listenOptions => { - ServerCertificate = _x509Certificate2, - ClientCertificateMode = ClientCertificateMode.RequireCertificate - }); - - await using (var server = new TestServer(App, new TestServiceContext(LoggerFactory), listenOptions)) + listenOptions.UseHttps(new HttpsConnectionAdapterOptions + { + ServerCertificate = _x509Certificate2, + ClientCertificateMode = ClientCertificateMode.RequireCertificate + }); + })) { await Assert.ThrowsAnyAsync( () => server.HttpClientSlim.GetStringAsync($"https://localhost:{server.Port}/")); @@ -252,9 +262,7 @@ void ConfigureListenOptions(ListenOptions listenOptions) [Fact] public void ThrowsWhenNoServerCertificateIsProvided() { - Assert.Throws(() => new HttpsConnectionMiddleware(context => Task.CompletedTask, - new HttpsConnectionAdapterOptions()) - ); + Assert.Throws(() => CreateMiddleware(new HttpsConnectionAdapterOptions())); } [Fact] @@ -1290,10 +1298,7 @@ public void AcceptsCertificateWithoutExtensions(string testCertName) var cert = new X509Certificate2(certPath, "testPassword"); Assert.Empty(cert.Extensions.OfType()); - new HttpsConnectionMiddleware(context => Task.CompletedTask, new HttpsConnectionAdapterOptions - { - ServerCertificate = cert, - }); + CreateMiddleware(cert); } [Theory] @@ -1308,7 +1313,7 @@ public void ValidatesEnhancedKeyUsageOnCertificate(string testCertName) var eku = Assert.Single(cert.Extensions.OfType()); Assert.NotEmpty(eku.EnhancedKeyUsages); - new HttpsConnectionMiddleware(context => Task.CompletedTask, new HttpsConnectionAdapterOptions + CreateMiddleware(new HttpsConnectionAdapterOptions { ServerCertificate = cert, }); @@ -1327,7 +1332,7 @@ public void ThrowsForCertificatesMissingServerEku(string testCertName) Assert.NotEmpty(eku.EnhancedKeyUsages); var ex = Assert.Throws(() => - new HttpsConnectionMiddleware(context => Task.CompletedTask, new HttpsConnectionAdapterOptions + CreateMiddleware(new HttpsConnectionAdapterOptions { ServerCertificate = cert, })); @@ -1379,7 +1384,7 @@ public void Http1AndHttp2DowngradeToHttp1ForHttpsOnIncompatibleWindowsVersions() ServerCertificate = _x509Certificate2, HttpProtocols = HttpProtocols.Http1AndHttp2 }; - new HttpsConnectionMiddleware(context => Task.CompletedTask, httpConnectionAdapterOptions); + CreateMiddleware(httpConnectionAdapterOptions); Assert.Equal(HttpProtocols.Http1, httpConnectionAdapterOptions.HttpProtocols); } @@ -1394,7 +1399,7 @@ public void Http1AndHttp2DoesNotDowngradeOnCompatibleWindowsVersions() ServerCertificate = _x509Certificate2, HttpProtocols = HttpProtocols.Http1AndHttp2 }; - new HttpsConnectionMiddleware(context => Task.CompletedTask, httpConnectionAdapterOptions); + CreateMiddleware(httpConnectionAdapterOptions); Assert.Equal(HttpProtocols.Http1AndHttp2, httpConnectionAdapterOptions.HttpProtocols); } @@ -1410,7 +1415,7 @@ public void Http2ThrowsOnIncompatibleWindowsVersions() HttpProtocols = HttpProtocols.Http2 }; - Assert.Throws(() => new HttpsConnectionMiddleware(context => Task.CompletedTask, httpConnectionAdapterOptions)); + Assert.Throws(() => CreateMiddleware(httpConnectionAdapterOptions)); } [ConditionalFact] @@ -1425,7 +1430,20 @@ public void Http2DoesNotThrowOnCompatibleWindowsVersions() }; // Does not throw - new HttpsConnectionMiddleware(context => Task.CompletedTask, httpConnectionAdapterOptions); + CreateMiddleware(httpConnectionAdapterOptions); + } + + private static HttpsConnectionMiddleware CreateMiddleware(X509Certificate2 serverCertificate) + { + return CreateMiddleware(new HttpsConnectionAdapterOptions + { + ServerCertificate = serverCertificate, + }); + } + + private static HttpsConnectionMiddleware CreateMiddleware(HttpsConnectionAdapterOptions options) + { + return new HttpsConnectionMiddleware(context => Task.CompletedTask, options, new KestrelMetrics(new TestMeterFactory())); } private static async Task App(HttpContext httpContext) diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs index 3ee91abd9d8b..6d39bff9dfb7 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests.TestTransport; @@ -22,6 +23,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Metrics; using Microsoft.Extensions.Options; using Xunit; @@ -31,11 +33,12 @@ public class HttpsTests : LoggedTest { private static readonly X509Certificate2 _x509Certificate2 = TestResources.GetTestCertificate(); - private KestrelServerOptions CreateServerOptions() + private static KestrelServerOptions CreateServerOptions() { var serverOptions = new KestrelServerOptions(); serverOptions.ApplicationServices = new ServiceCollection() .AddLogging() + .AddSingleton(new KestrelMetrics(new TestMeterFactory())) .BuildServiceProvider(); return serverOptions; } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/KestrelMetricsTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/KestrelMetricsTests.cs new file mode 100644 index 000000000000..656b854bdc1f --- /dev/null +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/KestrelMetricsTests.cs @@ -0,0 +1,306 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.Metrics; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests.TestTransport; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Metrics; + +namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests; + +public class KestrelMetricsTests : TestApplicationErrorLoggerLoggedTest +{ + private static readonly X509Certificate2 _x509Certificate2 = TestResources.GetTestCertificate(); + + [Fact] + public async Task Http1Connection() + { + var sync = new SyncPoint(); + + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)); + listenOptions.Use(next => + { + return async connectionContext => + { + connectionContext.Features.Get().Tags.Add(new KeyValuePair("custom", "value!")); + + // Wait for the test to verify the connection has started. + await sync.WaitToContinue(); + + await next(connectionContext); + }; + }); + + var testMeterFactory = new TestMeterFactory(); + using var connectionDuration = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "connection-duration"); + using var currentConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "current-connections"); + using var queuedConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "queued-connections"); + + var serviceContext = new TestServiceContext(LoggerFactory, metrics: new KestrelMetrics(testMeterFactory)); + + var sendString = "POST / HTTP/1.0\r\nContent-Length: 12\r\n\r\nHello World?"; + + await using var server = new TestServer(EchoApp, serviceContext, listenOptions); + + using (var connection = server.CreateConnection()) + { + await connection.Send(sendString); + + // Wait for connection to start on the server. + await sync.WaitForSyncPoint(); + + Assert.Empty(connectionDuration.GetMeasurements()); + Assert.Collection(currentConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0")); + + // Signal that connection can continue. + sync.Continue(); + + await connection.ReceiveEnd( + "HTTP/1.1 200 OK", + "Connection: close", + $"Date: {serviceContext.DateHeaderValue}", + "", + "Hello World?"); + + await connection.WaitForConnectionClose(); + } + + Assert.Collection(connectionDuration.GetMeasurements(), m => + { + AssertDuration(m, "127.0.0.1:0"); + Assert.Equal("value!", (string)m.Tags.ToArray().Single(t => t.Key == "custom").Value); + }); + Assert.Collection(currentConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + Assert.Collection(queuedConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + } + + [Fact] + public async Task Http1Connection_Error() + { + var sync = new SyncPoint(); + + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)); + listenOptions.Use(next => + { + return async connectionContext => + { + // Wait for the test to verify the connection has started. + await sync.WaitToContinue(); + + throw new InvalidOperationException("Test"); + }; + }); + + var testMeterFactory = new TestMeterFactory(); + using var connectionDuration = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "connection-duration"); + using var currentConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "current-connections"); + using var queuedConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "queued-connections"); + + var serviceContext = new TestServiceContext(LoggerFactory, metrics: new KestrelMetrics(testMeterFactory)); + + var sendString = "POST / HTTP/1.0\r\nContent-Length: 12\r\n\r\nHello World?"; + + await using var server = new TestServer(EchoApp, serviceContext, listenOptions); + + using (var connection = server.CreateConnection()) + { + await connection.Send(sendString); + + // Wait for connection to start on the server. + await sync.WaitForSyncPoint(); + + Assert.Empty(connectionDuration.GetMeasurements()); + Assert.Collection(currentConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0")); + + // Signal that connection can continue. + sync.Continue(); + + await connection.ReceiveEnd(""); + + await connection.WaitForConnectionClose(); + } + + Assert.Collection(connectionDuration.GetMeasurements(), m => + { + AssertDuration(m, "127.0.0.1:0"); + Assert.Equal("System.InvalidOperationException", (string)m.Tags.ToArray().Single(t => t.Key == "exception-name").Value); + }); + Assert.Collection(currentConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + Assert.Collection(queuedConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + } + + [Fact] + public async Task Http1Connection_Upgrade() + { + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)); + + var testMeterFactory = new TestMeterFactory(); + using var connectionDuration = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "connection-duration"); + using var currentConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "current-connections"); + using var currentUpgradedRequests = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "current-upgraded-connections"); + + var serviceContext = new TestServiceContext(LoggerFactory, metrics: new KestrelMetrics(testMeterFactory)); + + await using var server = new TestServer(UpgradeApp, serviceContext, listenOptions); + + using (var connection = server.CreateConnection()) + { + await connection.SendEmptyGetWithUpgrade(); + await connection.ReceiveEnd("HTTP/1.1 101 Switching Protocols", + "Connection: Upgrade", + $"Date: {server.Context.DateHeaderValue}", + "", + ""); + } + + Assert.Collection(connectionDuration.GetMeasurements(), m => AssertDuration(m, "127.0.0.1:0")); + Assert.Collection(currentConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + Assert.Collection(currentUpgradedRequests.GetMeasurements(), m => Assert.Equal(1, m.Value), m => Assert.Equal(-1, m.Value)); + + static async Task UpgradeApp(HttpContext context) + { + var upgradeFeature = context.Features.Get(); + + if (upgradeFeature.IsUpgradableRequest) + { + await upgradeFeature.UpgradeAsync(); + } + } + } + + [ConditionalFact] + [TlsAlpnSupported] + [MinimumOSVersion(OperatingSystems.Windows, WindowsVersions.Win10)] + public async Task Http2Connection() + { + string connectionId = null; + + const int requestsToSend = 2; + var requestsReceived = 0; + + var testMeterFactory = new TestMeterFactory(); + using var connectionDuration = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "connection-duration"); + using var currentConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "current-connections"); + using var queuedConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "queued-connections"); + using var queuedRequests = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "queued-requests"); + using var tlsHandshakeDuration = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "tls-handshake-duration"); + using var currentTlsHandshakes = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "current-tls-handshakes"); + + await using (var server = new TestServer(context => + { + connectionId = context.Features.Get().ConnectionId; + requestsReceived++; + return Task.CompletedTask; + }, + new TestServiceContext(LoggerFactory, metrics: new KestrelMetrics(testMeterFactory)), + listenOptions => + { + listenOptions.UseHttps(_x509Certificate2, options => + { + options.SslProtocols = SslProtocols.Tls12; + }); + listenOptions.Protocols = HttpProtocols.Http2; + })) + { + using var connection = server.CreateConnection(); + + using var socketsHandler = new SocketsHttpHandler() + { + ConnectCallback = (_, _) => + { + // This test should only require a single connection. + if (connectionId != null) + { + throw new InvalidOperationException(); + } + + return new ValueTask(connection.Stream); + }, + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true + } + }; + + using var httpClient = new HttpClient(socketsHandler); + + for (int i = 0; i < requestsToSend; i++) + { + using var httpRequestMessage = new HttpRequestMessage() + { + RequestUri = new Uri("https://localhost/"), + Version = new Version(2, 0), + VersionPolicy = HttpVersionPolicy.RequestVersionExact, + }; + + using var responseMessage = await httpClient.SendAsync(httpRequestMessage); + responseMessage.EnsureSuccessStatusCode(); + } + } + + Assert.NotNull(connectionId); + Assert.Equal(2, requestsReceived); + + Assert.Collection(connectionDuration.GetMeasurements(), m => AssertDuration(m, "127.0.0.1:0")); + Assert.Collection(currentConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + Assert.Collection(queuedConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + + Assert.Collection(queuedRequests.GetMeasurements(), + m => AssertRequestCount(m, 1, "HTTP/2"), + m => AssertRequestCount(m, -1, "HTTP/2"), + m => AssertRequestCount(m, 1, "HTTP/2"), + m => AssertRequestCount(m, -1, "HTTP/2")); + + Assert.Collection(tlsHandshakeDuration.GetMeasurements(), m => + { + Assert.True(m.Value > 0); + Assert.Equal("Tls12", (string)m.Tags.ToArray().Single(t => t.Key == "protocol").Value); + }); + Assert.Collection(currentTlsHandshakes.GetMeasurements(), m => Assert.Equal(1, m.Value), m => Assert.Equal(-1, m.Value)); + + static void AssertRequestCount(Measurement measurement, long expectedValue, string httpVersion) + { + Assert.Equal(expectedValue, measurement.Value); + Assert.Equal(httpVersion, (string)measurement.Tags.ToArray().Single(t => t.Key == "version").Value); + } + } + + private static async Task EchoApp(HttpContext httpContext) + { + var request = httpContext.Request; + var response = httpContext.Response; + var buffer = new byte[httpContext.Request.ContentLength ?? 0]; + + if (buffer.Length > 0) + { + await request.Body.FillBufferUntilEndAsync(buffer).DefaultTimeout(); + await response.Body.WriteAsync(buffer, 0, buffer.Length); + } + } + + private static void AssertDuration(Measurement measurement, string localEndpoint) + { + Assert.True(measurement.Value > 0); + Assert.Equal(localEndpoint, (string)measurement.Tags.ToArray().Single(t => t.Key == "endpoint").Value); + } + + private static void AssertCount(Measurement measurement, long expectedValue, string localEndpoint) + { + Assert.Equal(expectedValue, measurement.Value); + Assert.Equal(localEndpoint, (string)measurement.Tags.ToArray().Single(t => t.Key == "endpoint").Value); + } +} diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs index a9e22498e3a1..ee9e1efa529d 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs @@ -86,6 +86,7 @@ public TestServer(RequestDelegate app, TestServiceContext context, Action(this); services.AddSingleton(context.LoggerFactory); + services.AddSingleton(context.Metrics); services.AddSingleton(sp => { diff --git a/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs b/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs index 87d80b16a898..35b4144283da 100644 --- a/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs +++ b/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs @@ -15,11 +15,12 @@ public static string GenerateFile() "IRouteValuesFeature", "IEndpointFeature", "IServiceProvidersFeature", - "IHttpActivityFeature" }; var commonFeatures = new[] { + "IHttpActivityFeature", + "IHttpMetricsTagsFeature", "IItemsFeature", "IQueryFeature", "IRequestBodyPipeFeature", diff --git a/src/Servers/Kestrel/tools/CodeGenerator/TransportConnectionFeatureCollection.cs b/src/Servers/Kestrel/tools/CodeGenerator/TransportConnectionFeatureCollection.cs index 056320c2380c..cae7e34924c4 100644 --- a/src/Servers/Kestrel/tools/CodeGenerator/TransportConnectionFeatureCollection.cs +++ b/src/Servers/Kestrel/tools/CodeGenerator/TransportConnectionFeatureCollection.cs @@ -23,7 +23,8 @@ public static string GenerateFile() "IStreamDirectionFeature", "IStreamIdFeature", "IStreamAbortFeature", - "IStreamClosedFeature" + "IStreamClosedFeature", + "IConnectionMetricsTagsFeature" }; var implementedFeatures = new[] diff --git a/src/Shared/Metrics/DefaultMeterFactory.cs b/src/Shared/Metrics/DefaultMeterFactory.cs new file mode 100644 index 000000000000..f6961526d1f7 --- /dev/null +++ b/src/Shared/Metrics/DefaultMeterFactory.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.Metrics; +using System.Linq; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Metrics; + +// TODO: Remove when Metrics DI intergration package is available https://github.com/dotnet/aspnetcore/issues/47618 +internal sealed class DefaultMeterFactory : IMeterFactory +{ + private readonly IOptions _options; + private readonly IMeterRegistry _meterRegistry; + private readonly Dictionary _meters = new Dictionary(); + + public DefaultMeterFactory(IOptions options, IMeterRegistry meterRegistry) + { + _options = options; + _meterRegistry = meterRegistry; + } + + public Meter CreateMeter(string name) + { + return CreateMeterCore(name, version: null, defaultTags: null); + } + + public Meter CreateMeter(MeterOptions options) + { + return CreateMeterCore(options.Name, options.Version, options.DefaultTags); + } + + private Meter CreateMeterCore(string name, string? version, IList>? defaultTags) + { + var tags = defaultTags?.ToArray(); + if (tags != null) + { + Array.Sort(tags, (t1, t2) => string.Compare(t1.Key, t2.Key, StringComparison.Ordinal)); + } + var key = new MeterKey(name, version, tags); + + if (_meters.TryGetValue(key, out var meter)) + { + return meter; + } + + // TODO: Configure meter with default tags. + meter = new Meter(name, version); + _meters[key] = meter; + _meterRegistry.Add(meter); + + return meter; + } + + private readonly struct MeterKey : IEquatable + { + public MeterKey(string name, string? version, KeyValuePair[]? defaultTags) + { + Name = name; + Version = version; + DefaultTags = defaultTags; + } + + public string Name { get; } + public string? Version { get; } + public IList>? DefaultTags { get; } + + public bool Equals(MeterKey other) + { + return Name == other.Name + && Version == other.Version + && TagsEqual(other); + } + + private bool TagsEqual(MeterKey other) + { + if (DefaultTags is null && other.DefaultTags is null) + { + return true; + } + if (DefaultTags is not null && other.DefaultTags is not null && DefaultTags.SequenceEqual(other.DefaultTags)) + { + return true; + } + return false; + } + + public override bool Equals(object? obj) + { + return obj is MeterKey key && Equals(key); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Name); + hashCode.Add(Version); + if (DefaultTags is not null) + { + foreach (var item in DefaultTags) + { + hashCode.Add(item); + } + } + + return hashCode.ToHashCode(); + } + } +} diff --git a/src/Shared/Metrics/IMeterFactory.cs b/src/Shared/Metrics/IMeterFactory.cs new file mode 100644 index 000000000000..82563cc89701 --- /dev/null +++ b/src/Shared/Metrics/IMeterFactory.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.Metrics; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Metrics; + +// TODO: Remove when Metrics DI intergration package is available https://github.com/dotnet/aspnetcore/issues/47618 +internal sealed class MetricsOptions +{ + public IList> DefaultTags { get; } = new List>(); +} + +internal interface IMetricsBuilder +{ + IServiceCollection Services { get; } +} + +internal sealed class MetricsBuilder : IMetricsBuilder +{ + public MetricsBuilder(IServiceCollection services) => Services = services; + public IServiceCollection Services { get; } +} + +internal sealed class MeterOptions +{ + public required string Name { get; set; } + public string? Version { get; set; } + public IList>? DefaultTags { get; set; } +} + +internal interface IMeterFactory +{ + Meter CreateMeter(string name); + Meter CreateMeter(MeterOptions options); +} + +internal interface IMeterRegistry +{ + void Add(Meter meter); + bool Contains(Meter meter); +} + +internal sealed class DefaultMeterRegistry : IMeterRegistry, IDisposable +{ + private readonly object _lock = new object(); + private readonly List _meters = new List(); + + public void Add(Meter meter) + { + lock (_lock) + { + _meters.Add(meter); + } + } + + public bool Contains(Meter meter) + { + lock (_lock) + { + return _meters.Contains(meter); + } + } + + public void Dispose() + { + lock (_lock) + { + foreach (var meter in _meters) + { + meter.Dispose(); + } + _meters.Clear(); + } + } +} diff --git a/src/Shared/Metrics/InstrumentRecorder.cs b/src/Shared/Metrics/InstrumentRecorder.cs new file mode 100644 index 000000000000..33fb51ea8f4e --- /dev/null +++ b/src/Shared/Metrics/InstrumentRecorder.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.Metrics; + +namespace Microsoft.Extensions.Metrics; + +// TODO: Remove when Metrics DI intergration package is available https://github.com/dotnet/aspnetcore/issues/47618 +internal sealed class InstrumentRecorder : IDisposable where T : struct +{ + private readonly object _lock = new object(); + private readonly string _meterName; + private readonly string _instrumentName; + private readonly MeterListener _meterListener; + private readonly List> _values; + private readonly List>> _callbacks; + + public InstrumentRecorder(IMeterRegistry registry, string meterName, string instrumentName, object? state = null) + { + _meterName = meterName; + _instrumentName = instrumentName; + _callbacks = new List>>(); + _values = new List>(); + _meterListener = new MeterListener(); + _meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == _meterName && registry.Contains(instrument.Meter) && instrument.Name == _instrumentName) + { + listener.EnableMeasurementEvents(instrument, state); + } + }; + _meterListener.SetMeasurementEventCallback(OnMeasurementRecorded); + _meterListener.Start(); + } + + private void OnMeasurementRecorded(Instrument instrument, T measurement, ReadOnlySpan> tags, object? state) + { + lock (_lock) + { + var m = new Measurement(measurement, tags); + _values.Add(m); + + // Should this happen in the lock? + // Is there a better way to notify listeners that there are new measurements? + foreach (var callback in _callbacks) + { + callback(m); + } + } + } + + public void Register(Action> callback) + { + _callbacks.Add(callback); + } + + public IReadOnlyList> GetMeasurements() + { + lock (_lock) + { + return _values.ToArray(); + } + } + + public void Dispose() + { + _meterListener.Dispose(); + } +} diff --git a/src/Shared/Metrics/MetricsServiceExtensions.cs b/src/Shared/Metrics/MetricsServiceExtensions.cs new file mode 100644 index 000000000000..f18fa7148476 --- /dev/null +++ b/src/Shared/Metrics/MetricsServiceExtensions.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Metrics; + +namespace Microsoft.Extensions.DependencyInjection; + +// TODO: Remove when Metrics DI intergration package is available https://github.com/dotnet/aspnetcore/issues/47618 +internal static class MetricsServiceExtensions +{ + public static IServiceCollection AddMetrics(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } + + public static IServiceCollection AddMetrics(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddMetrics(); + configure(new MetricsBuilder(services)); + + return services; + } + + public static IMetricsBuilder AddDefaultTag(this IMetricsBuilder builder, string name, object? value) + { + builder.Services.Configure(o => o.DefaultTags.Add(new KeyValuePair(name, value))); + return builder; + } +} diff --git a/src/Shared/Metrics/TestMeterFactory.cs b/src/Shared/Metrics/TestMeterFactory.cs new file mode 100644 index 000000000000..9f1f2495d247 --- /dev/null +++ b/src/Shared/Metrics/TestMeterFactory.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.Metrics; + +namespace Microsoft.Extensions.Metrics; + +// TODO: Remove when Metrics DI intergration package is available https://github.com/dotnet/aspnetcore/issues/47618 +internal class TestMeterFactory : IMeterFactory +{ + public List Meters { get; } = new List(); + + public Meter CreateMeter(string name) + { + var meter = new Meter(name); + Meters.Add(meter); + return meter; + } + + public Meter CreateMeter(MeterOptions options) + { + var meter = new Meter(options.Name, options.Version); + Meters.Add(meter); + return meter; + } +} + +internal class TestMeterRegistry : IMeterRegistry +{ + private readonly List _meters; + + public TestMeterRegistry() : this(new List()) + { + } + + public TestMeterRegistry(List meters) + { + _meters = meters; + } + + public void Add(Meter meter) => _meters.Add(meter); + + public bool Contains(Meter meter) => _meters.Contains(meter); +} diff --git a/src/Shared/ValueStopwatch/ValueStopwatch.cs b/src/Shared/ValueStopwatch/ValueStopwatch.cs index 6441ce5aaaba..d96ec70f87bb 100644 --- a/src/Shared/ValueStopwatch/ValueStopwatch.cs +++ b/src/Shared/ValueStopwatch/ValueStopwatch.cs @@ -8,7 +8,9 @@ namespace Microsoft.Extensions.Internal; internal struct ValueStopwatch { +#if !NET7_0_OR_GREATER private static readonly double TimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; +#endif private readonly long _startTimestamp; @@ -31,8 +33,13 @@ public TimeSpan GetElapsedTime() } var end = Stopwatch.GetTimestamp(); + +#if !NET7_0_OR_GREATER var timestampDelta = end - _startTimestamp; var ticks = (long)(TimestampToTicks * timestampDelta); return new TimeSpan(ticks); +#else + return Stopwatch.GetElapsedTime(_startTimestamp, end); +#endif } } diff --git a/src/SignalR/common/Http.Connections/src/ConnectionsDependencyInjectionExtensions.cs b/src/SignalR/common/Http.Connections/src/ConnectionsDependencyInjectionExtensions.cs index 5df56e2ac495..4ccfa7eb342d 100644 --- a/src/SignalR/common/Http.Connections/src/ConnectionsDependencyInjectionExtensions.cs +++ b/src/SignalR/common/Http.Connections/src/ConnectionsDependencyInjectionExtensions.cs @@ -25,6 +25,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services services.TryAddEnumerable(ServiceDescriptor.Singleton, ConnectionOptionsSetup>()); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs index db9b11d30498..469a60666d45 100644 --- a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs +++ b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs @@ -41,6 +41,7 @@ internal sealed partial class HttpConnectionDispatcher private readonly HttpConnectionManager _manager; private readonly ILoggerFactory _loggerFactory; + private readonly HttpConnectionsMetrics _metrics; private readonly ILogger _logger; private const int _protocolVersion = 1; @@ -49,10 +50,11 @@ internal sealed partial class HttpConnectionDispatcher private const string HeaderValueNoCacheNoStore = "no-cache, no-store"; private const string HeaderValueEpochDate = "Thu, 01 Jan 1970 00:00:00 GMT"; - public HttpConnectionDispatcher(HttpConnectionManager manager, ILoggerFactory loggerFactory) + public HttpConnectionDispatcher(HttpConnectionManager manager, ILoggerFactory loggerFactory, HttpConnectionsMetrics metrics) { _manager = manager; _loggerFactory = loggerFactory; + _metrics = metrics; _logger = _loggerFactory.CreateLogger(); } @@ -238,7 +240,7 @@ private async Task ExecuteAsync(HttpContext context, ConnectionDelegate connecti // We should be able to safely dispose because there's no more data being written // We don't need to wait for close here since we've already waited for both sides - await _manager.DisposeAndRemoveAsync(connection, closeGracefully: false); + await _manager.DisposeAndRemoveAsync(connection, closeGracefully: false, HttpConnectionStopStatus.NormalClosure); } else { @@ -252,7 +254,7 @@ private async Task ExecuteAsync(HttpContext context, ConnectionDelegate connecti currentRequestTcs.TrySetCanceled(); // We should be able to safely dispose because there's no more data being written // We don't need to wait for close here since we've already waited for both sides - await _manager.DisposeAndRemoveAsync(connection, closeGracefully: false); + await _manager.DisposeAndRemoveAsync(connection, closeGracefully: false, HttpConnectionStopStatus.NormalClosure); } else { @@ -280,7 +282,7 @@ private async Task DoPersistentConnection(ConnectionDelegate connectionDelegate, // Wait for any of them to end await Task.WhenAny(connection.ApplicationTask!, connection.TransportTask!); - await _manager.DisposeAndRemoveAsync(connection, closeGracefully: true); + await _manager.DisposeAndRemoveAsync(connection, closeGracefully: true, HttpConnectionStopStatus.NormalClosure); } } @@ -503,7 +505,7 @@ private async Task ProcessDeleteAsync(HttpContext context) Log.TerminatingConnection(_logger); // Dispose the connection, but don't wait for it. We assign it here so we can wait in tests - connection.DisposeAndRemoveTask = _manager.DisposeAndRemoveAsync(connection, closeGracefully: false); + connection.DisposeAndRemoveTask = _manager.DisposeAndRemoveAsync(connection, closeGracefully: false, HttpConnectionStopStatus.NormalClosure); context.Response.StatusCode = StatusCodes.Status202Accepted; context.Response.ContentType = "text/plain"; @@ -526,6 +528,7 @@ private async Task EnsureConnectionStateAsync(HttpConnectionContext connec if (connection.TransportType == HttpTransportType.None) { connection.TransportType = transportType; + _metrics.TransportStart(transportType); } else if (connection.TransportType != transportType) { diff --git a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionManager.cs b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionManager.cs index 39b56cf4e5eb..61a133ae0b31 100644 --- a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionManager.cs +++ b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionManager.cs @@ -19,19 +19,21 @@ internal sealed partial class HttpConnectionManager // TODO: Consider making this configurable? At least for testing? private static readonly TimeSpan _heartbeatTickRate = TimeSpan.FromSeconds(1); - private readonly ConcurrentDictionary _connections = - new ConcurrentDictionary(StringComparer.Ordinal); + private readonly ConcurrentDictionary _connections = + new ConcurrentDictionary(StringComparer.Ordinal); private readonly PeriodicTimer _nextHeartbeat; private readonly ILogger _logger; private readonly ILogger _connectionLogger; private readonly long _disconnectTimeoutTicks; + private readonly HttpConnectionsMetrics _metrics; - public HttpConnectionManager(ILoggerFactory loggerFactory, IHostApplicationLifetime appLifetime, IOptions connectionOptions) + public HttpConnectionManager(ILoggerFactory loggerFactory, IHostApplicationLifetime appLifetime, IOptions connectionOptions, HttpConnectionsMetrics metrics) { _logger = loggerFactory.CreateLogger(); _connectionLogger = loggerFactory.CreateLogger(); _nextHeartbeat = new PeriodicTimer(_heartbeatTickRate); _disconnectTimeoutTicks = (long)(connectionOptions.Value.DisconnectTimeout ?? ConnectionOptionsSetup.DefaultDisconectTimeout).TotalMilliseconds; + _metrics = metrics; // Register these last as the callbacks could run immediately appLifetime.ApplicationStarted.Register(Start); @@ -78,22 +80,32 @@ internal HttpConnectionContext CreateConnection(HttpConnectionDispatcherOptions connectionToken = id; } + var startTimestamp = HttpConnectionsEventSource.Log.IsEnabled() || _metrics.IsEnabled() + ? Stopwatch.GetTimestamp() + : default; + Log.CreatedNewConnection(_logger, id); - var connectionTimer = HttpConnectionsEventSource.Log.ConnectionStart(id); + HttpConnectionsEventSource.Log.ConnectionStart(id); + _metrics.ConnectionStart(); + var pair = DuplexPipe.CreateConnectionPair(options.TransportPipeOptions, options.AppPipeOptions); var connection = new HttpConnectionContext(id, connectionToken, _connectionLogger, pair.Application, pair.Transport, options); - _connections.TryAdd(connectionToken, (connection, connectionTimer)); + _connections.TryAdd(connectionToken, (connection, startTimestamp)); return connection; } - public void RemoveConnection(string id) + public void RemoveConnection(string id, HttpTransportType transportType, HttpConnectionStopStatus status) { if (_connections.TryRemove(id, out var pair)) { // Remove the connection completely - HttpConnectionsEventSource.Log.ConnectionStop(id, pair.Timer); + var currentTimestamp = (pair.StartTimestamp > 0) ? Stopwatch.GetTimestamp() : default; + + HttpConnectionsEventSource.Log.ConnectionStop(id, pair.StartTimestamp, currentTimestamp); + _metrics.TransportStop(transportType); + _metrics.ConnectionStop(transportType, status, pair.StartTimestamp, currentTimestamp); Log.RemovedConnection(_logger, id); } } @@ -153,7 +165,7 @@ public void Scan() // This is most likely a long polling connection. The transport here ends because // a poll completed and has been inactive for > 5 seconds so we wait for the // application to finish gracefully - _ = DisposeAndRemoveAsync(connection, closeGracefully: true); + _ = DisposeAndRemoveAsync(connection, closeGracefully: true, HttpConnectionStopStatus.Timeout); } else { @@ -187,13 +199,13 @@ public void CloseConnections() foreach (var c in _connections) { // We're shutting down so don't wait for closing the application - tasks.Add(DisposeAndRemoveAsync(c.Value.Connection, closeGracefully: false)); + tasks.Add(DisposeAndRemoveAsync(c.Value.Connection, closeGracefully: false, HttpConnectionStopStatus.AppShutdown)); } Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(5)); } - internal async Task DisposeAndRemoveAsync(HttpConnectionContext connection, bool closeGracefully) + internal async Task DisposeAndRemoveAsync(HttpConnectionContext connection, bool closeGracefully, HttpConnectionStopStatus status) { try { @@ -215,7 +227,7 @@ internal async Task DisposeAndRemoveAsync(HttpConnectionContext connection, bool { // Remove it from the list after disposal so that's it's easy to see // connections that might be in a hung state via the connections list - RemoveConnection(connection.ConnectionToken); + RemoveConnection(connection.ConnectionToken, connection.TransportType, status); } } } diff --git a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionStopStatus.cs b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionStopStatus.cs new file mode 100644 index 000000000000..222424686c2a --- /dev/null +++ b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionStopStatus.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Connections.Internal; + +internal enum HttpConnectionStopStatus +{ + NormalClosure, + Timeout, + AppShutdown +} diff --git a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionsEventSource.cs b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionsEventSource.cs index 1fd5cf1483bf..a96d84664b1b 100644 --- a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionsEventSource.cs +++ b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionsEventSource.cs @@ -1,8 +1,8 @@ // 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.Tracing; -using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Http.Connections.Internal; @@ -35,15 +35,15 @@ internal HttpConnectionsEventSource(string eventSourceName) // This has to go through NonEvent because only Primitive types are allowed // in function parameters for Events [NonEvent] - public void ConnectionStop(string connectionId, ValueStopwatch timer) + public void ConnectionStop(string connectionId, long startTimestamp, long currentTimestamp) { Interlocked.Increment(ref _connectionsStopped); Interlocked.Decrement(ref _currentConnections); if (IsEnabled()) { - var duration = timer.IsActive ? timer.GetElapsedTime().TotalMilliseconds : 0.0; - _connectionDuration!.WriteMetric(duration); + var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); + _connectionDuration!.WriteMetric(duration.TotalMilliseconds); if (IsEnabled(EventLevel.Informational, EventKeywords.None)) { @@ -53,7 +53,7 @@ public void ConnectionStop(string connectionId, ValueStopwatch timer) } [Event(eventId: 1, Level = EventLevel.Informational, Message = "Started connection '{0}'.")] - public ValueStopwatch ConnectionStart(string connectionId) + public void ConnectionStart(string connectionId) { Interlocked.Increment(ref _connectionsStarted); Interlocked.Increment(ref _currentConnections); @@ -61,9 +61,7 @@ public ValueStopwatch ConnectionStart(string connectionId) if (IsEnabled(EventLevel.Informational, EventKeywords.None)) { WriteEvent(1, connectionId); - return ValueStopwatch.StartNew(); } - return default; } [Event(eventId: 2, Level = EventLevel.Informational, Message = "Stopped connection '{0}'.")] diff --git a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionsMetrics.cs b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionsMetrics.cs new file mode 100644 index 000000000000..e55520538792 --- /dev/null +++ b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionsMetrics.cs @@ -0,0 +1,81 @@ +// 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 Microsoft.Extensions.Metrics; + +namespace Microsoft.AspNetCore.Http.Connections.Internal; + +internal sealed class HttpConnectionsMetrics : IDisposable +{ + public const string MeterName = "Microsoft.AspNetCore.Http.Connections"; + + private readonly Meter _meter; + private readonly UpDownCounter _currentConnectionsCounter; + private readonly Histogram _connectionDuration; + private readonly UpDownCounter _currentTransportsCounter; + + public HttpConnectionsMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.CreateMeter(MeterName); + + _currentConnectionsCounter = _meter.CreateUpDownCounter( + "current-connections", + description: "Number of connections that are currently active on the server."); + + _connectionDuration = _meter.CreateHistogram( + "connection-duration", + unit: "s", + description: "The duration of connections on the server."); + + _currentTransportsCounter = _meter.CreateUpDownCounter( + "current-transports", + description: "Number of negotiated transports that are currently active on the server."); + } + + public void ConnectionStart() + { + // Tags must match connection end. + _currentConnectionsCounter.Add(1); + } + + public void ConnectionStop(HttpTransportType transportType, HttpConnectionStopStatus status, long startTimestamp, long currentTimestamp) + { + // Tags must match connection start. + _currentConnectionsCounter.Add(-1); + + if (_connectionDuration.Enabled) + { + var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); + _connectionDuration.Record(duration.TotalSeconds, + new KeyValuePair("status", status.ToString()), + new KeyValuePair("transport", transportType.ToString())); + } + } + + public void TransportStart(HttpTransportType transportType) + { + Debug.Assert(transportType != HttpTransportType.None); + + // Tags must match transport end. + _currentTransportsCounter.Add(1, new KeyValuePair("transport", transportType.ToString())); + } + + public void TransportStop(HttpTransportType transportType) + { + // Tags must match transport start. + // If the transport type is none then the transport was never started for this connection. + if (transportType != HttpTransportType.None) + { + _currentTransportsCounter.Add(-1, new KeyValuePair("transport", transportType.ToString())); + } + } + + public void Dispose() + { + _meter.Dispose(); + } + + public bool IsEnabled() => _currentConnectionsCounter.Enabled || _connectionDuration.Enabled; +} diff --git a/src/SignalR/common/Http.Connections/src/Microsoft.AspNetCore.Http.Connections.csproj b/src/SignalR/common/Http.Connections/src/Microsoft.AspNetCore.Http.Connections.csproj index b0310d4225e2..e6ee74ec7735 100644 --- a/src/SignalR/common/Http.Connections/src/Microsoft.AspNetCore.Http.Connections.csproj +++ b/src/SignalR/common/Http.Connections/src/Microsoft.AspNetCore.Http.Connections.csproj @@ -36,8 +36,6 @@ - - diff --git a/src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs b/src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs index 241fad3e9be9..9d16f32f4a6b 100644 --- a/src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs +++ b/src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.Diagnostics; +using System.Diagnostics.Metrics; using System.IdentityModel.Tokens.Jwt; using System.IO.Pipelines; using System.Net; @@ -35,6 +36,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Metrics; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Tokens; @@ -52,7 +54,7 @@ public async Task NegotiateVersionZeroReservesConnectionIdAndReturnsIt() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); var services = new ServiceCollection(); services.AddSingleton(); @@ -76,7 +78,7 @@ public async Task NegotiateReservesConnectionTokenAndConnectionIdAndReturnsIt() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); var services = new ServiceCollection(); services.AddSingleton(); @@ -102,7 +104,7 @@ public async Task CheckThatThresholdValuesAreEnforced() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); var services = new ServiceCollection(); services.AddSingleton(); @@ -139,7 +141,7 @@ public async Task InvalidNegotiateProtocolVersionThrows() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); var services = new ServiceCollection(); services.AddSingleton(); @@ -167,7 +169,7 @@ public async Task NoNegotiateVersionInQueryStringThrowsWhenMinProtocolVersionIsS using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); var services = new ServiceCollection(); services.AddSingleton(); @@ -197,7 +199,7 @@ public async Task CheckThatThresholdValuesAreEnforcedWithSends(HttpTransportType using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var options = new HttpConnectionDispatcherOptions(); options.TransportMaxBufferSize = 8; options.ApplicationMaxBufferSize = 8; @@ -252,7 +254,7 @@ public async Task NegotiateReturnsAvailableTransportsAfterFilteringByOptions(Htt using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); context.Features.Set(new ResponseFeature()); context.Features.Set(new TestWebSocketConnectionFeature()); @@ -287,7 +289,7 @@ public async Task EndpointsThatAcceptConnectionId404WhenUnknownConnectionIdProvi using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); using (var strm = new MemoryStream()) { @@ -330,7 +332,7 @@ public async Task EndpointsThatAcceptConnectionId404WhenUnknownConnectionIdProvi using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); using (var strm = new MemoryStream()) { @@ -366,7 +368,7 @@ public async Task PostNotAllowedForWebSocketConnections() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.WebSockets; @@ -404,7 +406,7 @@ public async Task PostReturns404IfConnectionDisposed() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; await connection.DisposeAsync(closeGracefully: false); @@ -443,7 +445,7 @@ public async Task TransportEndingGracefullyWaitsOnApplication(HttpTransportType using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = transportType; @@ -506,7 +508,7 @@ public async Task TransportEndingGracefullyWaitsOnApplicationLongPolling() { var disconnectTimeout = TimeSpan.FromSeconds(5); var manager = CreateConnectionManager(LoggerFactory, disconnectTimeout); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; @@ -571,7 +573,7 @@ public async Task PostSendsToConnection(HttpTransportType transportType) using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = transportType; @@ -621,7 +623,7 @@ public async Task PostSendsToConnectionInParallel(HttpTransportType transportTyp using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = transportType; @@ -710,7 +712,7 @@ public async Task ResponsesForLongPollingHaveCacheHeaders() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -740,7 +742,7 @@ public async Task HttpContextFeatureForLongpollingWorksBetweenPolls() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; @@ -842,7 +844,7 @@ public async Task EndpointsThatRequireConnectionId400WhenNoConnectionIdProvided( using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); using (var strm = new MemoryStream()) { var context = new DefaultHttpContext(); @@ -882,7 +884,7 @@ public async Task IOExceptionWhenReadingRequestReturns400Response(HttpTransportT using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = transportType; @@ -919,7 +921,7 @@ public async Task EndpointsThatRequireConnectionId400WhenNoConnectionIdProvidedF using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); using (var strm = new MemoryStream()) { var context = new DefaultHttpContext(); @@ -998,7 +1000,7 @@ public async Task CompletedEndPointEndsConnection() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.ServerSentEvents; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1033,7 +1035,7 @@ bool ExpectedErrors(WriteContext writeContext) var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.ServerSentEvents; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); var context = MakeRequest("/foo", connection, services); @@ -1060,7 +1062,7 @@ public async Task CompletedEndPointEndsLongPollingConnection() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1083,6 +1085,60 @@ public async Task CompletedEndPointEndsLongPollingConnection() } } + [Fact] + public async Task Metrics() + { + using (StartVerifiableLog()) + { + var testMeterFactory = new TestMeterFactory(); + using var connectionDuration = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), HttpConnectionsMetrics.MeterName, "connection-duration"); + using var currentConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), HttpConnectionsMetrics.MeterName, "current-connections"); + using var currentTransports = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), HttpConnectionsMetrics.MeterName, "current-transports"); + + var metrics = new HttpConnectionsMetrics(testMeterFactory); + var manager = CreateConnectionManager(LoggerFactory, metrics); + var connection = manager.CreateConnection(); + + var dispatcher = CreateDispatcher(manager, LoggerFactory, metrics); + + var services = new ServiceCollection(); + services.AddSingleton(); + var context = MakeRequest("/foo", connection, services); + + var builder = new ConnectionBuilder(services.BuildServiceProvider()); + builder.UseConnectionHandler(); + var app = builder.Build(); + // First poll will 200 + await dispatcher.ExecuteAsync(context, new HttpConnectionDispatcherOptions(), app); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + await dispatcher.ExecuteAsync(context, new HttpConnectionDispatcherOptions(), app); + + Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + AssertResponseHasCacheHeaders(context.Response); + + var exists = manager.TryGetConnection(connection.ConnectionId, out _); + Assert.False(exists); + + Assert.Collection(currentConnections.GetMeasurements(), m => Assert.Equal(1, m.Value), m => Assert.Equal(-1, m.Value)); + Assert.Collection(connectionDuration.GetMeasurements(), m => AssertDuration(m, HttpConnectionStopStatus.NormalClosure, HttpTransportType.LongPolling)); + Assert.Collection(currentTransports.GetMeasurements(), m => AssertTransport(m, 1, HttpTransportType.LongPolling), m => AssertTransport(m, -1, HttpTransportType.LongPolling)); + } + + static void AssertTransport(Measurement measurement, long expected, HttpTransportType transportType) + { + Assert.Equal(expected, measurement.Value); + Assert.Equal(transportType.ToString(), (string)measurement.Tags.ToArray().Single(t => t.Key == "transport").Value); + } + + static void AssertDuration(Measurement measurement, HttpConnectionStopStatus status, HttpTransportType transportType) + { + Assert.True(measurement.Value > 0); + Assert.Equal(status.ToString(), (string)measurement.Tags.ToArray().Single(t => t.Key == "status").Value); + Assert.Equal(transportType.ToString(), (string)measurement.Tags.ToArray().Single(t => t.Key == "transport").Value); + } + } + [Fact] public async Task LongPollingTimeoutSets200StatusCode() { @@ -1092,7 +1148,7 @@ public async Task LongPollingTimeoutSets200StatusCode() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1187,7 +1243,7 @@ bool ExpectedErrors(WriteContext writeContext) var manager = CreateConnectionManager(LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); var context = MakeRequest("/foo", connection, services); @@ -1220,7 +1276,7 @@ public async Task SSEConnectionClosesWhenSendTimeoutReached() var manager = CreateConnectionManager(LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.ServerSentEvents; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); var context = MakeRequest("/foo", connection, services); @@ -1257,7 +1313,7 @@ bool ExpectedErrors(WriteContext writeContext) var manager = CreateConnectionManager(LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.WebSockets; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var sync = new SyncPoint(); var services = new ServiceCollection(); services.AddSingleton(); @@ -1290,7 +1346,7 @@ public async Task WebSocketTransportTimesOutWhenCloseFrameNotReceived() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.WebSockets; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1320,7 +1376,7 @@ public async Task RequestToActiveConnectionId409ForStreamingTransports(HttpTrans var connection = manager.CreateConnection(); connection.TransportType = transportType; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1363,7 +1419,7 @@ public async Task RequestToActiveConnectionIdKillsPreviousConnectionLongPolling( var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1428,7 +1484,7 @@ public async Task MultipleRequestsToActiveConnectionId409ForLongPolling() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1493,7 +1549,7 @@ public async Task RequestToDisposedConnectionIdReturns404(HttpTransportType tran connection.TransportType = transportType; connection.Status = HttpConnectionStatus.Disposed; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1524,7 +1580,7 @@ public async Task ConnectionStateSetToInactiveAfterPoll() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1559,7 +1615,7 @@ public async Task BlockingConnectionWorksWithStreamingConnections() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.ServerSentEvents; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1594,7 +1650,7 @@ public async Task BlockingConnectionWorksWithLongPollingConnection() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1636,7 +1692,7 @@ public async Task AttemptingToPollWhileAlreadyPollingReplacesTheCurrentPoll() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1683,7 +1739,7 @@ public async Task TransferModeSet(HttpTransportType transportType, TransferForma var connection = manager.CreateConnection(); connection.TransportType = transportType; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1715,7 +1771,7 @@ public async Task LongPollingKeepsWindowsPrincipalAndIdentityBetweenRequests() var manager = CreateConnectionManager(LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); var services = new ServiceCollection(); services.AddOptions(); @@ -1767,7 +1823,7 @@ public async Task LongPollingKeepsWindowsIdentityWithoutWindowsPrincipalBetweenR var manager = CreateConnectionManager(LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); var services = new ServiceCollection(); services.AddOptions(); @@ -1822,7 +1878,7 @@ public async Task WindowsIdentityNotClosed(HttpTransportType transportType) var manager = CreateConnectionManager(LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = transportType; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddOptions(); services.AddSingleton(); @@ -1868,7 +1924,7 @@ public async Task SetsInherentKeepAliveFeatureOnFirstLongPollingRequest() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1900,7 +1956,7 @@ public async Task DeleteEndpointRejectsRequestToTerminateNonLongPollingTransport var connection = manager.CreateConnection(); connection.TransportType = transportType; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(); @@ -1941,7 +1997,7 @@ public async Task DeleteEndpointGracefullyTerminatesLongPolling() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -1994,7 +2050,7 @@ public async Task DeleteEndpointGracefullyTerminatesLongPollingEvenWhenBetweenPo var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2047,7 +2103,7 @@ public async Task DeleteEndpointTerminatesLongPollingWithHangingApplication() var connection = manager.CreateConnection(options); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2095,7 +2151,7 @@ public async Task PollCanReceiveFinalMessageAfterAppCompletes() { var transportType = HttpTransportType.LongPolling; var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = transportType; @@ -2146,7 +2202,7 @@ public async Task NegotiateDoesNotReturnWebSocketsWhenNotAvailable() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var context = new DefaultHttpContext(); context.Features.Set(new ResponseFeature()); var services = new ServiceCollection(); @@ -2198,7 +2254,7 @@ public async Task WriteThatIsDisposedBeforeCompleteReturns404() var connection = manager.CreateConnection(options); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2260,7 +2316,7 @@ public async Task CanDisposeWhileWriteLockIsBlockedOnBackpressureAndResponseRetu var connection = manager.CreateConnection(options); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2317,7 +2373,7 @@ public async Task LongPollingCanPollIfWritePipeHasBackpressure() var connection = manager.CreateConnection(options); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2376,7 +2432,7 @@ bool ExpectedErrors(WriteContext writeContext) var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2416,7 +2472,7 @@ public async Task LongPollingConnectionClosingTriggersConnectionClosedToken() var connection = manager.CreateConnection(options); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2467,7 +2523,7 @@ public async Task SSEConnectionClosingTriggersConnectionClosedToken() var manager = CreateConnectionManager(LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.ServerSentEvents; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); var context = MakeRequest("/foo", connection, services); @@ -2495,7 +2551,7 @@ public async Task WebSocketConnectionClosingTriggersConnectionClosedToken() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.WebSockets; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); var context = MakeRequest("/foo", connection, services); @@ -2542,7 +2598,7 @@ public async Task AbortingConnectionAbortsHttpContextAndTriggersConnectionClosed var manager = CreateConnectionManager(LoggerFactory); var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.ServerSentEvents; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); var context = MakeRequest("/foo", connection, services); @@ -2577,7 +2633,7 @@ public async Task ServicesAvailableWithLongPolling() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2628,7 +2684,7 @@ public async Task ServicesPreserveScopeWithLongPolling() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2683,7 +2739,7 @@ public async Task DisposeLongPollingConnectionDisposesServiceScope() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.LongPolling; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); @@ -2730,7 +2786,7 @@ public async Task LongRunningActivityTagSetOnExecuteAsync() var connection = manager.CreateConnection(); connection.TransportType = HttpTransportType.ServerSentEvents; - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var services = new ServiceCollection(); services.AddSingleton(); var context = MakeRequest("/foo", connection, services); @@ -2764,7 +2820,7 @@ public async Task ConnectionClosedRequestedTriggeredOnAuthExpiration() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory, TimeSpan.FromSeconds(5)); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var options = new HttpConnectionDispatcherOptions() { CloseOnAuthenticationExpiration = true }; var connection = manager.CreateConnection(options); connection.TransportType = HttpTransportType.LongPolling; @@ -3183,7 +3239,7 @@ public async Task DisableRequestTimeoutInLongPolling() using (StartVerifiableLog()) { var manager = CreateConnectionManager(LoggerFactory, TimeSpan.FromSeconds(5)); - var dispatcher = new HttpConnectionDispatcher(manager, LoggerFactory); + var dispatcher = CreateDispatcher(manager, LoggerFactory); var options = new HttpConnectionDispatcherOptions(); var connection = manager.CreateConnection(options); connection.TransportType = HttpTransportType.LongPolling; @@ -3297,7 +3353,7 @@ private static async Task CheckTransportSupported(HttpTransportType supportedTra var connection = manager.CreateConnection(); connection.TransportType = transportType; - var dispatcher = new HttpConnectionDispatcher(manager, loggerFactory); + var dispatcher = CreateDispatcher(manager, loggerFactory); using (var strm = new MemoryStream()) { var context = new DefaultHttpContext(); @@ -3375,16 +3431,20 @@ private static void SetTransport(HttpContext context, HttpTransportType transpor } } - private static HttpConnectionManager CreateConnectionManager(ILoggerFactory loggerFactory) + private static HttpConnectionManager CreateConnectionManager(ILoggerFactory loggerFactory, HttpConnectionsMetrics metrics = null) { - return CreateConnectionManager(loggerFactory, null); + return CreateConnectionManager(loggerFactory, null, metrics); } - private static HttpConnectionManager CreateConnectionManager(ILoggerFactory loggerFactory, TimeSpan? disconnectTimeout) + private static HttpConnectionManager CreateConnectionManager(ILoggerFactory loggerFactory, TimeSpan? disconnectTimeout, HttpConnectionsMetrics metrics = null) { var connectionOptions = new ConnectionOptions(); connectionOptions.DisconnectTimeout = disconnectTimeout; - return new HttpConnectionManager(loggerFactory ?? new LoggerFactory(), new EmptyApplicationLifetime(), Options.Create(connectionOptions)); + return new HttpConnectionManager( + loggerFactory ?? new LoggerFactory(), + new EmptyApplicationLifetime(), + Options.Create(connectionOptions), + metrics ?? new HttpConnectionsMetrics(new TestMeterFactory())); } private string GetContentAsString(Stream body) @@ -3403,6 +3463,11 @@ private static void AssertResponseHasCacheHeaders(HttpResponse response) Assert.Equal("no-cache", response.Headers.Pragma); Assert.Equal("Thu, 01 Jan 1970 00:00:00 GMT", response.Headers.Expires); } + + private static HttpConnectionDispatcher CreateDispatcher(HttpConnectionManager manager, ILoggerFactory loggerFactory, HttpConnectionsMetrics metrics = null) + { + return new HttpConnectionDispatcher(manager, loggerFactory, metrics ?? new HttpConnectionsMetrics(new TestMeterFactory())); + } } public class NeverEndingConnectionHandler : ConnectionHandler diff --git a/src/SignalR/common/Http.Connections/test/HttpConnectionManagerTests.cs b/src/SignalR/common/Http.Connections/test/HttpConnectionManagerTests.cs index ffdd014f4739..2447ed4f1747 100644 --- a/src/SignalR/common/Http.Connections/test/HttpConnectionManagerTests.cs +++ b/src/SignalR/common/Http.Connections/test/HttpConnectionManagerTests.cs @@ -2,13 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.Metrics; using System.IO.Pipelines; +using System.Net; using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.Connections.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.SignalR.Tests; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Metrics; using Microsoft.Extensions.Options; using Xunit; @@ -185,7 +190,7 @@ public void RemoveConnection() Assert.Same(newConnection, connection); Assert.Same(transport, newConnection.Transport); - connectionManager.RemoveConnection(connection.ConnectionToken); + connectionManager.RemoveConnection(connection.ConnectionToken, connection.TransportType, HttpConnectionStopStatus.Timeout); Assert.False(connectionManager.TryGetConnection(connection.ConnectionToken, out newConnection)); } } @@ -419,10 +424,43 @@ public async Task ApplicationLifetimeCanStartBeforeHttpConnectionManagerInitiali } } - private static HttpConnectionManager CreateConnectionManager(ILoggerFactory loggerFactory, IHostApplicationLifetime lifetime = null) + [Fact] + public void Metrics() + { + using (StartVerifiableLog()) + { + var testMeterFactory = new TestMeterFactory(); + using var connectionDuration = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), HttpConnectionsMetrics.MeterName, "connection-duration"); + using var currentConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), HttpConnectionsMetrics.MeterName, "current-connections"); + + var connectionManager = CreateConnectionManager(LoggerFactory, metrics: new HttpConnectionsMetrics(testMeterFactory)); + var connection = connectionManager.CreateConnection(); + + Assert.NotNull(connection.ConnectionId); + + Assert.Empty(connectionDuration.GetMeasurements()); + Assert.Collection(currentConnections.GetMeasurements(), m => Assert.Equal(1, m.Value)); + + connection.TransportType = HttpTransportType.WebSockets; + + connectionManager.RemoveConnection(connection.ConnectionId, connection.TransportType, HttpConnectionStopStatus.NormalClosure); + + Assert.Collection(currentConnections.GetMeasurements(), m => Assert.Equal(1, m.Value), m => Assert.Equal(-1, m.Value)); + Assert.Collection(connectionDuration.GetMeasurements(), m => AssertDuration(m, HttpConnectionStopStatus.NormalClosure, HttpTransportType.WebSockets)); + } + } + + private static void AssertDuration(Measurement measurement, HttpConnectionStopStatus status, HttpTransportType transportType) + { + Assert.True(measurement.Value > 0); + Assert.Equal(status.ToString(), (string)measurement.Tags.ToArray().Single(t => t.Key == "status").Value); + Assert.Equal(transportType.ToString(), (string)measurement.Tags.ToArray().Single(t => t.Key == "transport").Value); + } + + private static HttpConnectionManager CreateConnectionManager(ILoggerFactory loggerFactory, IHostApplicationLifetime lifetime = null, HttpConnectionsMetrics metrics = null) { - lifetime = lifetime ?? new EmptyApplicationLifetime(); - return new HttpConnectionManager(loggerFactory, lifetime, Options.Create(new ConnectionOptions())); + lifetime ??= new EmptyApplicationLifetime(); + return new HttpConnectionManager(loggerFactory, lifetime, Options.Create(new ConnectionOptions()), metrics ?? new HttpConnectionsMetrics(new TestMeterFactory())); } [Flags] diff --git a/src/SignalR/common/Http.Connections/test/Internal/HttpConnectionsEventSourceTests.cs b/src/SignalR/common/Http.Connections/test/Internal/HttpConnectionsEventSourceTests.cs index e93d5fe925a1..40b4ce1b4c8b 100644 --- a/src/SignalR/common/Http.Connections/test/Internal/HttpConnectionsEventSourceTests.cs +++ b/src/SignalR/common/Http.Connections/test/Internal/HttpConnectionsEventSourceTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics; using System.Diagnostics.Tracing; using System.Globalization; using Microsoft.AspNetCore.Internal; @@ -60,8 +61,7 @@ public void ConnectionStop() eventListener.EnableEvents(httpConnectionsEventSource, EventLevel.Informational); // Act - var stopWatch = ValueStopwatch.StartNew(); - httpConnectionsEventSource.ConnectionStop("1", stopWatch); + httpConnectionsEventSource.ConnectionStop("1", startTimestamp: Stopwatch.GetTimestamp(), currentTimestamp: Stopwatch.GetTimestamp()); // Assert var eventData = eventListener.EventData; diff --git a/src/SignalR/server/SignalR/src/SignalRDependencyInjectionExtensions.cs b/src/SignalR/server/SignalR/src/SignalRDependencyInjectionExtensions.cs index 9b2eded595d6..09893e9b02bb 100644 --- a/src/SignalR/server/SignalR/src/SignalRDependencyInjectionExtensions.cs +++ b/src/SignalR/server/SignalR/src/SignalRDependencyInjectionExtensions.cs @@ -38,6 +38,7 @@ public static ISignalRServerBuilder AddSignalR(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); + services.AddMetrics(); services.AddConnections(); // Disable the WebSocket keep alive since SignalR has it's own services.Configure(o => o.KeepAliveInterval = TimeSpan.Zero);