diff --git a/src/OpenTelemetry.Exporter.Prometheus/.publicApi/netcoreapp3.1/PublicAPI.Unshipped.txt b/src/OpenTelemetry.Exporter.Prometheus/.publicApi/netcoreapp3.1/PublicAPI.Unshipped.txt index fa2cedde6..340bdb69a 100644 --- a/src/OpenTelemetry.Exporter.Prometheus/.publicApi/netcoreapp3.1/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.Exporter.Prometheus/.publicApi/netcoreapp3.1/PublicAPI.Unshipped.txt @@ -16,5 +16,8 @@ OpenTelemetry.Exporter.PrometheusExporterOptions.StartHttpListener.set -> void OpenTelemetry.Metrics.PrometheusExporterMeterProviderBuilderExtensions override OpenTelemetry.Exporter.PrometheusExporter.Dispose(bool disposing) -> void override OpenTelemetry.Exporter.PrometheusExporter.Export(in OpenTelemetry.Batch metrics) -> OpenTelemetry.ExportResult -static Microsoft.AspNetCore.Builder.PrometheusExporterApplicationBuilderExtensions.UseOpenTelemetryPrometheusScrapingEndpoint(this Microsoft.AspNetCore.Builder.IApplicationBuilder app, OpenTelemetry.Metrics.MeterProvider meterProvider = null) -> Microsoft.AspNetCore.Builder.IApplicationBuilder +static Microsoft.AspNetCore.Builder.PrometheusExporterApplicationBuilderExtensions.UseOpenTelemetryPrometheusScrapingEndpoint(this Microsoft.AspNetCore.Builder.IApplicationBuilder app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder +static Microsoft.AspNetCore.Builder.PrometheusExporterApplicationBuilderExtensions.UseOpenTelemetryPrometheusScrapingEndpoint(this Microsoft.AspNetCore.Builder.IApplicationBuilder app, OpenTelemetry.Metrics.MeterProvider meterProvider, System.Func predicate, string path, System.Action configureBranchedPipeline) -> Microsoft.AspNetCore.Builder.IApplicationBuilder +static Microsoft.AspNetCore.Builder.PrometheusExporterApplicationBuilderExtensions.UseOpenTelemetryPrometheusScrapingEndpoint(this Microsoft.AspNetCore.Builder.IApplicationBuilder app, string path) -> Microsoft.AspNetCore.Builder.IApplicationBuilder +static Microsoft.AspNetCore.Builder.PrometheusExporterApplicationBuilderExtensions.UseOpenTelemetryPrometheusScrapingEndpoint(this Microsoft.AspNetCore.Builder.IApplicationBuilder app, System.Func predicate) -> Microsoft.AspNetCore.Builder.IApplicationBuilder static OpenTelemetry.Metrics.PrometheusExporterMeterProviderBuilderExtensions.AddPrometheusExporter(this OpenTelemetry.Metrics.MeterProviderBuilder builder, System.Action configure = null) -> OpenTelemetry.Metrics.MeterProviderBuilder \ No newline at end of file diff --git a/src/OpenTelemetry.Exporter.Prometheus/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus/CHANGELOG.md index 58883b20d..788d7e219 100644 --- a/src/OpenTelemetry.Exporter.Prometheus/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +* Added `IApplicationBuilder` extension methods to help with Prometheus + middleware configuration on ASP.NET Core + ([#3029](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3029)) + ## 1.2.0-rc5 Released 2022-Apr-12 @@ -19,7 +23,7 @@ Released 2022-Mar-04 Released 2022-Feb-02 * Update default `httpListenerPrefixes` for PrometheusExporter to be `http://localhost:9464/`. -([2783](https://github.com/open-telemetry/opentelemetry-dotnet/pull/2783)) +([#2783](https://github.com/open-telemetry/opentelemetry-dotnet/pull/2783)) ## 1.2.0-rc1 diff --git a/src/OpenTelemetry.Exporter.Prometheus/PrometheusExporterApplicationBuilderExtensions.cs b/src/OpenTelemetry.Exporter.Prometheus/PrometheusExporterApplicationBuilderExtensions.cs index f88fc1b89..ef96f4722 100644 --- a/src/OpenTelemetry.Exporter.Prometheus/PrometheusExporterApplicationBuilderExtensions.cs +++ b/src/OpenTelemetry.Exporter.Prometheus/PrometheusExporterApplicationBuilderExtensions.cs @@ -21,12 +21,14 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Exporter; using OpenTelemetry.Exporter.Prometheus; +using OpenTelemetry.Internal; using OpenTelemetry.Metrics; namespace Microsoft.AspNetCore.Builder { /// - /// Provides extension methods for to add Prometheus Scraper Endpoint. + /// Provides extension methods for to add + /// Prometheus scraping endpoint. /// public static class PrometheusExporterApplicationBuilderExtensions { @@ -34,25 +36,118 @@ namespace Microsoft.AspNetCore.Builder /// Adds OpenTelemetry Prometheus scraping endpoint middleware to an /// instance. /// + /// Note: A branched pipeline is created for the route + /// specified by . + /// The to add + /// middleware to. + /// A reference to the original for chaining calls. + public static IApplicationBuilder UseOpenTelemetryPrometheusScrapingEndpoint(this IApplicationBuilder app) + => UseOpenTelemetryPrometheusScrapingEndpoint(app, meterProvider: null, predicate: null, path: null, configureBranchedPipeline: null); + + /// + /// Adds OpenTelemetry Prometheus scraping endpoint middleware to an + /// instance. + /// + /// Note: A branched pipeline is created for the supplied + /// . + /// The to add + /// middleware to. + /// Path to use for the branched pipeline. + /// A reference to the original for chaining calls. + public static IApplicationBuilder UseOpenTelemetryPrometheusScrapingEndpoint(this IApplicationBuilder app, string path) + { + Guard.ThrowIfNull(path); + return UseOpenTelemetryPrometheusScrapingEndpoint(app, meterProvider: null, predicate: null, path: path, configureBranchedPipeline: null); + } + + /// + /// Adds OpenTelemetry Prometheus scraping endpoint middleware to an + /// instance. + /// + /// Note: A branched pipeline is created for the supplied + /// . + /// The to add + /// middleware to. + /// Predicate for deciding if a given + /// should be branched. + /// A reference to the original for chaining calls. + public static IApplicationBuilder UseOpenTelemetryPrometheusScrapingEndpoint(this IApplicationBuilder app, Func predicate) + { + Guard.ThrowIfNull(predicate); + return UseOpenTelemetryPrometheusScrapingEndpoint(app, meterProvider: null, predicate: predicate, path: null, configureBranchedPipeline: null); + } + + /// + /// Adds OpenTelemetry Prometheus scraping endpoint middleware to an + /// instance. + /// + /// Note: A branched pipeline is created based on the or . If neither nor are provided then + /// is + /// used. /// The to add /// middleware to. /// Optional /// containing a otherwise the primary /// SDK provider will be resolved using application services. - /// A reference to the instance after the operation has completed. - public static IApplicationBuilder UseOpenTelemetryPrometheusScrapingEndpoint(this IApplicationBuilder app, MeterProvider meterProvider = null) + /// Optional predicate for deciding if a given + /// should be branched. If supplied is ignored. + /// Optional path to use for the branched pipeline. + /// Ignored if is supplied. + /// Optional callback to + /// configure the branched pipeline. Called before registration of the + /// Prometheus middleware. + /// A reference to the original for chaining calls. + public static IApplicationBuilder UseOpenTelemetryPrometheusScrapingEndpoint( + this IApplicationBuilder app, + MeterProvider meterProvider, + Func predicate, + string path, + Action configureBranchedPipeline) { - var options = app.ApplicationServices.GetOptions(); + // Note: Order is important here. MeterProvider is accessed before + // GetOptions so that any changes made to + // PrometheusExporterOptions in deferred AddPrometheusExporter + // configure actions are reflected. + meterProvider ??= app.ApplicationServices.GetRequiredService(); - string path = options.ScrapeEndpointPath ?? PrometheusExporterOptions.DefaultScrapeEndpointPath; - if (!path.StartsWith("/")) + if (predicate == null) { - path = $"/{path}"; + if (path == null) + { + var options = app.ApplicationServices.GetOptions(); + + path = options.ScrapeEndpointPath ?? PrometheusExporterOptions.DefaultScrapeEndpointPath; + } + + if (!path.StartsWith("/")) + { + path = $"/{path}"; + } + + return app.Map( + new PathString(path), + builder => + { + configureBranchedPipeline?.Invoke(builder); + builder.UseMiddleware(meterProvider); + }); } - return app.Map( - new PathString(path), - builder => builder.UseMiddleware(meterProvider ?? app.ApplicationServices.GetRequiredService())); + return app.MapWhen( + context => predicate(context), + builder => + { + configureBranchedPipeline?.Invoke(builder); + builder.UseMiddleware(meterProvider); + }); } } } diff --git a/src/OpenTelemetry.Exporter.Prometheus/README.md b/src/OpenTelemetry.Exporter.Prometheus/README.md index 6788252e5..ecac7ad4d 100644 --- a/src/OpenTelemetry.Exporter.Prometheus/README.md +++ b/src/OpenTelemetry.Exporter.Prometheus/README.md @@ -45,8 +45,26 @@ dotnet add package OpenTelemetry.Exporter.Prometheus ```csharp public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { - app.UseRouting(); app.UseOpenTelemetryPrometheusScrapingEndpoint(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + ``` + + Overloads of the `UseOpenTelemetryPrometheusScrapingEndpoint` extension are + provided to change the path or for more advanced configuration a predicate + function can be used: + + ```csharp + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseOpenTelemetryPrometheusScrapingEndpoint( + context => context.Request.Path == "/internal/metrics" + && context.Connection.LocalPort == 5067); + app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); diff --git a/test/OpenTelemetry.Exporter.Prometheus.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.Tests/PrometheusExporterMiddlewareTests.cs index 37a85273e..d6b50c1a9 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.Tests/PrometheusExporterMiddlewareTests.cs @@ -18,7 +18,9 @@ using System; using System.Collections.Generic; using System.Diagnostics.Metrics; +using System.Linq; using System.Net; +using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -36,12 +38,156 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests private static readonly string MeterName = Utils.GetCurrentMethodName(); [Fact] - public async Task PrometheusExporterMiddlewareIntegration() + public Task PrometheusExporterMiddlewareIntegration() { - var host = await new HostBuilder() + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint()); + } + + [Fact] + public Task PrometheusExporterMiddlewareIntegration_Options() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics_options", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint(), + services => services.Configure(o => o.ScrapeEndpointPath = "metrics_options")); + } + + [Fact] + public Task PrometheusExporterMiddlewareIntegration_OptionsFallback() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint(), + services => services.Configure(o => o.ScrapeEndpointPath = null)); + } + + [Fact] + public Task PrometheusExporterMiddlewareIntegration_OptionsViaAddPrometheusExporter() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics_from_AddPrometheusExporter", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint(), + configureOptions: o => o.ScrapeEndpointPath = "/metrics_from_AddPrometheusExporter"); + } + + [Fact] + public Task PrometheusExporterMiddlewareIntegration_PathOverride() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics_override", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint("/metrics_override")); + } + + [Fact] + public Task PrometheusExporterMiddlewareIntegration_Predicate() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics_predicate?enabled=true", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint(httpcontext => httpcontext.Request.Path == "/metrics_predicate" && httpcontext.Request.Query["enabled"] == "true")); + } + + [Fact] + public Task PrometheusExporterMiddlewareIntegration_MixedPredicateAndPath() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics_predicate", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint( + meterProvider: null, + predicate: httpcontext => httpcontext.Request.Path == "/metrics_predicate", + path: "/metrics_path", + configureBranchedPipeline: branch => branch.Use((context, next) => + { + context.Response.Headers.Add("X-MiddlewareExecuted", "true"); + return next(); + })), + services => services.Configure(o => o.ScrapeEndpointPath = "/metrics_options"), + validateResponse: rsp => + { + if (!rsp.Headers.TryGetValues("X-MiddlewareExecuted", out IEnumerable headers)) + { + headers = Array.Empty(); + } + + Assert.Equal("true", headers.FirstOrDefault()); + }); + } + + [Fact] + public Task PrometheusExporterMiddlewareIntegration_MixedPath() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics_path", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint( + meterProvider: null, + predicate: null, + path: "/metrics_path", + configureBranchedPipeline: branch => branch.Use((context, next) => + { + context.Response.Headers.Add("X-MiddlewareExecuted", "true"); + return next(); + })), + services => services.Configure(o => o.ScrapeEndpointPath = "/metrics_options"), + validateResponse: rsp => + { + if (!rsp.Headers.TryGetValues("X-MiddlewareExecuted", out IEnumerable headers)) + { + headers = Array.Empty(); + } + + Assert.Equal("true", headers.FirstOrDefault()); + }); + } + + [Fact] + public async Task PrometheusExporterMiddlewareIntegration_MeterProvider() + { + using MeterProvider meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(MeterName) + .AddPrometheusExporter() + .Build(); + + await RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint( + meterProvider: meterProvider, + predicate: null, + path: null, + configureBranchedPipeline: null), + registerMeterProvider: false).ConfigureAwait(false); + } + + private static async Task RunPrometheusExporterMiddlewareIntegrationTest( + string path, + Action configure, + Action configureServices = null, + Action validateResponse = null, + bool registerMeterProvider = true, + Action configureOptions = null) + { + using var host = await new HostBuilder() .ConfigureWebHost(webBuilder => webBuilder .UseTestServer() - .UseStartup()) + .ConfigureServices(services => + { + if (registerMeterProvider) + { + services.AddOpenTelemetryMetrics(builder => builder + .AddMeter(MeterName) + .AddPrometheusExporter(o => + { + configureOptions?.Invoke(o); + if (o.StartHttpListener) + { + throw new InvalidOperationException("StartHttpListener should be false on .NET Core 3.1+."); + } + })); + } + + configureServices?.Invoke(services); + }) + .Configure(configure)) .StartAsync(); var tags = new KeyValuePair[] @@ -58,7 +204,7 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests counter.Add(100.18D, tags); counter.Add(0.99D, tags); - using var response = await host.GetTestClient().GetAsync("/metrics").ConfigureAwait(false); + using var response = await host.GetTestClient().GetAsync(path).ConfigureAwait(false); var endTimestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds(); @@ -80,35 +226,16 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests var index = content.LastIndexOf(' '); - Assert.Equal('\n', content[content.Length - 1]); + Assert.Equal('\n', content[^1]); var timestamp = long.Parse(content.Substring(index, content.Length - index - 1)); Assert.True(beginTimestamp <= timestamp && timestamp <= endTimestamp); + validateResponse?.Invoke(response); + await host.StopAsync().ConfigureAwait(false); } - - public class Startup - { - public void ConfigureServices(IServiceCollection services) - { - services.AddOpenTelemetryMetrics(builder => builder - .AddMeter(MeterName) - .AddPrometheusExporter(o => - { - if (o.StartHttpListener) - { - throw new InvalidOperationException("StartHttpListener should be false on .NET Core 3.1+."); - } - })); - } - - public void Configure(IApplicationBuilder app) - { - app.UseOpenTelemetryPrometheusScrapingEndpoint(); - } - } } } #endif