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