diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md index b44b5fb8b1e..e1439a9c231 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md @@ -2,6 +2,35 @@ ## Unreleased +* Introduced a new metric, `http.server.request.duration` measured in seconds. + The OTel SDK + [applies custom histogram buckets](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4820) + for this metric to comply with the + [Semantic Convention for Http Metrics](https://github.com/open-telemetry/semantic-conventions/blob/2bad9afad58fbd6b33cc683d1ad1f006e35e4a5d/docs/http/http-metrics.md). + This new metric is only available for users who opt-in to the new + semantic convention by configuring the `OTEL_SEMCONV_STABILITY_OPT_IN` + environment variable to either `http` (to emit only the new metric) or + `http/dup` (to emit both the new and old metrics). + ([#4802](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4802)) + * New metric: `http.server.request.duration` + * Unit: `s` (seconds) + * Histogram Buckets: `0, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, + 0.75, 1, 2.5, 5, 7.5, 10` + * Old metric: `http.server.duration` + * Unit: `ms` (milliseconds) + * Histogram Buckets: `0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, + 5000, 7500, 10000` + + Note: the older `http.server.duration` metric and + `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable will eventually be + removed after the HTTP semantic conventions are marked stable. + At which time this instrumentation can publish a stable release. Refer to + the specification for more information regarding the new HTTP semantic + conventions for both + [spans](https://github.com/open-telemetry/semantic-conventions/blob/2bad9afad58fbd6b33cc683d1ad1f006e35e4a5d/docs/http/http-spans.md) + and + [metrics](https://github.com/open-telemetry/semantic-conventions/blob/2bad9afad58fbd6b33cc683d1ad1f006e35e4a5d/docs/http/http-metrics.md). + ## 1.5.1-beta.1 Released 2023-Jul-20 diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs index ce06402bc34..9a8b35040c6 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs @@ -27,13 +27,16 @@ namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation; internal sealed class HttpInMetricsListener : ListenerHandler { - private const string HttpServerDurationMetricName = "http.server.duration"; + internal const string HttpServerDurationMetricName = "http.server.duration"; + internal const string HttpServerRequestDurationMetricName = "http.server.request.duration"; + private const string OnStopEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop"; private const string EventName = "OnStopActivity"; private readonly Meter meter; private readonly AspNetCoreMetricsInstrumentationOptions options; private readonly Histogram httpServerDuration; + private readonly Histogram httpServerRequestDuration; private readonly bool emitOldAttributes; private readonly bool emitNewAttributes; @@ -42,109 +45,172 @@ internal HttpInMetricsListener(string name, Meter meter, AspNetCoreMetricsInstru { this.meter = meter; this.options = options; - this.httpServerDuration = meter.CreateHistogram(HttpServerDurationMetricName, "ms", "Measures the duration of inbound HTTP requests."); this.emitOldAttributes = this.options.HttpSemanticConvention.HasFlag(HttpSemanticConvention.Old); this.emitNewAttributes = this.options.HttpSemanticConvention.HasFlag(HttpSemanticConvention.New); + + if (this.emitOldAttributes) + { + this.httpServerDuration = meter.CreateHistogram(HttpServerDurationMetricName, "ms", "Measures the duration of inbound HTTP requests."); + } + + if (this.emitNewAttributes) + { + this.httpServerRequestDuration = meter.CreateHistogram(HttpServerRequestDurationMetricName, "s", "Measures the duration of inbound HTTP requests."); + } } public override void OnEventWritten(string name, object payload) { if (name == OnStopEvent) { - var context = payload as HttpContext; - if (context == null) + if (this.emitOldAttributes) { - AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), EventName, HttpServerDurationMetricName); - return; + this.OnEventWritten_Old(name, payload); } - try + if (this.emitNewAttributes) { - if (this.options.Filter?.Invoke(HttpServerDurationMetricName, context) == false) - { - AspNetCoreInstrumentationEventSource.Log.RequestIsFilteredOut(nameof(HttpInMetricsListener), EventName, HttpServerDurationMetricName); - return; - } + this.OnEventWritten_New(name, payload); } - catch (Exception ex) + } + } + + public void OnEventWritten_Old(string name, object payload) + { + var context = payload as HttpContext; + if (context == null) + { + AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), EventName, HttpServerDurationMetricName); + return; + } + + try + { + if (this.options.Filter?.Invoke(HttpServerDurationMetricName, context) == false) { - AspNetCoreInstrumentationEventSource.Log.RequestFilterException(nameof(HttpInMetricsListener), EventName, HttpServerDurationMetricName, ex); + AspNetCoreInstrumentationEventSource.Log.RequestIsFilteredOut(nameof(HttpInMetricsListener), EventName, HttpServerDurationMetricName); return; } + } + catch (Exception ex) + { + AspNetCoreInstrumentationEventSource.Log.RequestFilterException(nameof(HttpInMetricsListener), EventName, HttpServerDurationMetricName, ex); + return; + } + + // TODO: Prometheus pulls metrics by invoking the /metrics endpoint. Decide if it makes sense to suppress this. + // Below is just a temporary way of achieving this suppression for metrics (we should consider suppressing traces too). + // If we want to suppress activity from Prometheus then we should use SuppressInstrumentationScope. + if (context.Request.Path.HasValue && context.Request.Path.Value.Contains("metrics")) + { + return; + } + + TagList tags = default; - // TODO: Prometheus pulls metrics by invoking the /metrics endpoint. Decide if it makes sense to suppress this. - // Below is just a temporary way of achieving this suppression for metrics (we should consider suppressing traces too). - // If we want to suppress activity from Prometheus then we should use SuppressInstrumentationScope. - if (context.Request.Path.HasValue && context.Request.Path.Value.Contains("metrics")) + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocol(context.Request.Protocol))); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpScheme, context.Request.Scheme)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpMethod, context.Request.Method)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode))); + + if (context.Request.Host.HasValue) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeNetHostName, context.Request.Host.Host)); + + if (context.Request.Host.Port is not null && context.Request.Host.Port != 80 && context.Request.Host.Port != 443) { - return; + tags.Add(new KeyValuePair(SemanticConventions.AttributeNetHostPort, context.Request.Host.Port)); } + } - TagList tags = default; - - // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/trace/semantic_conventions/http.md - if (this.emitOldAttributes) +#if NET6_0_OR_GREATER + var route = (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText; + if (!string.IsNullOrEmpty(route)) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRoute, route)); + } +#endif + if (this.options.Enrich != null) + { + try { - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocol(context.Request.Protocol))); - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpScheme, context.Request.Scheme)); - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpMethod, context.Request.Method)); - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode))); - - if (context.Request.Host.HasValue) - { - tags.Add(new KeyValuePair(SemanticConventions.AttributeNetHostName, context.Request.Host.Host)); - - if (context.Request.Host.Port is not null && context.Request.Host.Port != 80 && context.Request.Host.Port != 443) - { - tags.Add(new KeyValuePair(SemanticConventions.AttributeNetHostPort, context.Request.Host.Port)); - } - } + this.options.Enrich(HttpServerDurationMetricName, context, ref tags); } + catch (Exception ex) + { + AspNetCoreInstrumentationEventSource.Log.EnrichmentException(nameof(HttpInMetricsListener), EventName, HttpServerDurationMetricName, ex); + } + } - // see the spec https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/http/http-spans.md - if (this.emitNewAttributes) + // We are relying here on ASP.NET Core to set duration before writing the stop event. + // https://github.com/dotnet/aspnetcore/blob/d6fa351048617ae1c8b47493ba1abbe94c3a24cf/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L449 + // TODO: Follow up with .NET team if we can continue to rely on this behavior. + this.httpServerDuration.Record(Activity.Current.Duration.TotalMilliseconds, tags); + } + + public void OnEventWritten_New(string name, object payload) + { + var context = payload as HttpContext; + if (context == null) + { + AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), EventName, HttpServerRequestDurationMetricName); + return; + } + + try + { + if (this.options.Filter?.Invoke(HttpServerRequestDurationMetricName, context) == false) { - tags.Add(new KeyValuePair(SemanticConventions.AttributeNetworkProtocolVersion, HttpTagHelper.GetFlavorTagValueFromProtocol(context.Request.Protocol))); - tags.Add(new KeyValuePair(SemanticConventions.AttributeUrlScheme, context.Request.Scheme)); - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRequestMethod, context.Request.Method)); - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode))); - - if (context.Request.Host.HasValue) - { - tags.Add(new KeyValuePair(SemanticConventions.AttributeServerAddress, context.Request.Host.Host)); - - if (context.Request.Host.Port is not null && context.Request.Host.Port != 80 && context.Request.Host.Port != 443) - { - tags.Add(new KeyValuePair(SemanticConventions.AttributeServerPort, context.Request.Host.Port)); - } - } + AspNetCoreInstrumentationEventSource.Log.RequestIsFilteredOut(nameof(HttpInMetricsListener), EventName, HttpServerRequestDurationMetricName); + return; } + } + catch (Exception ex) + { + AspNetCoreInstrumentationEventSource.Log.RequestFilterException(nameof(HttpInMetricsListener), EventName, HttpServerRequestDurationMetricName, ex); + return; + } + + // TODO: Prometheus pulls metrics by invoking the /metrics endpoint. Decide if it makes sense to suppress this. + // Below is just a temporary way of achieving this suppression for metrics (we should consider suppressing traces too). + // If we want to suppress activity from Prometheus then we should use SuppressInstrumentationScope. + if (context.Request.Path.HasValue && context.Request.Path.Value.Contains("metrics")) + { + return; + } + + TagList tags = default; + + // see the spec https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/http/http-spans.md + tags.Add(new KeyValuePair(SemanticConventions.AttributeNetworkProtocolVersion, HttpTagHelper.GetFlavorTagValueFromProtocol(context.Request.Protocol))); + tags.Add(new KeyValuePair(SemanticConventions.AttributeUrlScheme, context.Request.Scheme)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRequestMethod, context.Request.Method)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode))); #if NET6_0_OR_GREATER - var route = (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText; - if (!string.IsNullOrEmpty(route)) + var route = (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText; + if (!string.IsNullOrEmpty(route)) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRoute, route)); + } +#endif + if (this.options.Enrich != null) + { + try { - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRoute, route)); + this.options.Enrich(HttpServerRequestDurationMetricName, context, ref tags); } -#endif - if (this.options.Enrich != null) + catch (Exception ex) { - try - { - this.options.Enrich(HttpServerDurationMetricName, context, ref tags); - } - catch (Exception ex) - { - AspNetCoreInstrumentationEventSource.Log.EnrichmentException(nameof(HttpInMetricsListener), EventName, HttpServerDurationMetricName, ex); - } + AspNetCoreInstrumentationEventSource.Log.EnrichmentException(nameof(HttpInMetricsListener), EventName, HttpServerRequestDurationMetricName, ex); } - - // We are relying here on ASP.NET Core to set duration before writing the stop event. - // https://github.com/dotnet/aspnetcore/blob/d6fa351048617ae1c8b47493ba1abbe94c3a24cf/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L449 - // TODO: Follow up with .NET team if we can continue to rely on this behavior. - this.httpServerDuration.Record(Activity.Current.Duration.TotalMilliseconds, tags); } + + // We are relying here on ASP.NET Core to set duration before writing the stop event. + // https://github.com/dotnet/aspnetcore/blob/d6fa351048617ae1c8b47493ba1abbe94c3a24cf/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L449 + // TODO: Follow up with .NET team if we can continue to rely on this behavior. + this.httpServerRequestDuration.Record(Activity.Current.Duration.TotalSeconds, tags); } } diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/README.md b/src/OpenTelemetry.Instrumentation.AspNetCore/README.md index 131f98a4b37..257dc308278 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/README.md +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/README.md @@ -90,13 +90,31 @@ public void ConfigureServices(IServiceCollection services) #### List of metrics produced -The instrumentation is implemented based on [metrics semantic -conventions](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md#metric-httpserverduration). -Currently, the instrumentation supports the following metric. +A different metric is emitted depending on whether a user opts-in to the new +Http Semantic Conventions using `OTEL_SEMCONV_STABILITY_OPT_IN`. -| Name | Instrument Type | Unit | Description | -|-------|-----------------|------|-------------| -| `http.server.duration` | Histogram | `ms` | Measures the duration of inbound HTTP requests. | +* By default, the instrumentation emits the following metric. + + | Name | Instrument Type | Unit | Description | Attributes | + |-------|-----------------|------|-------------|------------| + | `http.server.duration` | Histogram | `ms` | Measures the duration of inbound HTTP requests. | http.flavor, http.scheme, http.method, http.status_code, net.host.name, net.host.port, http.route | + +* If user sets the environment variable to `http`, the instrumentation emits + the following metric. + + | Name | Instrument Type | Unit | Description | Attributes | + |-------|-----------------|------|-------------|------------| + | `http.server.request.duration` | Histogram | `s` | Measures the duration of inbound HTTP requests. | network.protocol.version, url.scheme, http.request.method, http.response.status_code, http.route | + + This metric is emitted in `seconds` as per the semantic convention. While + the convention [recommends using custom histogram buckets](https://github.com/open-telemetry/semantic-conventions/blob/2bad9afad58fbd6b33cc683d1ad1f006e35e4a5d/docs/http/http-metrics.md) + , this feature is not yet available via .NET Metrics API. + A [workaround](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4820) + has been included in OTel SDK starting version `1.6.0` which applies + recommended buckets by default for `http.server.request.duration`. + +* If user sets the environment variable to `http/dup`, the instrumentation + emits both `http.server.duration` and `http.server.request.duration`. ## Advanced configuration diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs index 607472c83a4..8b179278a49 100644 --- a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs @@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenTelemetry.Metrics; @@ -30,6 +31,8 @@ namespace OpenTelemetry.Instrumentation.AspNetCore.Tests; public class MetricTests : IClassFixture>, IDisposable { + public const string SemanticConventionOptInKeyName = "OTEL_SEMCONV_STABILITY_OPT_IN"; + private const int StandardTagsCount = 6; private readonly WebApplicationFactory factory; @@ -48,11 +51,16 @@ public void AddAspNetCoreInstrumentation_BadArgs() } [Fact] - public async Task RequestMetricIsCaptured() + public async Task RequestMetricIsCaptured_Old() { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [SemanticConventionOptInKeyName] = null }) + .Build(); + var metricItems = new List(); this.meterProvider = Sdk.CreateMeterProviderBuilder() + .ConfigureServices(services => services.AddSingleton(configuration)) .AddAspNetCoreInstrumentation() .AddInMemoryExporter(metricItems) .Build(); @@ -83,11 +91,134 @@ public async Task RequestMetricIsCaptured() .ToArray(); var metric = Assert.Single(requestMetrics); + Assert.Equal("ms", metric.Unit); + var metricPoints = GetMetricPoints(metric); + Assert.Equal(2, metricPoints.Count); + + AssertMetricPoints_Old( + metricPoints: metricPoints, + expectedRoutes: new List { "api/Values", "api/Values/{id}" }, + expectedTagsCount: 6); + } + + [Fact] + public async Task RequestMetricIsCaptured_New() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [SemanticConventionOptInKeyName] = "http" }) + .Build(); + + var metricItems = new List(); + + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .ConfigureServices(services => services.AddSingleton(configuration)) + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(metricItems) + .Build(); + + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + using var response1 = await client.GetAsync("/api/values").ConfigureAwait(false); + using var response2 = await client.GetAsync("/api/values/2").ConfigureAwait(false); + + response1.EnsureSuccessStatusCode(); + response2.EnsureSuccessStatusCode(); + } + + // We need to let End callback execute as it is executed AFTER response was returned. + // In unit tests environment there may be a lot of parallel unit tests executed, so + // giving some breezing room for the End callback to complete + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + this.meterProvider.Dispose(); + + var requestMetrics = metricItems + .Where(item => item.Name == "http.server.request.duration") + .ToArray(); + + var metric = Assert.Single(requestMetrics); + + Assert.Equal("s", metric.Unit); var metricPoints = GetMetricPoints(metric); Assert.Equal(2, metricPoints.Count); - AssertMetricPoint(metricPoints[0], expectedRoute: "api/Values"); - AssertMetricPoint(metricPoints[1], expectedRoute: "api/Values/{id}"); + AssertMetricPoints_New( + metricPoints: metricPoints, + expectedRoutes: new List { "api/Values", "api/Values/{id}" }, + expectedTagsCount: 5); + } + + [Fact] + public async Task RequestMetricIsCaptured_Dup() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [SemanticConventionOptInKeyName] = "http/dup" }) + .Build(); + + var metricItems = new List(); + + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .ConfigureServices(services => services.AddSingleton(configuration)) + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(metricItems) + .Build(); + + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + using var response1 = await client.GetAsync("/api/values").ConfigureAwait(false); + using var response2 = await client.GetAsync("/api/values/2").ConfigureAwait(false); + + response1.EnsureSuccessStatusCode(); + response2.EnsureSuccessStatusCode(); + } + + // We need to let End callback execute as it is executed AFTER response was returned. + // In unit tests environment there may be a lot of parallel unit tests executed, so + // giving some breezing room for the End callback to complete + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + this.meterProvider.Dispose(); + + // Validate Old Semantic Convention + var requestMetrics = metricItems + .Where(item => item.Name == "http.server.duration") + .ToArray(); + + var metric = Assert.Single(requestMetrics); + Assert.Equal("ms", metric.Unit); + var metricPoints = GetMetricPoints(metric); + Assert.Equal(2, metricPoints.Count); + + AssertMetricPoints_Old( + metricPoints: metricPoints, + expectedRoutes: new List { "api/Values", "api/Values/{id}" }, + expectedTagsCount: 6); + + // Validate New Semantic Convention + requestMetrics = metricItems + .Where(item => item.Name == "http.server.request.duration") + .ToArray(); + + metric = Assert.Single(requestMetrics); + + Assert.Equal("s", metric.Unit); + metricPoints = GetMetricPoints(metric); + Assert.Equal(2, metricPoints.Count); + + AssertMetricPoints_New( + metricPoints: metricPoints, + expectedRoutes: new List { "api/Values", "api/Values/{id}" }, + expectedTagsCount: 5); } [Fact] @@ -133,7 +264,7 @@ void ConfigureTestServices(IServiceCollection services) // Assert single because we filtered out one route var metricPoint = Assert.Single(GetMetricPoints(metric)); - AssertMetricPoint(metricPoint); + AssertMetricPoint_Old(metricPoint); } [Fact] @@ -187,7 +318,7 @@ void ConfigureTestServices(IServiceCollection services) var metric = Assert.Single(requestMetrics); var metricPoint = Assert.Single(GetMetricPoints(metric)); - var tags = AssertMetricPoint(metricPoint, expectedTagsCount: StandardTagsCount + 2); + var tags = AssertMetricPoint_Old(metricPoint, expectedTagsCount: StandardTagsCount + 2); Assert.Contains(tagsToAdd[0], tags); Assert.Contains(tagsToAdd[1], tags); @@ -212,7 +343,71 @@ private static List GetMetricPoints(Metric metric) return metricPoints; } - private static KeyValuePair[] AssertMetricPoint( + private static void AssertMetricPoints_New( + List metricPoints, + List expectedRoutes, + int expectedTagsCount) + { + // Assert that one MetricPoint exists for each ExpectedRoute + foreach (var expectedRoute in expectedRoutes) + { + MetricPoint? metricPoint = null; + + foreach (var mp in metricPoints) + { + foreach (var tag in mp.Tags) + { + if (tag.Key == SemanticConventions.AttributeHttpRoute && tag.Value.ToString() == expectedRoute) + { + metricPoint = mp; + } + } + } + + if (metricPoint.HasValue) + { + AssertMetricPoint_New(metricPoint.Value, expectedRoute, expectedTagsCount); + } + else + { + Assert.Fail($"A metric for route '{expectedRoute}' was not found"); + } + } + } + + private static void AssertMetricPoints_Old( + List metricPoints, + List expectedRoutes, + int expectedTagsCount) + { + // Assert that one MetricPoint exists for each ExpectedRoute + foreach (var expectedRoute in expectedRoutes) + { + MetricPoint? metricPoint = null; + + foreach (var mp in metricPoints) + { + foreach (var tag in mp.Tags) + { + if (tag.Key == SemanticConventions.AttributeHttpRoute && tag.Value.ToString() == expectedRoute) + { + metricPoint = mp; + } + } + } + + if (metricPoint.HasValue) + { + AssertMetricPoint_Old(metricPoint.Value, expectedRoute, expectedTagsCount); + } + else + { + Assert.Fail($"A metric for route '{expectedRoute}' was not found"); + } + } + } + + private static KeyValuePair[] AssertMetricPoint_New( MetricPoint metricPoint, string expectedRoute = "api/Values", int expectedTagsCount = StandardTagsCount) @@ -230,6 +425,56 @@ private static KeyValuePair[] AssertMetricPoint( attributes[i++] = tag; } + // Inspect Attributes + Assert.Equal(expectedTagsCount, attributes.Length); + + var method = new KeyValuePair(SemanticConventions.AttributeHttpRequestMethod, "GET"); + var scheme = new KeyValuePair(SemanticConventions.AttributeUrlScheme, "http"); + var statusCode = new KeyValuePair(SemanticConventions.AttributeHttpResponseStatusCode, 200); + var flavor = new KeyValuePair(SemanticConventions.AttributeNetworkProtocolVersion, "1.1"); + var route = new KeyValuePair(SemanticConventions.AttributeHttpRoute, expectedRoute); + Assert.Contains(method, attributes); + Assert.Contains(scheme, attributes); + Assert.Contains(statusCode, attributes); + Assert.Contains(flavor, attributes); + Assert.Contains(route, attributes); + + // Inspect Histogram Bounds + var histogramBuckets = metricPoint.GetHistogramBuckets(); + var histogramBounds = new List(); + foreach (var t in histogramBuckets) + { + histogramBounds.Add(t.ExplicitBound); + } + + Assert.Equal( + expected: new List { 0, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, double.PositiveInfinity }, + actual: histogramBounds); + + return attributes; + } + + private static KeyValuePair[] AssertMetricPoint_Old( + MetricPoint metricPoint, + string expectedRoute = "api/Values", + int expectedTagsCount = StandardTagsCount) + { + var count = metricPoint.GetHistogramCount(); + var sum = metricPoint.GetHistogramSum(); + + Assert.Equal(1L, count); + Assert.True(sum > 0); + + var attributes = new KeyValuePair[metricPoint.Tags.Count]; + int i = 0; + foreach (var tag in metricPoint.Tags) + { + attributes[i++] = tag; + } + + // Inspect Attributes + Assert.Equal(expectedTagsCount, attributes.Length); + var method = new KeyValuePair(SemanticConventions.AttributeHttpMethod, "GET"); var scheme = new KeyValuePair(SemanticConventions.AttributeHttpScheme, "http"); var statusCode = new KeyValuePair(SemanticConventions.AttributeHttpStatusCode, 200); @@ -242,7 +487,18 @@ private static KeyValuePair[] AssertMetricPoint( Assert.Contains(flavor, attributes); Assert.Contains(host, attributes); Assert.Contains(route, attributes); - Assert.Equal(expectedTagsCount, attributes.Length); + + // Inspect Histogram Bounds + var histogramBuckets = metricPoint.GetHistogramBuckets(); + var histogramBounds = new List(); + foreach (var t in histogramBuckets) + { + histogramBounds.Add(t.ExplicitBound); + } + + Assert.Equal( + expected: new List { 0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000, double.PositiveInfinity }, + actual: histogramBounds); return attributes; }