Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,11 @@ public void RequestEnd(HttpContext httpContext, Exception? exception, HostingApp
httpContext.Request.Host,
route,
httpContext.Response.StatusCode,
reachedPipelineEnd,
exception,
customTags,
startTimestamp,
currentTimestamp);

if (reachedPipelineEnd)
{
_metrics.UnhandledRequest();
}
}

if (reachedPipelineEnd)
Expand Down
121 changes: 92 additions & 29 deletions src/Hosting/Hosting/src/Internal/HostingMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Collections.Frozen;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Metrics;
using Microsoft.AspNetCore.Http;

Expand All @@ -13,26 +14,22 @@ internal sealed class HostingMetrics : IDisposable
public const string MeterName = "Microsoft.AspNetCore.Hosting";

private readonly Meter _meter;
private readonly UpDownCounter<long> _currentRequestsCounter;
private readonly UpDownCounter<long> _activeRequestsCounter;
private readonly Histogram<double> _requestDuration;
private readonly Counter<long> _unhandledRequestsCounter;

public HostingMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create(MeterName);

_currentRequestsCounter = _meter.CreateUpDownCounter<long>(
"http-server-current-requests",
_activeRequestsCounter = _meter.CreateUpDownCounter<long>(
"http.server.active_requests",
unit: "{request}",
description: "Number of HTTP requests that are currently active on the server.");

_requestDuration = _meter.CreateHistogram<double>(
"http-server-request-duration",
"http.server.request.duration",
unit: "s",
description: "The duration of HTTP requests on the server.");

_unhandledRequestsCounter = _meter.CreateCounter<long>(
"http-server-unhandled-requests",
description: "Number of HTTP requests that reached the end of the middleware pipeline without being handled by application code.");
description: "Measures the duration of inbound HTTP requests.");
}

// Note: Calling code checks whether counter is enabled.
Expand All @@ -41,35 +38,43 @@ public void RequestStart(bool isHttps, string scheme, string method, HostString
// Tags must match request end.
var tags = new TagList();
InitializeRequestTags(ref tags, isHttps, scheme, method, host);
_currentRequestsCounter.Add(1, tags);
_activeRequestsCounter.Add(1, tags);
}

public void RequestEnd(string protocol, bool isHttps, string scheme, string method, HostString host, string? route, int statusCode, Exception? exception, List<KeyValuePair<string, object?>>? customTags, long startTimestamp, long currentTimestamp)
public void RequestEnd(string protocol, bool isHttps, string scheme, string method, HostString host, string? route, int statusCode, bool unhandledRequest, Exception? exception, List<KeyValuePair<string, object?>>? customTags, long startTimestamp, long currentTimestamp)
{
var tags = new TagList();
InitializeRequestTags(ref tags, isHttps, scheme, method, host);

// Tags must match request start.
if (_currentRequestsCounter.Enabled)
if (_activeRequestsCounter.Enabled)
{
_currentRequestsCounter.Add(-1, tags);
_activeRequestsCounter.Add(-1, tags);
}

if (_requestDuration.Enabled)
{
tags.Add("protocol", protocol);
tags.Add("network.protocol.name", "http");
if (TryGetHttpVersion(protocol, out var httpVersion))
{
tags.Add("network.protocol.version", httpVersion);
}
if (unhandledRequest)
{
tags.Add("aspnetcore.request.is_unhandled", true);
}

// Add information gathered during request.
tags.Add("status-code", GetBoxedStatusCode(statusCode));
tags.Add("http.response.status_code", GetBoxedStatusCode(statusCode));
if (route != null)
{
tags.Add("route", route);
tags.Add("http.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.
// An exception caught by ExceptionHandlerMiddleware and DeveloperExceptionMiddleware isn't thrown to here. Instead, those middleware add exception.type to custom tags.
if (exception != null)
{
tags.Add("exception-name", exception.GetType().FullName);
tags.Add("exception.type", exception.GetType().FullName);
}
if (customTags != null)
{
Expand All @@ -84,36 +89,37 @@ public void RequestEnd(string protocol, bool isHttps, string scheme, string meth
}
}

public void UnhandledRequest()
{
_unhandledRequestsCounter.Add(1);
}

public void Dispose()
{
_meter.Dispose();
}

public bool IsEnabled() => _currentRequestsCounter.Enabled || _requestDuration.Enabled || _unhandledRequestsCounter.Enabled;
public bool IsEnabled() => _activeRequestsCounter.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);
tags.Add("url.scheme", scheme);
tags.Add("http.request.method", ResolveHttpMethod(method));

_ = isHttps;
_ = host;
// TODO: Support configuration for enabling host header annotations
/*
if (host.HasValue)
{
tags.Add("host", host.Host);
tags.Add("server.address", 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);
tags.Add("server.port", port);
}
}
}
*/
}

// Status Codes listed at http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
Expand Down Expand Up @@ -197,4 +203,61 @@ private static object GetBoxedStatusCode(int statusCode)

return statusCode;
}

private static readonly FrozenDictionary<string, string> KnownMethods = FrozenDictionary.ToFrozenDictionary(new[]
{
KeyValuePair.Create(HttpMethods.Connect, HttpMethods.Connect),
KeyValuePair.Create(HttpMethods.Delete, HttpMethods.Delete),
KeyValuePair.Create(HttpMethods.Get, HttpMethods.Get),
KeyValuePair.Create(HttpMethods.Head, HttpMethods.Head),
KeyValuePair.Create(HttpMethods.Options, HttpMethods.Options),
KeyValuePair.Create(HttpMethods.Patch, HttpMethods.Patch),
KeyValuePair.Create(HttpMethods.Post, HttpMethods.Post),
KeyValuePair.Create(HttpMethods.Put, HttpMethods.Put),
KeyValuePair.Create(HttpMethods.Trace, HttpMethods.Trace)
}, StringComparer.OrdinalIgnoreCase);

private static string ResolveHttpMethod(string method)
{
// TODO: Support configuration for configuring known methods
if (KnownMethods.TryGetValue(method, out var result))
{
// KnownMethods ignores case. Use the value returned by the dictionary to have a consistent case.
return result;
}
return "_OTHER";
}

private static bool TryGetHttpVersion(string protocol, [NotNullWhen(true)] out string? version)
{
if (HttpProtocol.IsHttp11(protocol))
{
version = "1.1";
return true;
}
if (HttpProtocol.IsHttp2(protocol))
{
// HTTP/2 only has one version.
version = "2";
return true;
}
if (HttpProtocol.IsHttp3(protocol))
{
// HTTP/3 only has one version.
version = "3";
return true;
}
if (HttpProtocol.IsHttp10(protocol))
{
version = "1.0";
return true;
}
if (HttpProtocol.IsHttp09(protocol))
{
version = "0.9";
return true;
}
version = null;
return false;
}
}
24 changes: 12 additions & 12 deletions src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ public async Task EventCountersAndMetricsValues()
var hostingApplication1 = CreateApplication(out var features1, eventSource: hostingEventSource, meterFactory: testMeterFactory1);
var hostingApplication2 = CreateApplication(out var features2, eventSource: hostingEventSource, meterFactory: testMeterFactory2);

using var currentRequestsRecorder1 = new MetricCollector<long>(testMeterFactory1, HostingMetrics.MeterName, "http-server-current-requests");
using var currentRequestsRecorder2 = new MetricCollector<long>(testMeterFactory2, HostingMetrics.MeterName, "http-server-current-requests");
using var requestDurationRecorder1 = new MetricCollector<double>(testMeterFactory1, HostingMetrics.MeterName, "http-server-request-duration");
using var requestDurationRecorder2 = new MetricCollector<double>(testMeterFactory2, HostingMetrics.MeterName, "http-server-request-duration");
using var activeRequestsCollector1 = new MetricCollector<long>(testMeterFactory1, HostingMetrics.MeterName, "http.server.active_requests");
using var activeRequestsCollector2 = new MetricCollector<long>(testMeterFactory2, HostingMetrics.MeterName, "http.server.active_requests");
using var requestDurationCollector1 = new MetricCollector<double>(testMeterFactory1, HostingMetrics.MeterName, "http.server.request.duration");
using var requestDurationCollector2 = new MetricCollector<double>(testMeterFactory2, HostingMetrics.MeterName, "http.server.request.duration");

// Act/Assert 1
var context1 = hostingApplication1.CreateContext(features1);
Expand All @@ -75,15 +75,15 @@ public async Task EventCountersAndMetricsValues()
Assert.Equal(0, await currentRequestValues.FirstOrDefault(v => v == 0));
Assert.Equal(0, await failedRequestValues.FirstOrDefault(v => v == 0));

Assert.Collection(currentRequestsRecorder1.GetMeasurementSnapshot(),
Assert.Collection(activeRequestsCollector1.GetMeasurementSnapshot(),
m => Assert.Equal(1, m.Value),
m => Assert.Equal(-1, m.Value));
Assert.Collection(currentRequestsRecorder2.GetMeasurementSnapshot(),
Assert.Collection(activeRequestsCollector2.GetMeasurementSnapshot(),
m => Assert.Equal(1, m.Value),
m => Assert.Equal(-1, m.Value));
Assert.Collection(requestDurationRecorder1.GetMeasurementSnapshot(),
Assert.Collection(requestDurationCollector1.GetMeasurementSnapshot(),
m => Assert.True(m.Value > 0));
Assert.Collection(requestDurationRecorder2.GetMeasurementSnapshot(),
Assert.Collection(requestDurationCollector2.GetMeasurementSnapshot(),
m => Assert.True(m.Value > 0));

// Act/Assert 2
Expand All @@ -106,20 +106,20 @@ public async Task EventCountersAndMetricsValues()
Assert.Equal(0, await currentRequestValues.FirstOrDefault(v => v == 0));
Assert.Equal(2, await failedRequestValues.FirstOrDefault(v => v == 2));

Assert.Collection(currentRequestsRecorder1.GetMeasurementSnapshot(),
Assert.Collection(activeRequestsCollector1.GetMeasurementSnapshot(),
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.GetMeasurementSnapshot(),
Assert.Collection(activeRequestsCollector2.GetMeasurementSnapshot(),
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.GetMeasurementSnapshot(),
Assert.Collection(requestDurationCollector1.GetMeasurementSnapshot(),
m => Assert.True(m.Value > 0),
m => Assert.True(m.Value > 0));
Assert.Collection(requestDurationRecorder2.GetMeasurementSnapshot(),
Assert.Collection(requestDurationCollector2.GetMeasurementSnapshot(),
m => Assert.True(m.Value > 0),
m => Assert.True(m.Value > 0));
}
Expand Down
Loading