Skip to content

Simple ASP.NET Core instrumentation capturing request count metric #2159

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
14 changes: 14 additions & 0 deletions examples/AspNetCore/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,19 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Instrumentation.AspNetCore;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

namespace Examples.AspNetCore
{
public class Startup
{
private MeterProvider meterProvider;

public Startup(IConfiguration configuration)
{
this.Configuration = configuration;
Expand Down Expand Up @@ -111,6 +115,16 @@ public void ConfigureServices(IServiceCollection services)

break;
}

// TODO: Add IServiceCollection.AddOpenTelemetryMetrics extension method
var providerBuilder = Sdk.CreateMeterProviderBuilder()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

until we add Ext.Hosting support, we can simply add the meterprovider as a singleton in the services, to ensure it gets disposed at end.

.AddAspNetCoreInstrumentation();

// TODO: Add configuration switch for Prometheus and OTLP export
providerBuilder
.AddConsoleExporter();

this.meterProvider = providerBuilder.Build();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
Expand Down
10 changes: 10 additions & 0 deletions src/OpenTelemetry.Api/Metrics/MeterProviderBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ protected MeterProviderBuilder()
{
}

/// <summary>
/// Adds instrumentation to the provider.
/// </summary>
/// <typeparam name="TInstrumentation">Type of instrumentation class.</typeparam>
/// <param name="instrumentationFactory">Function that builds instrumentation.</param>
/// <returns>Returns <see cref="MeterProviderBuilder"/> for chaining.</returns>
public abstract MeterProviderBuilder AddInstrumentation<TInstrumentation>(
Func<TInstrumentation> instrumentationFactory)
where TInstrumentation : class;

/// <summary>
/// Adds given Meter names to the list of subscribed sources.
/// </summary>
Expand Down
53 changes: 53 additions & 0 deletions src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// <copyright file="AspNetCoreMetrics.cs" company="OpenTelemetry Authors">
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>

using System;
using System.Diagnostics.Metrics;
using System.Reflection;
using OpenTelemetry.Instrumentation.AspNetCore.Implementation;

namespace OpenTelemetry.Instrumentation.AspNetCore
{
/// <summary>
/// Asp.Net Core Requests instrumentation.
/// </summary>
internal class AspNetCoreMetrics : IDisposable
{
internal static readonly AssemblyName AssemblyName = typeof(HttpInListener).Assembly.GetName();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking this PR, in general we might want to be defensive while having static initialization logic - TypeInitializationException could be very hard to troubleshoot.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea good point. This should be fixed elsewhere as well - here's another example

internal class HttpInListener : ListenerHandler
{
internal const string ActivityOperationName = "Microsoft.AspNetCore.Hosting.HttpRequestIn";
internal static readonly AssemblyName AssemblyName = typeof(HttpInListener).Assembly.GetName();
internal static readonly string ActivitySourceName = AssemblyName.Name;
internal static readonly Version Version = AssemblyName.Version;
.

I can grab this in a follow up.

internal static readonly string InstrumentationName = AssemblyName.Name;
internal static readonly string InstrumentationVersion = AssemblyName.Version.ToString();

private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber;
private readonly Meter meter;

/// <summary>
/// Initializes a new instance of the <see cref="AspNetCoreMetrics"/> class.
/// </summary>
public AspNetCoreMetrics()
{
this.meter = new Meter(InstrumentationName, InstrumentationVersion);
this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber(new HttpInMetricsListener("Microsoft.AspNetCore", this.meter), null);
this.diagnosticSourceSubscriber.Subscribe();
}

/// <inheritdoc/>
public void Dispose()
{
this.diagnosticSourceSubscriber?.Dispose();
this.meter?.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// <copyright file="HttpInMetricsListener.cs" company="OpenTelemetry Authors">
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>

using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.AspNetCore.Http;
using OpenTelemetry.Trace;

namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation
{
internal class HttpInMetricsListener : ListenerHandler
{
private readonly PropertyFetcher<HttpContext> stopContextFetcher = new PropertyFetcher<HttpContext>("HttpContext");
private readonly Meter meter;

private Counter<long> httpServerRequestCount;

public HttpInMetricsListener(string name, Meter meter)
: base(name)
{
this.meter = meter;

// TODO:
// In the future, this instrumentation should produce the http.server.duration metric which will likely be represented as a histogram.
// See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md#http-server
//
// Histograms are not yet supported by the SDK.
//
// For now we produce a count metric called http.server.request_count just for demonstration purposes.
// This metric is not defined by the in the semantic conventions.
this.httpServerRequestCount = meter.CreateCounter<long>("http.server.request_count", null, "The number of HTTP requests processed.");
}

public override void OnStopActivity(Activity activity, object payload)
{
HttpContext context = this.stopContextFetcher.Fetch(payload);
if (context == null)
{
AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), nameof(this.OnStopActivity));
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).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

@alanwest alanwest Jul 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yea the comment could probably be more clear - this comment was left over from my original PR from awhile back.

I meant suppressing invocations of the /metrics endpoint exposed via the ASP.NET Core middleware - pretty sure this would cause an activity to be generated. At least that's what I recall when running this with the old metrics code and exporting to prometheus, though I haven't tried out prometheus now that we've resurrected all the code.

In a follow up PR I'll expand the test app to export via Prometheus and OTLP and validate where things stand.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we haven't resurrected the middleware version of Prometheus. The in-proc server using HttpListener is resurrected, and it already does suppress. (but since there is no instrumentation for it, it shouldn't get captured anyway).
We can resurrect the asp.net core middleware based one and add the suppress flag there, so activity wont be recorded from that operation.

// 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;
}

// TODO: This is just a minimal set of attributes. See the spec for additional attributes:
// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md#http-server
var tags = new KeyValuePair<string, object>[]
{
new KeyValuePair<string, object>(SemanticConventions.AttributeHttpMethod, context.Request.Method),
new KeyValuePair<string, object>(SemanticConventions.AttributeHttpScheme, context.Request.Scheme),
new KeyValuePair<string, object>(SemanticConventions.AttributeHttpStatusCode, context.Response.StatusCode),
new KeyValuePair<string, object>(SemanticConventions.AttributeHttpFlavor, context.Request.Protocol),
};

this.httpServerRequestCount.Add(1, tags);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// <copyright file="MeterProviderBuilderExtensions.cs" company="OpenTelemetry Authors">
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>

using System;
using OpenTelemetry.Instrumentation.AspNetCore;

namespace OpenTelemetry.Metrics
{
/// <summary>
/// Extension methods to simplify registering of ASP.NET Core request instrumentation.
/// </summary>
public static class MeterProviderBuilderExtensions
{
/// <summary>
/// Enables the incoming requests automatic data collection for ASP.NET Core.
/// </summary>
/// <param name="builder"><see cref="MeterProviderBuilder"/> being configured.</param>
/// <returns>The instance of <see cref="MeterProviderBuilder"/> to chain the calls.</returns>
public static MeterProviderBuilder AddAspNetCoreInstrumentation(
this MeterProviderBuilder builder)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}

// TODO: Implement an IDeferredMeterProviderBuilder

// TODO: Handle AspNetCoreInstrumentationOptions
// Filter - makes sense for metric instrumentation
// Enrich - do we want a similar kind of functionality for metrics?
// RecordException - probably doesn't make sense for metric instrumentation
// EnableGrpcAspNetCoreSupport - this instrumentation will also need to also handle gRPC requests

var instrumentation = new AspNetCoreMetrics();
builder.AddSource(AspNetCoreMetrics.InstrumentationName);
return builder.AddInstrumentation(() => instrumentation);
}
}
}
33 changes: 33 additions & 0 deletions src/OpenTelemetry/Metrics/MeterProviderBuilderSdk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ namespace OpenTelemetry.Metrics
{
internal class MeterProviderBuilderSdk : MeterProviderBuilder
{
private readonly List<InstrumentationFactory> instrumentationFactories = new List<InstrumentationFactory>();
private readonly List<string> meterSources = new List<string>();
private ResourceBuilder resourceBuilder = ResourceBuilder.CreateDefault();

Expand All @@ -33,6 +34,22 @@ internal MeterProviderBuilderSdk()

internal List<MetricProcessor> MetricProcessors { get; } = new List<MetricProcessor>();

public override MeterProviderBuilder AddInstrumentation<TInstrumentation>(Func<TInstrumentation> instrumentationFactory)
{
if (instrumentationFactory == null)
{
throw new ArgumentNullException(nameof(instrumentationFactory));
}

this.instrumentationFactories.Add(
new InstrumentationFactory(
typeof(TInstrumentation).Name,
"semver:" + typeof(TInstrumentation).Assembly.GetName().Version,
instrumentationFactory));

return this;
}

public override MeterProviderBuilder AddSource(params string[] names)
{
if (names == null)
Expand Down Expand Up @@ -76,8 +93,24 @@ internal MeterProvider Build()
return new MeterProviderSdk(
this.resourceBuilder.Build(),
this.meterSources,
this.instrumentationFactories,
this.MeasurementProcessors.ToArray(),
this.MetricProcessors.ToArray());
}

// TODO: This is copied from TracerProviderBuilderSdk. Move to common location.
internal readonly struct InstrumentationFactory
{
public readonly string Name;
public readonly string Version;
public readonly Func<object> Factory;

internal InstrumentationFactory(string name, string version, Func<object> factory)
{
this.Name = name;
this.Version = version;
this.Factory = factory;
}
}
}
}
21 changes: 21 additions & 0 deletions src/OpenTelemetry/Metrics/MeterProviderSdk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenTelemetry.Resources;
Expand All @@ -29,6 +30,7 @@ public class MeterProviderSdk
{
internal readonly ConcurrentDictionary<AggregatorStore, bool> AggregatorStores = new ConcurrentDictionary<AggregatorStore, bool>();

private readonly List<object> instrumentations = new List<object>();
private readonly object collectLock = new object();
private readonly CancellationTokenSource cts = new CancellationTokenSource();
private readonly List<Task> collectorTasks = new List<Task>();
Expand All @@ -39,6 +41,7 @@ public class MeterProviderSdk
internal MeterProviderSdk(
Resource resource,
IEnumerable<string> meterSources,
List<MeterProviderBuilderSdk.InstrumentationFactory> instrumentationFactories,
MeasurementProcessor[] measurementProcessors,
MetricProcessor[] metricProcessors)
{
Expand All @@ -52,6 +55,14 @@ internal MeterProviderSdk(
processor.SetParentProvider(this);
}

if (instrumentationFactories.Any())
{
foreach (var instrumentationFactory in instrumentationFactories)
{
this.instrumentations.Add(instrumentationFactory.Factory());
}
}

// Setup Listener
var meterSourcesToSubscribe = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
foreach (var name in meterSources)
Expand Down Expand Up @@ -121,6 +132,16 @@ internal void MeasurementRecorded<T>(Instrument instrument, T value, ReadOnlySpa

protected override void Dispose(bool disposing)
{
if (this.instrumentations != null)
{
foreach (var item in this.instrumentations)
{
(item as IDisposable)?.Dispose();
}

this.instrumentations.Clear();
}

this.listener.Dispose();

this.cts.Cancel();
Expand Down