diff --git a/.github/ISSUE_TEMPLATE/comp_instrumentation_aspnetcore.md b/.github/ISSUE_TEMPLATE/comp_instrumentation_aspnetcore.md new file mode 100644 index 0000000000..bd5d9c2550 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/comp_instrumentation_aspnetcore.md @@ -0,0 +1,41 @@ +--- +name: OpenTelemetry.Instrumentation.AspNetCore +about: Issue with OpenTelemetry.Instrumentation.AspNetCore +labels: comp:instrumentation.aspnetcore +--- + +# Issue with OpenTelemetry.Instrumentation.AspNetCore + +List of [all OpenTelemetry NuGet +packages](https://www.nuget.org/profiles/OpenTelemetry) and version that you are +using (e.g. `OpenTelemetry 1.3.2`): + +* TBD + +Runtime version (e.g. `net462`, `net48`, `net6.0`, `net7.0` etc. You can +find this information from the `*.csproj` file): + +* TBD + +**Is this a feature request or a bug?** + +* [ ] Feature Request +* [ ] Bug + +**What is the expected behavior?** + +What do you expect to see? + +**What is the actual behavior?** + +What did you see instead? If you are reporting a bug, create a self-contained +project using the template of your choice and apply the minimum required code to +result in the issue you're observing. We will close this issue if: + +* The repro project you share with us is complex. We can't investigate custom + projects, so don't point us to such, please. +* If we can not reproduce the behavior you're reporting. + +## Additional Context + +Add any other context about the feature request here. diff --git a/.github/codecov.yml b/.github/codecov.yml index 3198fe49bb..93c6e5eb08 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -58,6 +58,11 @@ flags: - src/OpenTelemetry.Instrumentation.AspNet - src/OpenTelemetry.Instrumentation.AspNet.TelemetryHttpModule + unittests-Instrumentation.AspNetCore: + carryforward: true + paths: + - src/OpenTelemetry.Instrumentation.AspNetCore + unittests-Instrumentation.EventCounters: carryforward: true paths: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e01fa622b..a6e0614a40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,8 @@ jobs: code: ['**.cs', '**.csproj', '.editorconfig'] aot: ['src/OpenTelemetry.Extensions.Enrichment/**'] aottestapp: ['test/OpenTelemetry.AotCompatibility.TestApp/**'] - aspnet: ['*/OpenTelemetry.Instrumentation.AspNet*/**', 'examples/AspNet/**', '!**/*.md'] + aspnet: ['*/OpenTelemetry.Instrumentation.AspNet.*/**', 'examples/AspNet/**', '!**/*.md'] + aspnetcore: ['*/OpenTelemetry.Instrumentation.AspNetCore*/**', '!**/*.md'] aws: ['*/OpenTelemetry.*.AWS*/**', '!**/*.md'] azure: ['*/OpenTelemetry.ResourceDetectors.Azure*/**', '!**/*.md'] eventcounters: ['*/OpenTelemetry.Instrumentation.EventCounters*/**', 'examples/event-counters/**', '!**/*.md'] @@ -49,7 +50,8 @@ jobs: 'test/**', 'examples/**', '!test/OpenTelemetry.AotCompatibility.TestApp/**', - '!*/OpenTelemetry.Instrumentation.AspNet*/**', + '!*/OpenTelemetry.Instrumentation.AspNet.*/**', + '!*/OpenTelemetry.Instrumentation.AspNetCore*/**', '!examples/AspNet/**', '!*/OpenTelemetry.ResourceDetectors.Azure*/**', '!*/OpenTelemetry.ResourceDetectors.Host*/**', @@ -108,6 +110,18 @@ jobs: os-list: '[ "windows-latest" ]' tfm-list: '[ "net462" ]' + build-test-aspnetcore: + needs: detect-changes + if: | + contains(needs.detect-changes.outputs.changes, 'aspnetcore') + || contains(needs.detect-changes.outputs.changes, 'build') + || contains(needs.detect-changes.outputs.changes, 'shared') + uses: ./.github/workflows/Component.BuildTest.yml + with: + project-name: OpenTelemetry.Instrumentation.AspNetCore + code-cov-name: Instrumentation.AspNetCore + tfm-list: '[ "net6.0", "net7.0", "net8.0" ]' + build-test-azure: needs: detect-changes if: | @@ -351,6 +365,7 @@ jobs: OpenTelemetry.Extensions.Tests.csproj, OpenTelemetry.Instrumentation.AspNet.Tests.csproj, OpenTelemetry.Instrumentation.AspNet.TelemetryHttpModule.Tests.csproj, + OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj, OpenTelemetry.Instrumentation.EventCounters.Tests.csproj, OpenTelemetry.Instrumentation.GrpcNetClient.Tests.csproj, OpenTelemetry.Instrumentation.Http.Tests.csproj, @@ -407,6 +422,7 @@ jobs: if: | contains(needs.detect-changes.outputs.changes, 'eventcounters') || contains(needs.detect-changes.outputs.changes, 'runtime') + || contains(needs.detect-changes.outputs.changes, 'aspnetcore') || contains(needs.detect-changes.outputs.changes, 'aws') || contains(needs.detect-changes.outputs.changes, 'azure') || contains(needs.detect-changes.outputs.changes, 'extensions') @@ -433,6 +449,7 @@ jobs: lint-md, lint-dotnet-format, build-test-aspnet, + build-test-aspnetcore, build-test-azure, build-test-eventcounters, build-test-extensions, diff --git a/.github/workflows/package-Instrumentation.AspNetCore.yml b/.github/workflows/package-Instrumentation.AspNetCore.yml new file mode 100644 index 0000000000..9861c3a06d --- /dev/null +++ b/.github/workflows/package-Instrumentation.AspNetCore.yml @@ -0,0 +1,21 @@ +name: Pack OpenTelemetry.Instrumentation.AspNetCore + +on: + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + push: + tags: + - 'Instrumentation.AspNetCore-*' # trigger when we create a tag with prefix "Instrumentation.AspNetCore-" + +jobs: + call-build-test-pack: + permissions: + contents: write + uses: ./.github/workflows/Component.Package.yml + with: + project-name: OpenTelemetry.Instrumentation.AspNetCore + secrets: inherit diff --git a/build/Projects/OpenTelemetry.Instrumentation.AspNetCore.proj b/build/Projects/OpenTelemetry.Instrumentation.AspNetCore.proj new file mode 100644 index 0000000000..0e86e80ccf --- /dev/null +++ b/build/Projects/OpenTelemetry.Instrumentation.AspNetCore.proj @@ -0,0 +1,35 @@ + + + + $([System.IO.Directory]::GetParent($(MSBuildThisFileDirectory)).Parent.Parent.FullName) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/opentelemetry-dotnet-contrib.sln b/opentelemetry-dotnet-contrib.sln index 1830e85346..0809cbac97 100644 --- a/opentelemetry-dotnet-contrib.sln +++ b/opentelemetry-dotnet-contrib.sln @@ -45,6 +45,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\package-Extensions.Enrichment.yml = .github\workflows\package-Extensions.Enrichment.yml .github\workflows\package-Extensions.yml = .github\workflows\package-Extensions.yml .github\workflows\package-Instrumentation.AspNet.yml = .github\workflows\package-Instrumentation.AspNet.yml + .github\workflows\package-Instrumentation.AspNetCore.yml = .github\workflows\package-Instrumentation.AspNetCore.yml .github\workflows\package-Instrumentation.AWS.yml = .github\workflows\package-Instrumentation.AWS.yml .github\workflows\package-Instrumentation.AWSLambda.yml = .github\workflows\package-Instrumentation.AWSLambda.yml .github\workflows\package-Instrumentation.Cassandra.yml = .github\workflows\package-Instrumentation.Cassandra.yml @@ -322,6 +323,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Projects", "Projects", "{04 build\Projects\OpenTelemetry.Exporter.OneCollector.proj = build\Projects\OpenTelemetry.Exporter.OneCollector.proj build\Projects\OpenTelemetry.Extensions.proj = build\Projects\OpenTelemetry.Extensions.proj build\Projects\OpenTelemetry.Instrumentation.AspNet.proj = build\Projects\OpenTelemetry.Instrumentation.AspNet.proj + build\Projects\OpenTelemetry.Instrumentation.AspNetCore.proj = build\Projects\OpenTelemetry.Instrumentation.AspNetCore.proj build\Projects\OpenTelemetry.Instrumentation.EventCounters.proj = build\Projects\OpenTelemetry.Instrumentation.EventCounters.proj build\Projects\OpenTelemetry.Instrumentation.GrpcNetClient.proj = build\Projects\OpenTelemetry.Instrumentation.GrpcNetClient.proj build\Projects\OpenTelemetry.Instrumentation.Http.proj = build\Projects\OpenTelemetry.Instrumentation.Http.proj @@ -369,6 +371,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenTelemetry.Instrumentati EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenTelemetry.Instrumentation.GrpcNetClient.Tests", "test\OpenTelemetry.Instrumentation.GrpcNetClient.Tests\OpenTelemetry.Instrumentation.GrpcNetClient.Tests.csproj", "{2E1A5759-1431-4724-8885-3E9447FBF617}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenTelemetry.Instrumentation.AspNetCore", "src\OpenTelemetry.Instrumentation.AspNetCore\OpenTelemetry.Instrumentation.AspNetCore.csproj", "{A8FF0DEB-F371-42FC-8A53-A8C25FE408FC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenTelemetry.Instrumentation.AspNetCore.Tests", "test\OpenTelemetry.Instrumentation.AspNetCore.Tests\OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj", "{917AEC46-816C-4E05-913E-F0F44C24C437}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestApp.AspNetCore", "test\TestApp.AspNetCore\TestApp.AspNetCore.csproj", "{1E743561-B1D4-4100-B6AD-1FD25FA8659B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenTelemetry.Instrumentation.AspNetCore.Benchmark", "test\OpenTelemetry.Instrumentation.AspNetCore.Benchmark\OpenTelemetry.Instrumentation.AspNetCore.Benchmark.csproj", "{92CD1B60-74B8-4E6E-9E7F-83AC3C792980}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -747,6 +757,22 @@ Global {2E1A5759-1431-4724-8885-3E9447FBF617}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E1A5759-1431-4724-8885-3E9447FBF617}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E1A5759-1431-4724-8885-3E9447FBF617}.Release|Any CPU.Build.0 = Release|Any CPU + {A8FF0DEB-F371-42FC-8A53-A8C25FE408FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8FF0DEB-F371-42FC-8A53-A8C25FE408FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8FF0DEB-F371-42FC-8A53-A8C25FE408FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8FF0DEB-F371-42FC-8A53-A8C25FE408FC}.Release|Any CPU.Build.0 = Release|Any CPU + {917AEC46-816C-4E05-913E-F0F44C24C437}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {917AEC46-816C-4E05-913E-F0F44C24C437}.Debug|Any CPU.Build.0 = Debug|Any CPU + {917AEC46-816C-4E05-913E-F0F44C24C437}.Release|Any CPU.ActiveCfg = Release|Any CPU + {917AEC46-816C-4E05-913E-F0F44C24C437}.Release|Any CPU.Build.0 = Release|Any CPU + {1E743561-B1D4-4100-B6AD-1FD25FA8659B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E743561-B1D4-4100-B6AD-1FD25FA8659B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E743561-B1D4-4100-B6AD-1FD25FA8659B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E743561-B1D4-4100-B6AD-1FD25FA8659B}.Release|Any CPU.Build.0 = Release|Any CPU + {92CD1B60-74B8-4E6E-9E7F-83AC3C792980}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92CD1B60-74B8-4E6E-9E7F-83AC3C792980}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92CD1B60-74B8-4E6E-9E7F-83AC3C792980}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92CD1B60-74B8-4E6E-9E7F-83AC3C792980}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -858,6 +884,10 @@ Global {1156D564-2E3C-47D6-97C1-FF3ADEDC41C8} = {2097345F-4DD3-477D-BC54-A922F9B2B402} {0156E342-CE63-46F5-992D-691A7CCB50F8} = {22DF5DC0-1290-4E83-A9D8-6BB7DE3B3E63} {2E1A5759-1431-4724-8885-3E9447FBF617} = {2097345F-4DD3-477D-BC54-A922F9B2B402} + {A8FF0DEB-F371-42FC-8A53-A8C25FE408FC} = {22DF5DC0-1290-4E83-A9D8-6BB7DE3B3E63} + {917AEC46-816C-4E05-913E-F0F44C24C437} = {2097345F-4DD3-477D-BC54-A922F9B2B402} + {1E743561-B1D4-4100-B6AD-1FD25FA8659B} = {2097345F-4DD3-477D-BC54-A922F9B2B402} + {92CD1B60-74B8-4E6E-9E7F-83AC3C792980} = {2097345F-4DD3-477D-BC54-A922F9B2B402} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B0816796-CDB3-47D7-8C3C-946434DE3B66} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/PublicAPI.Shipped.txt b/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..fc47928891 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/PublicAPI.Shipped.txt @@ -0,0 +1,18 @@ +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.AspNetCoreTraceInstrumentationOptions() -> void +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.EnrichWithException.get -> System.Action +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.EnrichWithException.set -> void +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.EnrichWithHttpRequest.get -> System.Action +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.EnrichWithHttpRequest.set -> void +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.EnrichWithHttpResponse.get -> System.Action +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.EnrichWithHttpResponse.set -> void +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.Filter.get -> System.Func +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.Filter.set -> void +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.RecordException.get -> bool +OpenTelemetry.Instrumentation.AspNetCore.AspNetCoreTraceInstrumentationOptions.RecordException.set -> void +OpenTelemetry.Metrics.AspNetCoreInstrumentationMeterProviderBuilderExtensions +OpenTelemetry.Trace.AspNetCoreInstrumentationTracerProviderBuilderExtensions +static OpenTelemetry.Metrics.AspNetCoreInstrumentationMeterProviderBuilderExtensions.AddAspNetCoreInstrumentation(this OpenTelemetry.Metrics.MeterProviderBuilder builder) -> OpenTelemetry.Metrics.MeterProviderBuilder +static OpenTelemetry.Trace.AspNetCoreInstrumentationTracerProviderBuilderExtensions.AddAspNetCoreInstrumentation(this OpenTelemetry.Trace.TracerProviderBuilder builder) -> OpenTelemetry.Trace.TracerProviderBuilder +static OpenTelemetry.Trace.AspNetCoreInstrumentationTracerProviderBuilderExtensions.AddAspNetCoreInstrumentation(this OpenTelemetry.Trace.TracerProviderBuilder builder, string name, System.Action configureAspNetCoreTraceInstrumentationOptions) -> OpenTelemetry.Trace.TracerProviderBuilder +static OpenTelemetry.Trace.AspNetCoreInstrumentationTracerProviderBuilderExtensions.AddAspNetCoreInstrumentation(this OpenTelemetry.Trace.TracerProviderBuilder builder, System.Action configureAspNetCoreTraceInstrumentationOptions) -> OpenTelemetry.Trace.TracerProviderBuilder diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/PublicAPI.Unshipped.txt b/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/netstandard2.0/PublicAPI.Shipped.txt b/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/netstandard2.0/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/OpenTelemetry.Instrumentation.AspNetCore/.publicApi/netstandard2.0/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentation.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentation.cs new file mode 100644 index 0000000000..d309679262 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentation.cs @@ -0,0 +1,38 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Instrumentation.AspNetCore.Implementation; + +namespace OpenTelemetry.Instrumentation.AspNetCore; + +/// +/// Asp.Net Core Requests instrumentation. +/// +internal sealed class AspNetCoreInstrumentation : IDisposable +{ + private static readonly HashSet DiagnosticSourceEvents = new() + { + "Microsoft.AspNetCore.Hosting.HttpRequestIn", + "Microsoft.AspNetCore.Hosting.HttpRequestIn.Start", + "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop", + "Microsoft.AspNetCore.Diagnostics.UnhandledException", + "Microsoft.AspNetCore.Hosting.UnhandledException", + }; + + private readonly Func isEnabled = (eventName, _, _) + => DiagnosticSourceEvents.Contains(eventName); + + private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber; + + public AspNetCoreInstrumentation(HttpInListener httpInListener) + { + this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber(httpInListener, this.isEnabled, AspNetCoreInstrumentationEventSource.Log.UnknownErrorProcessingEvent); + this.diagnosticSourceSubscriber.Subscribe(); + } + + /// + public void Dispose() + { + this.diagnosticSourceSubscriber?.Dispose(); + } +} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationMeterProviderBuilderExtensions.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationMeterProviderBuilderExtensions.cs new file mode 100644 index 0000000000..a9d7ef7c8a --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationMeterProviderBuilderExtensions.cs @@ -0,0 +1,54 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if !NET8_0_OR_GREATER +using OpenTelemetry.Instrumentation.AspNetCore; +using OpenTelemetry.Instrumentation.AspNetCore.Implementation; +#endif +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Metrics; + +/// +/// Extension methods to simplify registering of ASP.NET Core request instrumentation. +/// +public static class AspNetCoreInstrumentationMeterProviderBuilderExtensions +{ + /// + /// Enables the incoming requests automatic data collection for ASP.NET Core. + /// + /// being configured. + /// The instance of to chain the calls. + public static MeterProviderBuilder AddAspNetCoreInstrumentation( + this MeterProviderBuilder builder) + { + Guard.ThrowIfNull(builder); + +#if NET8_0_OR_GREATER + return builder.ConfigureMeters(); +#else + // Note: Warm-up the status code and method mapping. + _ = TelemetryHelper.BoxedStatusCodes; + _ = TelemetryHelper.RequestDataHelper; + + builder.AddMeter(HttpInMetricsListener.InstrumentationName); + +#pragma warning disable CA2000 + builder.AddInstrumentation(new AspNetCoreMetrics()); +#pragma warning restore CA2000 + + return builder; +#endif + } + + internal static MeterProviderBuilder ConfigureMeters(this MeterProviderBuilder builder) + { + return builder + .AddMeter("Microsoft.AspNetCore.Hosting") + .AddMeter("Microsoft.AspNetCore.Server.Kestrel") + .AddMeter("Microsoft.AspNetCore.Http.Connections") + .AddMeter("Microsoft.AspNetCore.Routing") + .AddMeter("Microsoft.AspNetCore.Diagnostics") + .AddMeter("Microsoft.AspNetCore.RateLimiting"); + } +} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationTracerProviderBuilderExtensions.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationTracerProviderBuilderExtensions.cs new file mode 100644 index 0000000000..00fa0d9435 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationTracerProviderBuilderExtensions.cs @@ -0,0 +1,125 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenTelemetry.Instrumentation.AspNetCore; +using OpenTelemetry.Instrumentation.AspNetCore.Implementation; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Trace; + +/// +/// Extension methods to simplify registering of ASP.NET Core request instrumentation. +/// +public static class AspNetCoreInstrumentationTracerProviderBuilderExtensions +{ + /// + /// Enables the incoming requests automatic data collection for ASP.NET Core. + /// + /// being configured. + /// The instance of to chain the calls. + public static TracerProviderBuilder AddAspNetCoreInstrumentation(this TracerProviderBuilder builder) + => AddAspNetCoreInstrumentation(builder, name: null, configureAspNetCoreTraceInstrumentationOptions: null); + + /// + /// Enables the incoming requests automatic data collection for ASP.NET Core. + /// + /// being configured. + /// Callback action for configuring . + /// The instance of to chain the calls. + public static TracerProviderBuilder AddAspNetCoreInstrumentation( + this TracerProviderBuilder builder, + Action configureAspNetCoreTraceInstrumentationOptions) + => AddAspNetCoreInstrumentation(builder, name: null, configureAspNetCoreTraceInstrumentationOptions); + + /// + /// Enables the incoming requests automatic data collection for ASP.NET Core. + /// + /// being configured. + /// Name which is used when retrieving options. + /// Callback action for configuring . + /// The instance of to chain the calls. + public static TracerProviderBuilder AddAspNetCoreInstrumentation( + this TracerProviderBuilder builder, + string name, + Action configureAspNetCoreTraceInstrumentationOptions) + { + Guard.ThrowIfNull(builder); + + // Note: Warm-up the status code and method mapping. + _ = TelemetryHelper.BoxedStatusCodes; + _ = TelemetryHelper.RequestDataHelper; + + name ??= Options.DefaultName; + + builder.ConfigureServices(services => + { + if (configureAspNetCoreTraceInstrumentationOptions != null) + { + services.Configure(name, configureAspNetCoreTraceInstrumentationOptions); + } + + services.RegisterOptionsFactory(configuration => new AspNetCoreTraceInstrumentationOptions(configuration)); + }); + + if (builder is IDeferredTracerProviderBuilder deferredTracerProviderBuilder) + { + deferredTracerProviderBuilder.Configure((sp, builder) => + { + AddAspNetCoreInstrumentationSources(builder, sp); + }); + } + + return builder.AddInstrumentation(sp => + { + var options = sp.GetRequiredService>().Get(name); + + return new AspNetCoreInstrumentation( + new HttpInListener(options)); + }); + } + + // Note: This is used by unit tests. + internal static TracerProviderBuilder AddAspNetCoreInstrumentation( + this TracerProviderBuilder builder, + HttpInListener listener) + { + builder.AddAspNetCoreInstrumentationSources(); + +#pragma warning disable CA2000 + return builder.AddInstrumentation( + new AspNetCoreInstrumentation(listener)); +#pragma warning restore CA2000 + } + + private static void AddAspNetCoreInstrumentationSources( + this TracerProviderBuilder builder, + IServiceProvider serviceProvider = null) + { + // For .NET7.0 onwards activity will be created using activitySource. + // https://github.com/dotnet/aspnetcore/blob/bf3352f2422bf16fa3ca49021f0e31961ce525eb/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L327 + // For .NET6.0 and below, we will continue to use legacy way. + if (HttpInListener.Net7OrGreater) + { + // TODO: Check with .NET team to see if this can be prevented + // as this allows user to override the ActivitySource. + var activitySourceService = serviceProvider?.GetService(); + if (activitySourceService != null) + { + builder.AddSource(activitySourceService.Name); + } + else + { + // For users not using hosting package? + builder.AddSource(HttpInListener.AspNetCoreActivitySourceName); + } + } + else + { + builder.AddSource(HttpInListener.ActivitySourceName); + builder.AddLegacySource(HttpInListener.ActivityOperationName); // for the activities created by AspNetCore + } + } +} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreMetrics.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreMetrics.cs new file mode 100644 index 0000000000..a819d561a9 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreMetrics.cs @@ -0,0 +1,41 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if !NET8_0_OR_GREATER +using OpenTelemetry.Instrumentation.AspNetCore.Implementation; + +namespace OpenTelemetry.Instrumentation.AspNetCore; + +/// +/// Asp.Net Core Requests instrumentation. +/// +internal sealed class AspNetCoreMetrics : IDisposable +{ + private static readonly HashSet DiagnosticSourceEvents = new() + { + "Microsoft.AspNetCore.Hosting.HttpRequestIn", + "Microsoft.AspNetCore.Hosting.HttpRequestIn.Start", + "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop", + "Microsoft.AspNetCore.Diagnostics.UnhandledException", + "Microsoft.AspNetCore.Hosting.UnhandledException", + }; + + private readonly Func isEnabled = (eventName, _, _) + => DiagnosticSourceEvents.Contains(eventName); + + private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber; + + internal AspNetCoreMetrics() + { + var metricsListener = new HttpInMetricsListener("Microsoft.AspNetCore"); + this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber(metricsListener, this.isEnabled, AspNetCoreInstrumentationEventSource.Log.UnknownErrorProcessingEvent); + this.diagnosticSourceSubscriber.Subscribe(); + } + + /// + public void Dispose() + { + this.diagnosticSourceSubscriber?.Dispose(); + } +} +#endif diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreTraceInstrumentationOptions.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreTraceInstrumentationOptions.cs new file mode 100644 index 0000000000..f5ffb7962f --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreTraceInstrumentationOptions.cs @@ -0,0 +1,115 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using OpenTelemetry.Instrumentation.AspNetCore.Implementation; + +namespace OpenTelemetry.Instrumentation.AspNetCore; + +/// +/// Options for requests instrumentation. +/// +public class AspNetCoreTraceInstrumentationOptions +{ + /// + /// Initializes a new instance of the class. + /// + public AspNetCoreTraceInstrumentationOptions() + : this(new ConfigurationBuilder().AddEnvironmentVariables().Build()) + { + } + + internal AspNetCoreTraceInstrumentationOptions(IConfiguration configuration) + { + Debug.Assert(configuration != null, "configuration was null"); + + if (configuration.TryGetBoolValue( + AspNetCoreInstrumentationEventSource.Log, + "OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_ENABLE_GRPC_INSTRUMENTATION", + out var enableGrpcInstrumentation)) + { + this.EnableGrpcAspNetCoreSupport = enableGrpcInstrumentation; + } + + if (configuration.TryGetBoolValue( + AspNetCoreInstrumentationEventSource.Log, + "OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION", + out var disableUrlQueryRedaction)) + { + this.DisableUrlQueryRedaction = disableUrlQueryRedaction; + } + } + + /// + /// Gets or sets a filter function that determines whether or not to + /// collect telemetry on a per request basis. + /// + /// + /// Notes: + /// + /// The return value for the filter function is interpreted as: + /// + /// If filter returns , the request is + /// collected. + /// If filter returns or throws an + /// exception the request is NOT collected. + /// + /// + /// + public Func Filter { get; set; } + + /// + /// Gets or sets an action to enrich an Activity. + /// + /// + /// : the activity being enriched. + /// : the HttpRequest object from which additional information can be extracted to enrich the activity. + /// + public Action EnrichWithHttpRequest { get; set; } + + /// + /// Gets or sets an action to enrich an Activity. + /// + /// + /// : the activity being enriched. + /// : the HttpResponse object from which additional information can be extracted to enrich the activity. + /// + public Action EnrichWithHttpResponse { get; set; } + + /// + /// Gets or sets an action to enrich an Activity. + /// + /// + /// : the activity being enriched. + /// : the Exception object from which additional information can be extracted to enrich the activity. + /// + public Action EnrichWithException { get; set; } + + /// + /// Gets or sets a value indicating whether the exception will be recorded as ActivityEvent or not. + /// + /// + /// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/exceptions/exceptions-spans.md. + /// + public bool RecordException { get; set; } + + /// + /// Gets or sets a value indicating whether RPC attributes are added to an Activity when using Grpc.AspNetCore. + /// + /// + /// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/rpc/rpc-spans.md. + /// + internal bool EnableGrpcAspNetCoreSupport { get; set; } + + /// + /// Gets or sets a value indicating whether the url query value should be redacted or not. + /// + /// + /// The query parameter values are redacted with value set as Redacted. + /// e.g. `?key1=value1` is set as `?key1=Redacted`. + /// The redaction can be disabled by setting this property to . + /// + internal bool DisableUrlQueryRedaction { get; set; } +} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AssemblyInfo.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AssemblyInfo.cs new file mode 100644 index 0000000000..2cd1a339fd --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Runtime.CompilerServices; + +#if SIGNED +[assembly: InternalsVisibleTo("OpenTelemetry.Instrumentation.AspNetCore.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010051c1562a090fb0c9f391012a32198b5e5d9a60e9b80fa2d7b434c9e5ccb7259bd606e66f9660676afc6692b8cdc6793d190904551d2103b7b22fa636dcbb8208839785ba402ea08fc00c8f1500ccef28bbf599aa64ffb1e1d5dc1bf3420a3777badfe697856e9d52070a50c3ea5821c80bef17ca3acffa28f89dd413f096f898")] +#else +[assembly: InternalsVisibleTo("OpenTelemetry.Instrumentation.AspNetCore.Tests")] +#endif diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md new file mode 100644 index 0000000000..2b31a759e3 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md @@ -0,0 +1,644 @@ +# Changelog + +## Unreleased + +* Update `OpenTelemetry.Api.ProviderBuilderExtensions` to `1.8.1`. + * Update `OpenTelemetry.Api` to `1.8.1`. + ([#1668](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/1668)) + +## 1.8.1 + +Released 2024-Apr-12 + +* **Breaking Change**: Fixed tracing instrumentation so that by default any + values detected in the query string component of requests are replaced with + the text `Redacted` when building the `url.query` tag. For example, + `?key1=value1&key2=value2` becomes `?key1=Redacted&key2=Redacted`. You can + disable this redaction by setting the environment variable + `OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION` to `true`. + ([#5532](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5532)) + +## 1.8.0 + +Released 2024-Apr-04 + +* Fixed an issue for spans when `server.port` attribute was not set with + `server.address` when it has default values (`80` for `HTTP` and + `443` for `HTTPS` protocol). + ([#5419](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5419)) + +* Fixed an issue where the `http.request.method_original` attribute was not set + on activity. Now, when `http.request.method` is set and the original method + is converted to its canonical form (e.g., `Get` is converted to `GET`), + the original value `Get` will be stored in `http.request.method_original`. + ([#5471](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5471)) + +* Fixed the name of spans that have `http.request.method` attribute set to `_OTHER`. + The span name will be set as `HTTP {http.route}` as per the [specification](https://github.com/open-telemetry/semantic-conventions/blob/v1.24.0/docs/http/http-spans.md#name). + ([#5484](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5484)) + +## 1.7.1 + +Released 2024-Feb-09 + +* Fixed issue + [#4466](https://github.com/open-telemetry/opentelemetry-dotnet/issues/4466) + where the activity instance returned by `Activity.Current` was different than + instance obtained from `IHttpActivityFeature.Activity`. + ([#5136](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5136)) + +* Fixed an issue where the `http.route` attribute was not set on either the + `Activity` or `http.server.request.duration` metric generated from a + request when an exception handling middleware is invoked. One caveat is that + this fix does not address the problem for the `http.server.request.duration` + metric when running ASP.NET Core 8. ASP.NET Core 8 contains an equivalent fix + which should ship in version 8.0.2 + (see: [dotnet/aspnetcore#52652](https://github.com/dotnet/aspnetcore/pull/52652)). + ([#5135](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5135)) + +* Fixes scenario when the `net6.0` target of this library is loaded into a + .NET 7+ process and the instrumentation does not behave as expected. This + is an unusual scenario that does not affect users consuming this package + normally. This fix is primarily to support the + [opentelemetry-dotnet-instrumentation](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5252) + project. + ([#5252](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5252)) + +## 1.7.0 + +Released 2023-Dec-13 + +## 1.6.0 - First stable release of this library + +Released 2023-Dec-13 + +* Re-introduced support for gRPC instrumentation as an opt-in experimental + feature. From now onwards, gRPC can be enabled by setting + `OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_ENABLE_GRPC_INSTRUMENTATION` flag to + `True`. `OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_ENABLE_GRPC_INSTRUMENTATION` can + be set as an environment variable or via IConfiguration. The change is + introduced in order to support stable release of `http` instrumentation. + Semantic conventions for RPC is still + [experimental](https://github.com/open-telemetry/semantic-conventions/tree/main/docs/rpc) + and hence the package will only support it as an opt-in experimental feature. + Note that the support was removed in `1.6.0-rc.1` version of the package and + versions released before `1.6.0-rc.1` had gRPC instrumentation enabled by + default. + ([#5130](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5130)) + +## 1.6.0-rc.1 + +Released 2023-Dec-01 + +* Removed support for `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable. The + library will now emit only the + [stable](https://github.com/open-telemetry/semantic-conventions/tree/v1.23.0/docs/http) + semantic conventions. + ([#5066](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5066)) + +* Removed `netstandard2.1` target. + ([#5094](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5094)) + +* Removed support for grpc instrumentation to unblock stable release of http + instrumentation. For details, see issue + [#5098](https://github.com/open-telemetry/opentelemetry-dotnet/issues/5098) + ([#5097](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5097)) + +* **Breaking Change** : Renamed `AspNetCoreInstrumentationOptions` to + `AspNetCoreTraceInstrumentationOptions`. + ([#5108](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5108)) + +## 1.6.0-beta.3 + +Released 2023-Nov-17 + +* Removed the Activity Status Description that was being set during + exceptions. Activity Status will continue to be reported as `Error`. + This is a **breaking change**. `EnrichWithException` can be leveraged + to restore this behavior. + ([#5025](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5025)) + +* Updated `http.request.method` to match specification guidelines. + * For activity, if the method does not belong to one of the [known + values](https://github.com/open-telemetry/semantic-conventions/blob/v1.22.0/docs/http/http-spans.md#:~:text=http.request.method%20has%20the%20following%20list%20of%20well%2Dknown%20values) + then the request method will be set on an additional tag + `http.request.method.original` and `http.request.method` will be set to + `_OTHER`. + * For metrics, if the original method does not belong to one of the [known + values](https://github.com/open-telemetry/semantic-conventions/blob/v1.22.0/docs/http/http-spans.md#:~:text=http.request.method%20has%20the%20following%20list%20of%20well%2Dknown%20values) + then `http.request.method` on `http.server.request.duration` metric will be + set to `_OTHER` + + `http.request.method` is set on `http.server.request.duration` metric or + activity when `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable is set to + `http` or `http/dup`. + ([#5001](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5001)) + +* An additional attribute `error.type` will be added to activity and +`http.server.request.duration` metric when the request results in unhandled +exception. The attribute value will be set to full name of exception type. + + The attribute will only be added when `OTEL_SEMCONV_STABILITY_OPT_IN` + environment variable is set to `http` or `http/dup`. + ([#4986](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4986)) + +* Fixed `network.protocol.version` attribute values to match the specification. + ([#5007](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5007)) + +* Calls to `/metrics` will now be included in the `http.server.request.duration` + metric. This change may affect Prometheus pull scenario if the Prometheus + server sends request to the scraping endpoint that contains `/metrics` in + path. + ([#5044](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5044)) + +* Fixes the `http.route` attribute for scenarios in which it was + previously missing or incorrect. Additionally, the `http.route` attribute + is now the same for both the metric and `Activity` emitted for a request. + Lastly, the `Activity.DisplayName` has been adjusted to have the format + `{http.request.method} {http.route}` to conform with [the specification](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md#name). + There remain scenarios when using conventional routing or Razor pages where + `http.route` is still incorrect. See [#5056](https://github.com/open-telemetry/opentelemetry-dotnet/issues/5056) + and [#5057](https://github.com/open-telemetry/opentelemetry-dotnet/issues/5057) + for more details. + ([#5026](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5026)) + +* Removed `network.protocol.name` from `http.server.request.duration` metric as + per spec. + ([#5049](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5049)) + +## 1.6.0-beta.2 + +Released 2023-Oct-26 + +* Introduced a new metric, `http.server.request.duration` measured in seconds. + The OTel SDK (starting with version 1.6.0) + [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). + +* Following metrics will now be enabled by default when targeting `.NET8.0` or + newer framework: + + * **Meter** : `Microsoft.AspNetCore.Hosting` + * `http.server.request.duration` + * `http.server.active_requests` + + * **Meter** : `Microsoft.AspNetCore.Server.Kestrel` + * `kestrel.active_connections` + * `kestrel.connection.duration` + * `kestrel.rejected_connections` + * `kestrel.queued_connections` + * `kestrel.queued_requests` + * `kestrel.upgraded_connections` + * `kestrel.tls_handshake.duration` + * `kestrel.active_tls_handshakes` + + * **Meter** : `Microsoft.AspNetCore.Http.Connections` + * `signalr.server.connection.duration` + * `signalr.server.active_connections` + + * **Meter** : `Microsoft.AspNetCore.Routing` + * `aspnetcore.routing.match_attempts` + + * **Meter** : `Microsoft.AspNetCore.Diagnostics` + * `aspnetcore.diagnostics.exceptions` + + * **Meter** : `Microsoft.AspNetCore.RateLimiting` + * `aspnetcore.rate_limiting.active_request_leases` + * `aspnetcore.rate_limiting.request_lease.duration` + * `aspnetcore.rate_limiting.queued_requests` + * `aspnetcore.rate_limiting.request.time_in_queue` + * `aspnetcore.rate_limiting.requests` + + For details about each individual metric check [ASP.NET Core + docs + page](https://learn.microsoft.com/dotnet/core/diagnostics/built-in-metrics-aspnetcore). + + **NOTES**: + * When targeting `.NET8.0` framework or newer, `http.server.request.duration` metric + will only follow + [v1.22.0](https://github.com/open-telemetry/semantic-conventions/blob/v1.22.0/docs/http/http-metrics.md#metric-httpclientrequestduration) + semantic conventions specification. Ability to switch behavior to older + conventions using `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable is + not available. + * Users can opt-out of metrics that are not required using + [views](https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/docs/metrics/customizing-the-sdk#drop-an-instrument). + + ([#4934](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4934)) + +* Added `network.protocol.name` dimension to `http.server.request.duration` +metric. This change only affects users setting `OTEL_SEMCONV_STABILITY_OPT_IN` +to `http` or `http/dup`. +([#4934](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4934)) + +* **Breaking**: Removed `Enrich` and `Filter` support for **metrics** + instrumentation. With this change, `AspNetCoreMetricsInstrumentationOptions` + is no longer available. + ([#4981](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4981)) + + * `Enrich` migration: + + An enrichment API for the `http.server.request.duration` metric is available + inside AspNetCore for users targeting .NET 8.0 (or newer). For details see: + [Enrich the ASP.NET Core request + metric](https://learn.microsoft.com/aspnet/core/log-mon/metrics/metrics?view=aspnetcore-8.0#enrich-the-aspnet-core-request-metric). + + * `Filter` migration: + + There is no comparable filter mechanism currently available for any .NET + version. Please [share your + feedback](https://github.com/open-telemetry/opentelemetry-dotnet/issues/4982) + if you are impacted by this feature gap. + + > **Note** + > The [View API](https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/docs/metrics/customizing-the-sdk#select-specific-tags) + may be used to drop dimensions. + +* Updated description for `http.server.request.duration` metrics to match spec + definition. + ([#4990](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4990)) + +## 1.5.1-beta.1 + +Released 2023-Jul-20 + +* The new HTTP and network semantic conventions can be opted in to by setting + the `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable. This allows for a + transition period for users to experiment with the new semantic conventions + and adapt as necessary. The environment variable supports the following + values: + * `http` - emit the new, frozen (proposed for stable) HTTP and networking + attributes, and stop emitting the old experimental HTTP and networking + attributes that the instrumentation emitted previously. + * `http/dup` - emit both the old and the frozen (proposed for stable) HTTP + and networking attributes, allowing for a more seamless transition. + * The default behavior (in the absence of one of these values) is to continue + emitting the same HTTP and network semantic conventions that were emitted in + `1.5.0-beta.1`. + * Note: this option will eventually be removed after the new HTTP and + network semantic conventions are marked stable. At which time this + instrumentation can receive a stable release, and the old HTTP and + network semantic conventions will no longer be supported. Refer to the + specification for more information regarding the new HTTP and network + semantic conventions for both + [spans](https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/http/http-spans.md) + and + [metrics](https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/http/http-metrics.md). + ([#4537](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4537), + [#4606](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4606), + [#4660](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4660)) + +* Fixed an issue affecting NET 7.0+. If custom propagation is being used + and tags are added to an Activity during sampling then that Activity would be dropped. + ([#4637](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4637)) + +## 1.5.0-beta.1 + +Released 2023-Jun-05 + +* Bumped the package version to `1.5.0-beta.1` to keep its major and minor + version in sync with that of the core packages. This would make it more + intuitive for users to figure out what version of core packages would work + with a given version of this package. The pre-release identifier has also been + changed from `rc` to `beta` as we believe this more accurately reflects the + status of this package. We believe the `rc` identifier will be more + appropriate as semantic conventions reach stability. + +* Fix issue where baggage gets cleared when the ASP.NET Core Activity + is stopped. The instrumentation no longer clears baggage. One problem + this caused was that it prevented Activity processors from accessing baggage + during their `OnEnd` call. +([#4274](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4274)) + +* Added direct reference to `System.Text.Encodings.Web` with minimum version of +`4.7.2` due to [CVE-2021-26701](https://github.com/dotnet/runtime/issues/49377). +This impacts target frameworks `netstandard2.0` and `netstandard2.1` which has a +reference to `Microsoft.AspNetCore.Http.Abstractions` that depends on +`System.Text.Encodings.Web` >= 4.5.0. +([#4399](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4399)) + +* Improve perf by avoiding boxing of common status codes values. + ([#4360](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4360), + [#4363](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4363)) + +## 1.0.0-rc9.14 + +Released 2023-Feb-24 + +* Updated OTel SDK dependency to 1.4.0 + +## 1.4.0-rc9.13 + +Released 2023-Feb-10 + +## 1.0.0-rc9.12 + +Released 2023-Feb-01 + +## 1.0.0-rc9.11 + +Released 2023-Jan-09 + +## 1.0.0-rc9.10 + +Released 2022-Dec-12 + +* **Users migrating from version `1.0.0-rc9.9` will see the following breaking + changes:** + * Updated `http.status_code` dimension type from string to int for + `http.server.duration` metric. + ([#3930](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3930)) + * `http.host` will no longer be populated on `http.server.duration` metric. + `net.host.name` and `net.host.port` attributes will be populated instead. +([#3928](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3928)) + + * The `http.server.duration` metric's `http.target` attribute is replaced with +`http.route` attribute. +([#3903](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3903)) + + * `http.host` will no longer be populated on activity. `net.host.name` and + `net.host.port` attributes will be populated instead. + ([#3858](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3858)) + +* Extension method `AddAspNetCoreInstrumentation` on `MeterProviderBuilder` now + supports `AspNetCoreMetricsInstrumentationOptions`. This option class exposes + configuration properties for metric filtering and tag enrichment. + ([#3948](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3948), + [#3982](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3982)) + +## 1.0.0-rc9.9 + +Released 2022-Nov-07 + +* **Breaking change** The `Enrich` callback option has been removed. + For better usability, it has been replaced by three separate options: + `EnrichWithHttpRequest`, `EnrichWithHttpResponse` and `EnrichWithException`. + Previously, the single `Enrich` callback required the consumer to detect + which event triggered the callback to be invoked (e.g., request start, + response end, or an exception) and then cast the object received to the + appropriate type: `HttpRequest`, `HttpResponse`, or `Exception`. The separate + callbacks make it clear what event triggers them and there is no longer the + need to cast the argument to the expected type. + ([#3749](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3749)) + +* Added back `netstandard2.0` and `netstandard2.1` targets. +([#3755](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3755)) + +## 1.0.0-rc9.8 + +Released 2022-Oct-17 + +## 1.0.0-rc9.7 + +Released 2022-Sep-29 + +* Performance improvement (Reduced memory allocation) - Updated DiagnosticSource +event subscription to specific set of events. +([#3519](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3519)) + +* Added overloads which accept a name to the `TracerProviderBuilder` + `AddAspNetCoreInstrumentation` extension to allow for more fine-grained + options management + ([#3661](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3661)) + +* Fix issue where when an application has an ExceptionFilter, the exception data + wouldn't be collected. + ([#3475](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3475)) + +## 1.0.0-rc9.6 + +Released 2022-Aug-18 + +* Removed `netstandard2.0` and `netstandard2.1` targets. .NET 5 reached EOL + in May 2022 and .NET Core 3.1 reaches EOL in December 2022. End of support + dates for .NET are published + [here](https://dotnet.microsoft.com/download/dotnet). The + instrumentation for ASP.NET Core now requires .NET 6 or later. + ([#3567](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3567)) + +* Fixed an issue where activity started within middleware was modified by + instrumentation library. + ([#3498](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3498)) + +* Updated to use Activity native support from + `System.Diagnostics.DiagnosticSource` to set activity status. + ([#3118](https://github.com/open-telemetry/opentelemetry-dotnet/issues/3118)) + ([#3555](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3555)) + +## 1.0.0-rc9.5 + +Released 2022-Aug-02 + +* Fix Remote IP Address - NULL reference exception. + ([#3481](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3481)) +* Metrics instrumentation to correctly populate `http.flavor` tag. + (1.1 instead of HTTP/1.1 etc.) + ([#3379](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3379)) +* Tracing instrumentation to populate `http.flavor` tag. + ([#3372](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3372)) +* Tracing instrumentation to populate `http.scheme` tag. + ([#3392](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3392)) + +## 1.0.0-rc9.4 + +Released 2022-Jun-03 + +* Added additional metric dimensions. + ([#3247](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3247)) +* Removes net5.0 target as .NET 5.0 is going out + of support. The package keeps netstandard2.1 target, so it + can still be used with .NET5.0 apps. + ([#3147](https://github.com/open-telemetry/opentelemetry-dotnet/issues/3147)) + +## 1.0.0-rc9.3 + +Released 2022-Apr-15 + +## 1.0.0-rc9.2 + +Released 2022-Apr-12 + +## 1.0.0-rc9.1 + +Released 2022-Mar-30 + +* Fix: Http server span status is now unset for `400`-`499`. + ([#2904](https://github.com/open-telemetry/opentelemetry-dotnet/pull/2904)) +* Fix: drop direct reference of the `Microsoft.AspNetCore.Http.Features` from + net5 & net6 targets (already part of the FrameworkReference since the net5). + ([#2860](https://github.com/open-telemetry/opentelemetry-dotnet/pull/2860)) +* Reduce allocations calculating the http.url tag. + ([#2947](https://github.com/open-telemetry/opentelemetry-dotnet/pull/2947)) + +## 1.0.0-rc10 (broken. use 1.0.0-rc9.1 and newer) + +Released 2022-Mar-04 + +## 1.0.0-rc9 + +Released 2022-Feb-02 + +## 1.0.0-rc8 + +Released 2021-Oct-08 + +* Replaced `http.path` tag on activity with `http.target`. + ([#2266](https://github.com/open-telemetry/opentelemetry-dotnet/pull/2266)) + +## 1.0.0-rc7 + +Released 2021-Jul-12 + +## 1.0.0-rc6 + +Released 2021-Jun-25 + +## 1.0.0-rc5 + +Released 2021-Jun-09 + +* Fixes bug + [#1740](https://github.com/open-telemetry/opentelemetry-dotnet/issues/1740): + Instrumentation.AspNetCore for gRPC services omits ALL rpc.* attributes under + certain conditions + ([#1879](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1879)) + +## 1.0.0-rc4 + +Released 2021-Apr-23 + +* When using OpenTelemetry.Extensions.Hosting you can now bind + `AspNetCoreInstrumentationOptions` from DI. + ([#1997](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1997)) + +## 1.0.0-rc3 + +Released 2021-Mar-19 + +* Leverages added AddLegacySource API from OpenTelemetry SDK to trigger Samplers + and ActivityProcessors. Samplers, ActivityProcessor.OnStart will now get the + Activity before any enrichment done by the instrumentation. + ([#1836](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1836)) +* Performance optimization by leveraging sampling decision and short circuiting + activity enrichment. `Filter` and `Enrich` are now only called if + `activity.IsAllDataRequested` is `true` + ([#1899](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1899)) + +## 1.0.0-rc2 + +Released 2021-Jan-29 + +## 1.0.0-rc1.1 + +Released 2020-Nov-17 + +* AspNetCoreInstrumentation sets ActivitySource to activities created outside + ActivitySource. + ([#1515](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1515/)) +* For gRPC invocations, leading forward slash is trimmed from span name in order + to conform to the specification. + ([#1551](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1551)) + +## 0.8.0-beta.1 + +Released 2020-Nov-5 + +* Record `Exception` in AspNetCore instrumentation based on `RecordException` in + `AspNetCoreInstrumentationOptions` + ([#1408](https://github.com/open-telemetry/opentelemetry-dotnet/issues/1408)) +* Added configuration option `EnableGrpcAspNetCoreSupport` to enable or disable + support for adding OpenTelemetry RPC attributes when using + [Grpc.AspNetCore](https://www.nuget.org/packages/Grpc.AspNetCore/). This + option is enabled by default. + ([#1423](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1423)) +* Renamed TextMapPropagator to TraceContextPropagator, CompositePropagator to + CompositeTextMapPropagator. IPropagator is renamed to TextMapPropagator and + changed from interface to abstract class. + ([#1427](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1427)) +* Propagators.DefaultTextMapPropagator will be used as the default Propagator + ([#1427](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1428)) +* Removed Propagator from Instrumentation Options. Instrumentation now always + respect the Propagator.DefaultTextMapPropagator. + ([#1448](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1448)) + +## 0.7.0-beta.1 + +Released 2020-Oct-16 + +* Instrumentation no longer store raw objects like `HttpRequest` in + Activity.CustomProperty. To enrich activity, use the Enrich action on the + instrumentation. + ([#1261](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1261)) +* Span Status is populated as per new spec + ([#1313](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1313)) + +## 0.6.0-beta.1 + +Released 2020-Sep-15 + +* For gRPC invocations, the `grpc.method` and `grpc.status_code` attributes + added by the library are removed from the span. The information from these + attributes is contained in other attributes that follow the conventions of + OpenTelemetry. + ([#1260](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1260)) + +## 0.5.0-beta.2 + +Released 2020-08-28 + +* Added Filter public API on AspNetCoreInstrumentationOptions to allow filtering + of instrumentation based on HttpContext. + +* Asp.Net Core Instrumentation automatically populates HttpRequest, HttpResponse + in Activity custom property + +* Changed the default propagation to support W3C Baggage + ([#1048](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1048)) + * The default ITextFormat is now `CompositePropagator(TraceContextFormat, + BaggageFormat)`. Baggage sent via the [W3C + Baggage](https://github.com/w3c/baggage/blob/master/baggage/HTTP_HEADER_FORMAT.md) + header will now be parsed and set on incoming Http spans. +* Introduced support for Grpc.AspNetCore (#803). + * Attributes are added to gRPC invocations: `rpc.system`, `rpc.service`, + `rpc.method`. These attributes are added to an existing span generated by + the instrumentation. This is unlike the instrumentation for client-side gRPC + calls where one span is created for the gRPC call and a separate span is + created for the underlying HTTP call in the event both gRPC and HTTP + instrumentation are enabled. +* Renamed `ITextPropagator` to `IPropagator` + ([#1190](https://github.com/open-telemetry/opentelemetry-dotnet/pull/1190)) + +## 0.4.0-beta.2 + +Released 2020-07-24 + +* First beta release + +## 0.3.0-beta + +Released 2020-07-23 + +* Initial release diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/AspNetCoreInstrumentationEventSource.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/AspNetCoreInstrumentationEventSource.cs new file mode 100644 index 0000000000..cafd0141d9 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/AspNetCoreInstrumentationEventSource.cs @@ -0,0 +1,94 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif +using System.Diagnostics.Tracing; +using Microsoft.Extensions.Configuration; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation; + +/// +/// EventSource events emitted from the project. +/// +[EventSource(Name = "OpenTelemetry-Instrumentation-AspNetCore")] +internal sealed class AspNetCoreInstrumentationEventSource : EventSource, IConfigurationExtensionsLogger +{ + public static AspNetCoreInstrumentationEventSource Log = new(); + + [NonEvent] + public void RequestFilterException(string handlerName, string eventName, string operationName, Exception ex) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.RequestFilterException(handlerName, eventName, operationName, ex.ToInvariantString()); + } + } + + [NonEvent] + public void EnrichmentException(string handlerName, string eventName, string operationName, Exception ex) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.EnrichmentException(handlerName, eventName, operationName, ex.ToInvariantString()); + } + } + + [NonEvent] + public void UnknownErrorProcessingEvent(string handlerName, string eventName, Exception ex) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.UnknownErrorProcessingEvent(handlerName, eventName, ex.ToInvariantString()); + } + } + + [Event(1, Message = "Payload is NULL, span will not be recorded. HandlerName: '{0}', EventName: '{1}', OperationName: '{2}'.", Level = EventLevel.Warning)] + public void NullPayload(string handlerName, string eventName, string operationName) + { + this.WriteEvent(1, handlerName, eventName, operationName); + } + + [Event(2, Message = "Request is filtered out. HandlerName: '{0}', EventName: '{1}', OperationName: '{2}'.", Level = EventLevel.Verbose)] + public void RequestIsFilteredOut(string handlerName, string eventName, string operationName) + { + this.WriteEvent(2, handlerName, eventName, operationName); + } + +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Parameters to this method are primitive and are trimmer safe.")] +#endif + [Event(3, Message = "Filter threw exception, request will not be collected. HandlerName: '{0}', EventName: '{1}', OperationName: '{2}', Exception: {3}.", Level = EventLevel.Error)] + public void RequestFilterException(string handlerName, string eventName, string operationName, string exception) + { + this.WriteEvent(3, handlerName, eventName, operationName, exception); + } + +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Parameters to this method are primitive and are trimmer safe.")] +#endif + [Event(4, Message = "Enrich threw exception. HandlerName: '{0}', EventName: '{1}', OperationName: '{2}', Exception: {3}.", Level = EventLevel.Warning)] + public void EnrichmentException(string handlerName, string eventName, string operationName, string exception) + { + this.WriteEvent(4, handlerName, eventName, operationName, exception); + } + + [Event(5, Message = "Unknown error processing event '{1}' from handler '{0}', Exception: {2}", Level = EventLevel.Error)] + public void UnknownErrorProcessingEvent(string handlerName, string eventName, string ex) + { + this.WriteEvent(5, handlerName, eventName, ex); + } + + [Event(6, Message = "Configuration key '{0}' has an invalid value: '{1}'", Level = EventLevel.Warning)] + public void InvalidConfigurationValue(string key, string value) + { + this.WriteEvent(6, key, value); + } + + void IConfigurationExtensionsLogger.LogInvalidConfigurationValue(string key, string value) + { + this.InvalidConfigurationValue(key, value); + } +} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs new file mode 100644 index 0000000000..89e8c1e278 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs @@ -0,0 +1,399 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Http; +#if !NETSTANDARD +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Routing; +#endif +using OpenTelemetry.Context.Propagation; +using OpenTelemetry.Instrumentation.GrpcNetClient; +using OpenTelemetry.Internal; +using OpenTelemetry.Trace; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation; + +internal class HttpInListener : ListenerHandler +{ + internal const string ActivityOperationName = "Microsoft.AspNetCore.Hosting.HttpRequestIn"; + internal const string OnStartEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Start"; + internal const string OnStopEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop"; + internal const string OnUnhandledHostingExceptionEvent = "Microsoft.AspNetCore.Hosting.UnhandledException"; + internal const string OnUnHandledDiagnosticsExceptionEvent = "Microsoft.AspNetCore.Diagnostics.UnhandledException"; + + // https://github.com/dotnet/aspnetcore/blob/8d6554e655b64da75b71e0e20d6db54a3ba8d2fb/src/Hosting/Hosting/src/GenericHost/GenericWebHostBuilder.cs#L85 + internal const string AspNetCoreActivitySourceName = "Microsoft.AspNetCore"; + + internal static readonly AssemblyName AssemblyName = typeof(HttpInListener).Assembly.GetName(); + internal static readonly string ActivitySourceName = AssemblyName.Name; + internal static readonly Version Version = AssemblyName.Version; + internal static readonly ActivitySource ActivitySource = new(ActivitySourceName, Version.ToString()); + internal static readonly bool Net7OrGreater = Environment.Version.Major >= 7; + + private const string DiagnosticSourceName = "Microsoft.AspNetCore"; + + private static readonly Func> HttpRequestHeaderValuesGetter = (request, name) => + { + if (request.Headers.TryGetValue(name, out var value)) + { + // This causes allocation as the `StringValues` struct has to be casted to an `IEnumerable` object. + return value; + } + + return Enumerable.Empty(); + }; + + private static readonly PropertyFetcher ExceptionPropertyFetcher = new("Exception"); + + private readonly AspNetCoreTraceInstrumentationOptions options; + + public HttpInListener(AspNetCoreTraceInstrumentationOptions options) + : base(DiagnosticSourceName) + { + Guard.ThrowIfNull(options); + + this.options = options; + } + + public override void OnEventWritten(string name, object payload) + { + switch (name) + { + case OnStartEvent: + { + this.OnStartActivity(Activity.Current, payload); + } + + break; + case OnStopEvent: + { + this.OnStopActivity(Activity.Current, payload); + } + + break; + case OnUnhandledHostingExceptionEvent: + case OnUnHandledDiagnosticsExceptionEvent: + { + this.OnException(Activity.Current, payload); + } + + break; + } + } + + public void OnStartActivity(Activity activity, object payload) + { + // The overall flow of what AspNetCore library does is as below: + // Activity.Start() + // DiagnosticSource.WriteEvent("Start", payload) + // DiagnosticSource.WriteEvent("Stop", payload) + // Activity.Stop() + + // This method is in the WriteEvent("Start", payload) path. + // By this time, samplers have already run and + // activity.IsAllDataRequested populated accordingly. + + var context = payload as HttpContext; + if (context == null) + { + AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInListener), nameof(this.OnStartActivity), activity.OperationName); + return; + } + + // Ensure context extraction irrespective of sampling decision + var request = context.Request; + var textMapPropagator = Propagators.DefaultTextMapPropagator; + if (textMapPropagator is not TraceContextPropagator) + { + var ctx = textMapPropagator.Extract(default, request, HttpRequestHeaderValuesGetter); + if (ctx.ActivityContext.IsValid() + && !((ctx.ActivityContext.TraceId == activity.TraceId) + && (ctx.ActivityContext.SpanId == activity.ParentSpanId) + && (ctx.ActivityContext.TraceState == activity.TraceStateString))) + { + // Create a new activity with its parent set from the extracted context. + // This makes the new activity as a "sibling" of the activity created by + // Asp.Net Core. + Activity newOne; + if (Net7OrGreater) + { + // For NET7.0 onwards activity is created using ActivitySource so, + // we will use the source of the activity to create the new one. + newOne = activity.Source.CreateActivity(ActivityOperationName, ActivityKind.Server, ctx.ActivityContext); + } + else + { +#pragma warning disable CA2000 + newOne = new Activity(ActivityOperationName); +#pragma warning restore CA2000 + newOne.SetParentId(ctx.ActivityContext.TraceId, ctx.ActivityContext.SpanId, ctx.ActivityContext.TraceFlags); + } + + newOne.TraceStateString = ctx.ActivityContext.TraceState; + + newOne.SetTag("IsCreatedByInstrumentation", bool.TrueString); + + // Starting the new activity make it the Activity.Current one. + newOne.Start(); + + // Set IsAllDataRequested to false for the activity created by the framework to only export the sibling activity and not the framework activity + activity.IsAllDataRequested = false; + activity = newOne; + } + + Baggage.Current = ctx.Baggage; + } + + // enrich Activity from payload only if sampling decision + // is favorable. + if (activity.IsAllDataRequested) + { + try + { + if (this.options.Filter?.Invoke(context) == false) + { + AspNetCoreInstrumentationEventSource.Log.RequestIsFilteredOut(nameof(HttpInListener), nameof(this.OnStartActivity), activity.OperationName); + activity.IsAllDataRequested = false; + activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + return; + } + } + catch (Exception ex) + { + AspNetCoreInstrumentationEventSource.Log.RequestFilterException(nameof(HttpInListener), nameof(this.OnStartActivity), activity.OperationName, ex); + activity.IsAllDataRequested = false; + activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + return; + } + + if (!Net7OrGreater) + { + ActivityInstrumentationHelper.SetActivitySourceProperty(activity, ActivitySource); + ActivityInstrumentationHelper.SetKindProperty(activity, ActivityKind.Server); + } + + var path = (request.PathBase.HasValue || request.Path.HasValue) ? (request.PathBase + request.Path).ToString() : "/"; + TelemetryHelper.RequestDataHelper.SetActivityDisplayName(activity, request.Method); + + // see the spec https://github.com/open-telemetry/semantic-conventions/blob/v1.23.0/docs/http/http-spans.md + + if (request.Host.HasValue) + { + activity.SetTag(SemanticConventions.AttributeServerAddress, request.Host.Host); + + if (request.Host.Port.HasValue) + { + activity.SetTag(SemanticConventions.AttributeServerPort, request.Host.Port.Value); + } + } + + if (request.QueryString.HasValue) + { + if (this.options.DisableUrlQueryRedaction) + { + activity.SetTag(SemanticConventions.AttributeUrlQuery, request.QueryString.Value); + } + else + { + activity.SetTag(SemanticConventions.AttributeUrlQuery, RedactionHelper.GetRedactedQueryString(request.QueryString.Value)); + } + } + + TelemetryHelper.RequestDataHelper.SetHttpMethodTag(activity, request.Method); + + activity.SetTag(SemanticConventions.AttributeUrlScheme, request.Scheme); + activity.SetTag(SemanticConventions.AttributeUrlPath, path); + activity.SetTag(SemanticConventions.AttributeNetworkProtocolVersion, RequestDataHelper.GetHttpProtocolVersion(request.Protocol)); + + if (request.Headers.TryGetValue("User-Agent", out var values)) + { + var userAgent = values.Count > 0 ? values[0] : null; + if (!string.IsNullOrEmpty(userAgent)) + { + activity.SetTag(SemanticConventions.AttributeUserAgentOriginal, userAgent); + } + } + + try + { + this.options.EnrichWithHttpRequest?.Invoke(activity, request); + } + catch (Exception ex) + { + AspNetCoreInstrumentationEventSource.Log.EnrichmentException(nameof(HttpInListener), nameof(this.OnStartActivity), activity.OperationName, ex); + } + } + } + + public void OnStopActivity(Activity activity, object payload) + { + if (activity.IsAllDataRequested) + { + HttpContext context = payload as HttpContext; + if (context == null) + { + AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInListener), nameof(this.OnStopActivity), activity.OperationName); + return; + } + + var response = context.Response; + +#if !NETSTANDARD + var routePattern = (context.Features.Get()?.Endpoint as RouteEndpoint ?? + context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText; + if (!string.IsNullOrEmpty(routePattern)) + { + TelemetryHelper.RequestDataHelper.SetActivityDisplayName(activity, context.Request.Method, routePattern); + activity.SetTag(SemanticConventions.AttributeHttpRoute, routePattern); + } +#endif + + activity.SetTag(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)); + + if (this.options.EnableGrpcAspNetCoreSupport && TryGetGrpcMethod(activity, out var grpcMethod)) + { + AddGrpcAttributes(activity, grpcMethod, context); + } + + if (activity.Status == ActivityStatusCode.Unset) + { + activity.SetStatus(SpanHelper.ResolveActivityStatusForHttpStatusCode(activity.Kind, response.StatusCode)); + } + + try + { + this.options.EnrichWithHttpResponse?.Invoke(activity, response); + } + catch (Exception ex) + { + AspNetCoreInstrumentationEventSource.Log.EnrichmentException(nameof(HttpInListener), nameof(this.OnStopActivity), activity.OperationName, ex); + } + } + + object tagValue; + if (Net7OrGreater) + { + tagValue = activity.GetTagValue("IsCreatedByInstrumentation"); + } + else + { + _ = activity.TryCheckFirstTag("IsCreatedByInstrumentation", out tagValue); + } + + if (ReferenceEquals(tagValue, bool.TrueString)) + { + // If instrumentation started a new Activity, it must + // be stopped here. + activity.SetTag("IsCreatedByInstrumentation", null); + activity.Stop(); + + // After the activity.Stop() code, Activity.Current becomes null. + // If Asp.Net Core uses Activity.Current?.Stop() - it'll not stop the activity + // it created. + // Currently Asp.Net core does not use Activity.Current, instead it stores a + // reference to its activity, and calls .Stop on it. + + // TODO: Should we still restore Activity.Current here? + // If yes, then we need to store the asp.net core activity inside + // the one created by the instrumentation. + // And retrieve it here, and set it to Current. + } + } + + public void OnException(Activity activity, object payload) + { + if (activity.IsAllDataRequested) + { + // We need to use reflection here as the payload type is not a defined public type. + if (!TryFetchException(payload, out Exception exc)) + { + AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInListener), nameof(this.OnException), activity.OperationName); + return; + } + + activity.SetTag(SemanticConventions.AttributeErrorType, exc.GetType().FullName); + + if (this.options.RecordException) + { + activity.RecordException(exc); + } + + activity.SetStatus(ActivityStatusCode.Error); + + try + { + this.options.EnrichWithException?.Invoke(activity, exc); + } + catch (Exception ex) + { + AspNetCoreInstrumentationEventSource.Log.EnrichmentException(nameof(HttpInListener), nameof(this.OnException), activity.OperationName, ex); + } + } + + // See https://github.com/dotnet/aspnetcore/blob/690d78279e940d267669f825aa6627b0d731f64c/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L252 + // and https://github.com/dotnet/aspnetcore/blob/690d78279e940d267669f825aa6627b0d731f64c/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs#L174 + // this makes sure that top-level properties on the payload object are always preserved. +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The event source guarantees that top level properties are preserved")] +#endif + static bool TryFetchException(object payload, out Exception exc) + => ExceptionPropertyFetcher.TryFetch(payload, out exc) && exc != null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryGetGrpcMethod(Activity activity, out string grpcMethod) + { + grpcMethod = GrpcTagHelper.GetGrpcMethodFromActivity(activity); + return !string.IsNullOrEmpty(grpcMethod); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AddGrpcAttributes(Activity activity, string grpcMethod, HttpContext context) + { + // The RPC semantic conventions indicate the span name + // should not have a leading forward slash. + // https://github.com/open-telemetry/semantic-conventions/blob/main/docs/rpc/rpc-spans.md#span-name + activity.DisplayName = grpcMethod.TrimStart('/'); + + activity.SetTag(SemanticConventions.AttributeRpcSystem, GrpcTagHelper.RpcSystemGrpc); + + // see the spec https://github.com/open-telemetry/semantic-conventions/blob/v1.23.0/docs/rpc/rpc-spans.md + + if (context.Connection.RemoteIpAddress != null) + { + activity.SetTag(SemanticConventions.AttributeClientAddress, context.Connection.RemoteIpAddress.ToString()); + } + + activity.SetTag(SemanticConventions.AttributeClientPort, context.Connection.RemotePort); + + bool validConversion = GrpcTagHelper.TryGetGrpcStatusCodeFromActivity(activity, out int status); + if (validConversion) + { + activity.SetStatus(GrpcTagHelper.ResolveSpanStatusForGrpcStatusCode(status)); + } + + if (GrpcTagHelper.TryParseRpcServiceAndRpcMethod(grpcMethod, out var rpcService, out var rpcMethod)) + { + activity.SetTag(SemanticConventions.AttributeRpcService, rpcService); + activity.SetTag(SemanticConventions.AttributeRpcMethod, rpcMethod); + + // Remove the grpc.method tag added by the gRPC .NET library + activity.SetTag(GrpcTagHelper.GrpcMethodTagName, null); + + // Remove the grpc.status_code tag added by the gRPC .NET library + activity.SetTag(GrpcTagHelper.GrpcStatusCodeTagName, null); + + if (validConversion) + { + // setting rpc.grpc.status_code + activity.SetTag(SemanticConventions.AttributeRpcGrpcStatusCode, status); + } + } + } +} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs new file mode 100644 index 0000000000..cf9682d1fa --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs @@ -0,0 +1,128 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; +using Microsoft.AspNetCore.Http; +using OpenTelemetry.Internal; + +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Routing; +#endif +using OpenTelemetry.Trace; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation; + +internal sealed class HttpInMetricsListener : ListenerHandler +{ + internal const string HttpServerRequestDurationMetricName = "http.server.request.duration"; + + internal const string OnUnhandledHostingExceptionEvent = "Microsoft.AspNetCore.Hosting.UnhandledException"; + internal const string OnUnhandledDiagnosticsExceptionEvent = "Microsoft.AspNetCore.Diagnostics.UnhandledException"; + + internal static readonly AssemblyName AssemblyName = typeof(HttpInListener).Assembly.GetName(); + internal static readonly string InstrumentationName = AssemblyName.Name; + internal static readonly string InstrumentationVersion = AssemblyName.Version.ToString(); + internal static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion); + + private const string OnStopEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop"; + + private static readonly PropertyFetcher ExceptionPropertyFetcher = new("Exception"); + private static readonly PropertyFetcher HttpContextPropertyFetcher = new("HttpContext"); + private static readonly object ErrorTypeHttpContextItemsKey = new(); + + private static readonly Histogram HttpServerRequestDuration = Meter.CreateHistogram(HttpServerRequestDurationMetricName, "s", "Duration of HTTP server requests."); + + internal HttpInMetricsListener(string name) + : base(name) + { + } + + public static void OnExceptionEventWritten(string name, object payload) + { + // We need to use reflection here as the payload type is not a defined public type. + if (!TryFetchException(payload, out Exception exc) || !TryFetchHttpContext(payload, out HttpContext ctx)) + { + AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), nameof(OnExceptionEventWritten), HttpServerRequestDurationMetricName); + return; + } + + ctx.Items.Add(ErrorTypeHttpContextItemsKey, exc.GetType().FullName); + + // See https://github.com/dotnet/aspnetcore/blob/690d78279e940d267669f825aa6627b0d731f64c/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L252 + // and https://github.com/dotnet/aspnetcore/blob/690d78279e940d267669f825aa6627b0d731f64c/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs#L174 + // this makes sure that top-level properties on the payload object are always preserved. +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The ASP.NET Core framework guarantees that top level properties are preserved")] +#endif + static bool TryFetchException(object payload, out Exception exc) + => ExceptionPropertyFetcher.TryFetch(payload, out exc) && exc != null; +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The ASP.NET Core framework guarantees that top level properties are preserved")] +#endif + static bool TryFetchHttpContext(object payload, out HttpContext ctx) + => HttpContextPropertyFetcher.TryFetch(payload, out ctx) && ctx != null; + } + + public static void OnStopEventWritten(string name, object payload) + { + var context = payload as HttpContext; + if (context == null) + { + AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), nameof(OnStopEventWritten), HttpServerRequestDurationMetricName); + 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, RequestDataHelper.GetHttpProtocolVersion(context.Request.Protocol))); + tags.Add(new KeyValuePair(SemanticConventions.AttributeUrlScheme, context.Request.Scheme)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode))); + + var httpMethod = TelemetryHelper.RequestDataHelper.GetNormalizedHttpMethod(context.Request.Method); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRequestMethod, httpMethod)); + +#if NET6_0_OR_GREATER + // Check the exception handler feature first in case the endpoint was overwritten + var route = (context.Features.Get()?.Endpoint as RouteEndpoint ?? + context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText; + if (!string.IsNullOrEmpty(route)) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRoute, route)); + } +#endif + if (context.Items.TryGetValue(ErrorTypeHttpContextItemsKey, out var errorType)) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeErrorType, errorType)); + } + + // 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. + HttpServerRequestDuration.Record(Activity.Current.Duration.TotalSeconds, tags); + } + + public override void OnEventWritten(string name, object payload) + { + switch (name) + { + case OnUnhandledDiagnosticsExceptionEvent: + case OnUnhandledHostingExceptionEvent: + { + OnExceptionEventWritten(name, payload); + } + + break; + case OnStopEvent: + { + OnStopEventWritten(name, payload); + } + + break; + } + } +} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/TelemetryHelper.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/TelemetryHelper.cs new file mode 100644 index 0000000000..4e8cd55524 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/TelemetryHelper.cs @@ -0,0 +1,33 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation; + +internal static class TelemetryHelper +{ + public static readonly object[] BoxedStatusCodes = InitializeBoxedStatusCodes(); + internal static readonly RequestDataHelper RequestDataHelper = new(); + + public static object GetBoxedStatusCode(int statusCode) + { + if (statusCode >= 100 && statusCode < 600) + { + return BoxedStatusCodes[statusCode - 100]; + } + + return statusCode; + } + + private static object[] InitializeBoxedStatusCodes() + { + var boxedStatusCodes = new object[500]; + for (int i = 0, c = 100; i < boxedStatusCodes.Length; i++, c++) + { + boxedStatusCodes[i] = c; + } + + return boxedStatusCodes; + } +} diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/OpenTelemetry.Instrumentation.AspNetCore.csproj b/src/OpenTelemetry.Instrumentation.AspNetCore/OpenTelemetry.Instrumentation.AspNetCore.csproj new file mode 100644 index 0000000000..c415de0ab1 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/OpenTelemetry.Instrumentation.AspNetCore.csproj @@ -0,0 +1,52 @@ + + + + net8.0;net7.0;net6.0;netstandard2.0 + ASP.NET Core instrumentation for OpenTelemetry .NET + $(PackageTags);distributed-tracing;AspNetCore + Instrumentation.AspNetCore- + 1.8.1 + + enable + + disable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/README.md b/src/OpenTelemetry.Instrumentation.AspNetCore/README.md new file mode 100644 index 0000000000..f8ef36ef2d --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/README.md @@ -0,0 +1,331 @@ +# ASP.NET Core Instrumentation for OpenTelemetry .NET + +[![NuGet](https://img.shields.io/nuget/v/OpenTelemetry.Instrumentation.AspNetCore.svg)](https://www.nuget.org/packages/OpenTelemetry.Instrumentation.AspNetCore) +[![NuGet](https://img.shields.io/nuget/dt/OpenTelemetry.Instrumentation.AspNetCore.svg)](https://www.nuget.org/packages/OpenTelemetry.Instrumentation.AspNetCore) + +This is an [Instrumentation +Library](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/glossary.md#instrumentation-library), +which instruments [ASP.NET Core](https://docs.microsoft.com/aspnet/core) and +collect metrics and traces about incoming web requests. This instrumentation +also collects traces from incoming gRPC requests using +[Grpc.AspNetCore](https://www.nuget.org/packages/Grpc.AspNetCore). +Instrumentation support for gRPC server requests is supported via an +[experimental](#experimental-support-for-grpc-requests) feature flag. + +This component is based on the +[v1.23](https://github.com/open-telemetry/semantic-conventions/tree/v1.23.0/docs/http) +of http semantic conventions. For details on the default set of attributes that +are added, checkout [Traces](#traces) and [Metrics](#metrics) sections below. + +## Steps to enable OpenTelemetry.Instrumentation.AspNetCore + +### Step 1: Install Package + +Add a reference to the +[`OpenTelemetry.Instrumentation.AspNetCore`](https://www.nuget.org/packages/OpenTelemetry.Instrumentation.AspNetCore) +package. Also, add any other instrumentations & exporters you will need. + +```shell +dotnet add package OpenTelemetry.Instrumentation.AspNetCore +``` + +### Step 2: Enable ASP.NET Core Instrumentation at application startup + +ASP.NET Core instrumentation must be enabled at application startup. This is +typically done in the `ConfigureServices` of your `Startup` class. Both examples +below enables OpenTelemetry by calling `AddOpenTelemetry()` on `IServiceCollection`. + This extension method requires adding the package +[`OpenTelemetry.Extensions.Hosting`](../OpenTelemetry.Extensions.Hosting/README.md) +to the application. This ensures instrumentations are disposed when the host +is shutdown. + +#### Traces + +The following example demonstrates adding ASP.NET Core instrumentation with the +extension method `WithTracing()` on `OpenTelemetryBuilder`. +then extension method `AddAspNetCoreInstrumentation()` on `TracerProviderBuilder` +to the application. This example also sets up the Console Exporter, +which requires adding the package [`OpenTelemetry.Exporter.Console`](../OpenTelemetry.Exporter.Console/README.md) +to the application. + +```csharp +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Trace; + +public void ConfigureServices(IServiceCollection services) +{ + services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation() + .AddConsoleExporter()); +} +``` + +Following list of attributes are added by default on activity. See +[http-spans](https://github.com/open-telemetry/semantic-conventions/tree/v1.23.0/docs/http/http-spans.md) +for more details about each individual attribute: + +* `error.type` +* `http.request.method` +* `http.request.method_original` +* `http.response.status_code` +* `http.route` +* `network.protocol.version` +* `user_agent.original` +* `server.address` +* `server.port` +* `url.path` +* `url.query` - By default, the values in the query component are replaced with + the text `Redacted`. For example, `?key1=value1&key2=value2` becomes + `?key1=Redacted&key2=Redacted`. You can disable this redaction by setting the + environment variable + `OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION` to `true`. +* `url.scheme` + +[Enrich Api](#enrich) can be used if any additional attributes are +required on activity. + +#### Metrics + +The following example demonstrates adding ASP.NET Core instrumentation with the +extension method `WithMetrics()` on `OpenTelemetryBuilder` +then extension method `AddAspNetCoreInstrumentation()` on `MeterProviderBuilder` +to the application. This example also sets up the Console Exporter, +which requires adding the package [`OpenTelemetry.Exporter.Console`](../OpenTelemetry.Exporter.Console/README.md) +to the application. + +```csharp +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Metrics; + +public void ConfigureServices(IServiceCollection services) +{ + services.AddOpenTelemetry() + .WithMetrics(builder => builder + .AddAspNetCoreInstrumentation() + .AddConsoleExporter()); +} +``` + +Following list of attributes are added by default on +`http.server.request.duration` metric. See +[http-metrics](https://github.com/open-telemetry/semantic-conventions/tree/v1.23.0/docs/http/http-metrics.md) +for more details about each individual attribute. `.NET8.0` and above supports +additional metrics, see [list of metrics produced](#list-of-metrics-produced) for +more details. + +* `error.type` +* `http.response.status_code` +* `http.request.method` +* `http.route` +* `network.protocol.version` +* `url.scheme` + +#### List of metrics produced + +When the application targets `.NET6.0` or `.NET7.0`, the instrumentation emits +the following metric: + +| Name | Details | +|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| `http.server.request.duration` | [Specification](https://github.com/open-telemetry/semantic-conventions/blob/release/v1.23.x/docs/http/http-metrics.md#metric-httpserverrequestduration) | + +Starting from `.NET8.0`, metrics instrumentation is natively implemented, and +the ASP.NET Core library has incorporated support for [built-in +metrics](https://learn.microsoft.com/dotnet/core/diagnostics/built-in-metrics-aspnetcore) +following the OpenTelemetry semantic conventions. The library includes additional +metrics beyond those defined in the +[specification](https://github.com/open-telemetry/semantic-conventions/blob/v1.23.0/docs/http/http-metrics.md), +covering additional scenarios for ASP.NET Core users. When the application +targets `.NET8.0` and newer versions, the instrumentation library automatically +enables all `built-in` metrics by default. + +Note that the `AddAspNetCoreInstrumentation()` extension simplifies the process +of enabling all built-in metrics via a single line of code. Alternatively, for +more granular control over emitted metrics, you can utilize the `AddMeter()` +extension on `MeterProviderBuilder` for meters listed in +[built-in-metrics-aspnetcore](https://learn.microsoft.com/dotnet/core/diagnostics/built-in-metrics-aspnetcore). +Using `AddMeter()` for metrics activation eliminates the need to take dependency +on the instrumentation library package and calling +`AddAspNetCoreInstrumentation()`. + +If you utilize `AddAspNetCoreInstrumentation()` and wish to exclude unnecessary +metrics, you can utilize +[Views](https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/docs/metrics/customizing-the-sdk#drop-an-instrument) +to achieve this. + +> [!NOTE] +> There is no difference in features or emitted metrics when enabling metrics +using `AddMeter()` or `AddAspNetCoreInstrumentation()` on `.NET8.0` and newer +versions. + +> [!NOTE] +> The `http.server.request.duration` 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/release/v1.23.x/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`. This applies to all +targeted frameworks. + +## Advanced configuration + +### Tracing + +This instrumentation can be configured to change the default behavior by using +`AspNetCoreTraceInstrumentationOptions`, which allows adding [`Filter`](#filter), +[`Enrich`](#enrich) as explained below. + +// TODO: This section could be refined. +When used with +[`OpenTelemetry.Extensions.Hosting`](../OpenTelemetry.Extensions.Hosting/README.md), +all configurations to `AspNetCoreTraceInstrumentationOptions` can be done in the +`ConfigureServices` +method of you applications `Startup` class as shown below. + +```csharp +// Configure +services.Configure(options => +{ + options.Filter = (httpContext) => + { + // only collect telemetry about HTTP GET requests + return httpContext.Request.Method.Equals("GET"); + }; +}); + +services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation() + .AddConsoleExporter()); +``` + +#### Filter + +This instrumentation by default collects all the incoming http requests. It +allows filtering of requests by using the `Filter` function in +`AspNetCoreTraceInstrumentationOptions`. This defines the condition for allowable +requests. The Filter receives the `HttpContext` of the incoming +request, and does not collect telemetry about the request if the Filter +returns false or throws exception. + +The following code snippet shows how to use `Filter` to only allow GET +requests. + +```csharp +services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation((options) => options.Filter = httpContext => + { + // only collect telemetry about HTTP GET requests + return httpContext.Request.Method.Equals("GET"); + }) + .AddConsoleExporter()); +``` + +It is important to note that this `Filter` option is specific to this +instrumentation. OpenTelemetry has a concept of a +[Sampler](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/sdk.md#sampling), +and the `Filter` option does the filtering *after* the Sampler is invoked. + +#### Enrich + +This instrumentation library provides `EnrichWithHttpRequest`, +`EnrichWithHttpResponse` and `EnrichWithException` options that can be used to +enrich the activity with additional information from the raw `HttpRequest`, +`HttpResponse` and `Exception` objects respectively. These actions are called +only when `activity.IsAllDataRequested` is `true`. It contains the activity +itself (which can be enriched) and the actual raw object. + +The following code snippet shows how to enrich the activity using all 3 +different options. + +```csharp +services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation(o => + { + o.EnrichWithHttpRequest = (activity, httpRequest) => + { + activity.SetTag("requestProtocol", httpRequest.Protocol); + }; + o.EnrichWithHttpResponse = (activity, httpResponse) => + { + activity.SetTag("responseLength", httpResponse.ContentLength); + }; + o.EnrichWithException = (activity, exception) => + { + activity.SetTag("exceptionType", exception.GetType().ToString()); + }; + })); +``` + +[Processor](../../docs/trace/extending-the-sdk/README.md#processor), +is the general extensibility point to add additional properties to any activity. +The `Enrich` option is specific to this instrumentation, and is provided to +get access to `HttpRequest` and `HttpResponse`. + +#### RecordException + +This instrumentation automatically sets Activity Status to Error if an unhandled +exception is thrown. Additionally, `RecordException` feature may be turned on, +to store the exception to the Activity itself as ActivityEvent. + +## Activity duration and http.server.request.duration metric calculation + +`Activity.Duration` and `http.server.request.duration` values represents the +time used to handle an inbound HTTP request as measured at the hosting layer of +ASP.NET Core. The time measurement starts once the underlying web host has: + +* Sufficiently parsed the HTTP request headers on the inbound network stream to + identify the new request. +* Initialized the context data structures such as the + [HttpContext](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.httpcontext). + +The time ends when: + +* The ASP.NET Core handler pipeline is finished executing. +* All response data has been sent. +* The context data structures for the request are being disposed. + +## Experimental support for gRPC requests + +gRPC instrumentation can be enabled by setting +`OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_ENABLE_GRPC_INSTRUMENTATION` flag to +`True`. The flag can be set as an environment variable or via IConfiguration as +shown below. + +```csharp +var appBuilder = WebApplication.CreateBuilder(args); + +appBuilder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_ENABLE_GRPC_INSTRUMENTATION"] = "true", + }); + +appBuilder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation()); +``` + + Semantic conventions for RPC are still + [experimental](https://github.com/open-telemetry/semantic-conventions/tree/main/docs/rpc) + and hence the instrumentation only offers it as an experimental feature. + +## Troubleshooting + +This component uses an +[EventSource](https://docs.microsoft.com/dotnet/api/system.diagnostics.tracing.eventsource) +with the name "OpenTelemetry-Instrumentation-AspNetCore" for its internal +logging. Please refer to [SDK +troubleshooting](../OpenTelemetry/README.md#troubleshooting) for instructions on +seeing these internal logs. + +## References + +* [Introduction to ASP.NET + Core](https://docs.microsoft.com/aspnet/core/introduction-to-aspnet-core) +* [gRPC services using ASP.NET Core](https://docs.microsoft.com/aspnet/core/grpc/aspnetcore) +* [OpenTelemetry Project](https://opentelemetry.io/) diff --git a/src/Shared/ActivityHelperExtensions.cs b/src/Shared/ActivityHelperExtensions.cs index 6a85d45527..7425c97edc 100644 --- a/src/Shared/ActivityHelperExtensions.cs +++ b/src/Shared/ActivityHelperExtensions.cs @@ -4,14 +4,24 @@ #nullable enable using System.Diagnostics; +using System.Runtime.CompilerServices; namespace OpenTelemetry.Trace; internal static class ActivityHelperExtensions { - public static object? GetTagValue(this Activity activity, string tagName) + /// + /// Gets the value of a specific tag on an . + /// + /// Activity instance. + /// Case-sensitive tag name to retrieve. + /// Tag value or null if a match was not found. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static object? GetTagValue(this Activity activity, string? tagName) { - foreach (var tag in activity.TagObjects) + Debug.Assert(activity != null, "Activity should not be null"); + + foreach (ref readonly var tag in activity!.EnumerateTagObjects()) { if (tag.Key == tagName) { @@ -21,4 +31,33 @@ internal static class ActivityHelperExtensions return null; } + + /// + /// Checks if the user provided tag name is the first tag of the and retrieves the tag value. + /// + /// Activity instance. + /// Tag name. + /// Tag value. + /// if the first tag of the supplied Activity matches the user provide tag name. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryCheckFirstTag(this Activity activity, string tagName, out object? tagValue) + { + Debug.Assert(activity != null, "Activity should not be null"); + + var enumerator = activity!.EnumerateTagObjects(); + + if (enumerator.MoveNext()) + { + ref readonly var tag = ref enumerator.Current; + + if (tag.Key == tagName) + { + tagValue = tag.Value; + return true; + } + } + + tagValue = null; + return false; + } } diff --git a/test/OpenTelemetry.AotCompatibility.TestApp/OpenTelemetry.AotCompatibility.TestApp.csproj b/test/OpenTelemetry.AotCompatibility.TestApp/OpenTelemetry.AotCompatibility.TestApp.csproj index 934b0d638c..169dbd0936 100644 --- a/test/OpenTelemetry.AotCompatibility.TestApp/OpenTelemetry.AotCompatibility.TestApp.csproj +++ b/test/OpenTelemetry.AotCompatibility.TestApp/OpenTelemetry.AotCompatibility.TestApp.csproj @@ -18,6 +18,7 @@ + diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Instrumentation/AspNetCoreInstrumentationBenchmarks.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Instrumentation/AspNetCoreInstrumentationBenchmarks.cs new file mode 100644 index 0000000000..1175922312 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Instrumentation/AspNetCoreInstrumentationBenchmarks.cs @@ -0,0 +1,186 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +/* +BenchmarkDotNet=v0.13.5, OS=Windows 11 (10.0.22621.1702/22H2/2022Update/SunValley2) +Intel Core i7-8850H CPU 2.60GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores +.NET SDK=7.0.203 + [Host] : .NET 7.0.5 (7.0.523.17405), X64 RyuJIT AVX2 + DefaultJob : .NET 7.0.5 (7.0.523.17405), X64 RyuJIT AVX2 + + +| Method | EnableInstrumentation | Mean | Error | StdDev | Gen0 | Allocated | +|--------------------------- |---------------------- |---------:|--------:|--------:|-------:|----------:| +| GetRequestForAspNetCoreApp | None | 136.8 us | 1.56 us | 1.46 us | 0.4883 | 2.45 KB | +| GetRequestForAspNetCoreApp | Traces | 148.1 us | 0.88 us | 0.82 us | 0.7324 | 3.57 KB | +| GetRequestForAspNetCoreApp | Metrics | 144.4 us | 1.16 us | 1.08 us | 0.4883 | 2.92 KB | +| GetRequestForAspNetCoreApp | Traces, Metrics | 163.0 us | 1.60 us | 1.49 us | 0.7324 | 3.63 KB | + +Allocation details for .NET 7: + +// Traces +* Activity creation + `Activity.Start()` = 416 B +* Casting of the struct `Microsoft.Extensions.Primitives.StringValues` to `IEnumerable` by `HttpRequestHeaderValuesGetter` + - `TraceContextPropagator.Extract` = 24 B + - `BaggageContextPropagator.Extract` = 24 B +* String creation for `HttpRequest.HostString.Host` = 40 B +* `Activity.TagsLinkedList` (this is allocated on the first Activity.SetTag call) = 40 B +* Boxing of `Port` number when adding it as a tag = 24 B +* String creation in `GetUri` method for adding the http url tag = 66 B +* Setting `Baggage` (Setting AsyncLocal values causes allocation) + - `BaggageHolder` creation = 24 B + - `System.Threading.AsyncLocalValueMap.TwoElementAsyncLocalValueMap` = 48 B + - `System.Threading.ExecutionContext` = 40 B +* `DiagNode>` + - This is allocated eight times for the eight tags that are added = 8 * 40 = 320 B +* `Activity.Stop()` trying to set `Activity.Current` (This happens because of setting another AsyncLocal variable which is `Baggage` + - System.Threading.AsyncLocalValueMap.OneElementAsyncLocalValueMap = 32 B + - System.Threading.ExecutionContext = 40 B + +Baseline = 2.45 KB +With Traces = 2.45 + (1138 / 1024) = 2.45 + 1.12 = 3.57 KB + + +// Metrics +* Activity creation + `Activity.Start()` = 416 B +* Boxing of `Port` number when adding it as a tag = 24 B +* String creation for `HttpRequest.HostString.Host` = 40 B + +Baseline = 2.45 KB +With Metrics = 2.45 + (416 + 40 + 24) / 1024 = 2.45 + 0.47 = 2.92 KB + +// With Traces and Metrics + +Baseline = 2.45 KB +With Traces and Metrics = Baseline + With Traces + (With Metrics - (Activity creation + `Acitivity.Stop()`)) (they use the same activity) + = 2.45 + (1138 + 64) / 1024 = 2.45 + 1.17 = ~3.63KB +*/ + +namespace OpenTelemetry.Instrumentation.AspNetCore.Benchmark.Instrumentation; + +public class AspNetCoreInstrumentationBenchmarks +{ + private HttpClient httpClient; + private WebApplication app; + private TracerProvider tracerProvider; + private MeterProvider meterProvider; + + [Flags] + public enum EnableInstrumentationOption + { + /// + /// Instrumentation is not enabled for any signal. + /// + None = 0, + + /// + /// Instrumentation is enbled only for Traces. + /// + Traces = 1, + + /// + /// Instrumentation is enbled only for Metrics. + /// + Metrics = 2, + } + + [Params(0, 1, 2, 3)] + public EnableInstrumentationOption EnableInstrumentation { get; set; } + + [GlobalSetup(Target = nameof(GetRequestForAspNetCoreApp))] + public void GetRequestForAspNetCoreAppGlobalSetup() + { + if (this.EnableInstrumentation == EnableInstrumentationOption.None) + { + this.StartWebApplication(); + this.httpClient = new HttpClient(); + } + else if (this.EnableInstrumentation == EnableInstrumentationOption.Traces) + { + this.StartWebApplication(); + this.httpClient = new HttpClient(); + + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .Build(); + } + else if (this.EnableInstrumentation == EnableInstrumentationOption.Metrics) + { + this.StartWebApplication(); + this.httpClient = new HttpClient(); + + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddAspNetCoreInstrumentation() + .Build(); + } + else if (this.EnableInstrumentation.HasFlag(EnableInstrumentationOption.Traces) && + this.EnableInstrumentation.HasFlag(EnableInstrumentationOption.Metrics)) + { + this.StartWebApplication(); + this.httpClient = new HttpClient(); + + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .Build(); + + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddAspNetCoreInstrumentation() + .Build(); + } + } + + [GlobalCleanup(Target = nameof(GetRequestForAspNetCoreApp))] + public async Task GetRequestForAspNetCoreAppGlobalCleanup() + { + if (this.EnableInstrumentation == EnableInstrumentationOption.None) + { + this.httpClient.Dispose(); + await this.app.DisposeAsync(); + } + else if (this.EnableInstrumentation == EnableInstrumentationOption.Traces) + { + this.httpClient.Dispose(); + await this.app.DisposeAsync(); + this.tracerProvider.Dispose(); + } + else if (this.EnableInstrumentation == EnableInstrumentationOption.Metrics) + { + this.httpClient.Dispose(); + await this.app.DisposeAsync(); + this.meterProvider.Dispose(); + } + else if (this.EnableInstrumentation.HasFlag(EnableInstrumentationOption.Traces) && + this.EnableInstrumentation.HasFlag(EnableInstrumentationOption.Metrics)) + { + this.httpClient.Dispose(); + await this.app.DisposeAsync(); + this.tracerProvider.Dispose(); + this.meterProvider.Dispose(); + } + } + + [Benchmark] + public async Task GetRequestForAspNetCoreApp() + { + var httpResponse = await this.httpClient.GetAsync(new Uri("http://localhost:5000")).ConfigureAwait(false); + httpResponse.EnsureSuccessStatusCode(); + } + + private void StartWebApplication() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + var app = builder.Build(); + app.MapGet("/", async context => await context.Response.WriteAsync($"Hello World!")); + app.RunAsync(); + + this.app = app; + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Instrumentation/AspNetCoreInstrumentationNewBenchmarks.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Instrumentation/AspNetCoreInstrumentationNewBenchmarks.cs new file mode 100644 index 0000000000..9ed7771cd0 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Instrumentation/AspNetCoreInstrumentationNewBenchmarks.cs @@ -0,0 +1,207 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +/* +// * Summary * + +BenchmarkDotNet=v0.13.5, OS=Windows 11 (10.0.22621.1992/22H2/2022Update/SunValley2), VM=Hyper-V +AMD EPYC 7763, 1 CPU, 16 logical and 8 physical cores +.NET SDK=7.0.306 + [Host] : .NET 7.0.9 (7.0.923.32018), X64 RyuJIT AVX2 + DefaultJob : .NET 7.0.9 (7.0.923.32018), X64 RyuJIT AVX2 + + +| Method | EnableInstrumentation | Mean | Error | StdDev | Allocated | +|--------------------------- |---------------------- |---------:|--------:|--------:|----------:| +| GetRequestForAspNetCoreApp | None | 150.7 us | 1.68 us | 1.57 us | 2.45 KB | +| GetRequestForAspNetCoreApp | Traces | 156.6 us | 3.12 us | 6.37 us | 3.46 KB | +| GetRequestForAspNetCoreApp | Metrics | 148.8 us | 2.87 us | 2.69 us | 2.92 KB | +| GetRequestForAspNetCoreApp | Traces, Metrics | 164.0 us | 3.19 us | 6.22 us | 3.52 KB | + +Allocation details for .NET 7: + +// Traces +* Activity creation + `Activity.Start()` = 416 B +* Casting of the struct `Microsoft.Extensions.Primitives.StringValues` to `IEnumerable` by `HttpRequestHeaderValuesGetter` + - `TraceContextPropagator.Extract` = 24 B + - `BaggageContextPropagator.Extract` = 24 B +* String creation for `HttpRequest.HostString.Host` = 40 B +* `Activity.TagsLinkedList` (this is allocated on the first Activity.SetTag call) = 40 B +* Boxing of `Port` number when adding it as a tag = 24 B +* Setting `Baggage` (Setting AsyncLocal values causes allocation) + - `BaggageHolder` creation = 24 B + - `System.Threading.AsyncLocalValueMap.TwoElementAsyncLocalValueMap` = 48 B + - `System.Threading.ExecutionContext` = 40 B +* `DiagNode>` + - This is allocated seven times for the seven (eight if query string is available) tags that are added = 7 * 40 = 280 B +* `Activity.Stop()` trying to set `Activity.Current` (This happens because of setting another AsyncLocal variable which is `Baggage` + - System.Threading.AsyncLocalValueMap.OneElementAsyncLocalValueMap = 32 B + - System.Threading.ExecutionContext = 40 B + +Baseline = 2.45 KB +With Traces = 2.45 + (1032 / 1024) = 2.45 + 1.01 = 3.46 KB + + +// Metrics +* Activity creation + `Activity.Start()` = 416 B +* Boxing of `Port` number when adding it as a tag = 24 B +* String creation for `HttpRequest.HostString.Host` = 40 B + +Baseline = 2.45 KB +With Metrics = 2.45 + (416 + 40 + 24) / 1024 = 2.45 + 0.47 = 2.92 KB + +// With Traces and Metrics + +Baseline = 2.45 KB +With Traces and Metrics = Baseline + With Traces + (With Metrics - (Activity creation + `Acitivity.Stop()`)) (they use the same activity) + = 2.45 + (1032 + 64) / 1024 = 2.45 + 1.07 = ~3.52KB +*/ +namespace OpenTelemetry.Instrumentation.AspNetCore.Benchmark.Instrumentation; + +public class AspNetCoreInstrumentationNewBenchmarks +{ + private HttpClient httpClient; + private WebApplication app; + private TracerProvider tracerProvider; + private MeterProvider meterProvider; + + [Flags] + public enum EnableInstrumentationOption + { + /// + /// Instrumentation is not enabled for any signal. + /// + None = 0, + + /// + /// Instrumentation is enbled only for Traces. + /// + Traces = 1, + + /// + /// Instrumentation is enbled only for Metrics. + /// + Metrics = 2, + } + + [Params(0, 1, 2, 3)] + public EnableInstrumentationOption EnableInstrumentation { get; set; } + + [GlobalSetup(Target = nameof(GetRequestForAspNetCoreApp))] + public void GetRequestForAspNetCoreAppGlobalSetup() + { + KeyValuePair[] config = new KeyValuePair[] { new KeyValuePair("OTEL_SEMCONV_STABILITY_OPT_IN", "http") }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(config) + .Build(); + + if (this.EnableInstrumentation == EnableInstrumentationOption.None) + { + this.StartWebApplication(); + this.httpClient = new HttpClient(); + } + else if (this.EnableInstrumentation == EnableInstrumentationOption.Traces) + { + this.StartWebApplication(); + this.httpClient = new HttpClient(); + + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .ConfigureServices(services => services.AddSingleton(configuration)) + .AddAspNetCoreInstrumentation() + .Build(); + } + else if (this.EnableInstrumentation == EnableInstrumentationOption.Metrics) + { + this.StartWebApplication(); + this.httpClient = new HttpClient(); + + var exportedItems = new List(); + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .ConfigureServices(services => services.AddSingleton(configuration)) + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems, metricReaderOptions => + { + metricReaderOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 1000; + }) + .Build(); + } + else if (this.EnableInstrumentation.HasFlag(EnableInstrumentationOption.Traces) && + this.EnableInstrumentation.HasFlag(EnableInstrumentationOption.Metrics)) + { + this.StartWebApplication(); + this.httpClient = new HttpClient(); + + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .ConfigureServices(services => services.AddSingleton(configuration)) + .AddAspNetCoreInstrumentation() + .Build(); + + var exportedItems = new List(); + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .ConfigureServices(services => services.AddSingleton(configuration)) + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems, metricReaderOptions => + { + metricReaderOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 1000; + }) + .Build(); + } + } + + [GlobalCleanup(Target = nameof(GetRequestForAspNetCoreApp))] + public async Task GetRequestForAspNetCoreAppGlobalCleanup() + { + if (this.EnableInstrumentation == EnableInstrumentationOption.None) + { + this.httpClient.Dispose(); + await this.app.DisposeAsync(); + } + else if (this.EnableInstrumentation == EnableInstrumentationOption.Traces) + { + this.httpClient.Dispose(); + await this.app.DisposeAsync(); + this.tracerProvider.Dispose(); + } + else if (this.EnableInstrumentation == EnableInstrumentationOption.Metrics) + { + this.httpClient.Dispose(); + await this.app.DisposeAsync(); + this.meterProvider.Dispose(); + } + else if (this.EnableInstrumentation.HasFlag(EnableInstrumentationOption.Traces) && + this.EnableInstrumentation.HasFlag(EnableInstrumentationOption.Metrics)) + { + this.httpClient.Dispose(); + await this.app.DisposeAsync(); + this.tracerProvider.Dispose(); + this.meterProvider.Dispose(); + } + } + + [Benchmark] + public async Task GetRequestForAspNetCoreApp() + { + var httpResponse = await this.httpClient.GetAsync(new Uri("http://localhost:5000")).ConfigureAwait(false); + httpResponse.EnsureSuccessStatusCode(); + } + + private void StartWebApplication() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + var app = builder.Build(); + app.MapGet("/", async context => await context.Response.WriteAsync($"Hello World!")); + app.RunAsync(); + + this.app = app; + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/OpenTelemetry.Instrumentation.AspNetCore.Benchmark.csproj b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/OpenTelemetry.Instrumentation.AspNetCore.Benchmark.csproj new file mode 100644 index 0000000000..84fe25b212 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/OpenTelemetry.Instrumentation.AspNetCore.Benchmark.csproj @@ -0,0 +1,21 @@ + + + Exe + + $(SupportedNetTargets) + enable + disable + + + + + + + + + + + + + + diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Program.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Program.cs new file mode 100644 index 0000000000..612690cb98 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/Program.cs @@ -0,0 +1,11 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using BenchmarkDotNet.Running; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Benchmark; + +internal class Program +{ + private static void Main(string[] args) => BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/README.md b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/README.md new file mode 100644 index 0000000000..075e40772c --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark/README.md @@ -0,0 +1,11 @@ +# OpenTelemetry ASP.NET Core Instrumentation Benchmarks + +Navigate to `./test/OpenTelemetry.Instrumentation.AspNetCore.Benchmark` directory +and run the following command: + +```sh +dotnet run -c Release -f net8.0 -- -m +`` + +Then choose the benchmark class that you want to run by entering the required +option number from the list of options shown on the Console window. diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/BasicTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/BasicTests.cs new file mode 100644 index 0000000000..5590a2fb3e --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/BasicTests.cs @@ -0,0 +1,1272 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +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.Context.Propagation; +using OpenTelemetry.Instrumentation.AspNetCore.Implementation; +using OpenTelemetry.Tests; +using OpenTelemetry.Trace; +using TestApp.AspNetCore; +using TestApp.AspNetCore.Filters; +using Xunit; +using Uri = System.Uri; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Tests; + +// See https://github.com/aspnet/Docs/tree/master/aspnetcore/test/integration-tests/samples/2.x/IntegrationTestsSample +[Collection("AspNetCore")] +public sealed class BasicTests + : IClassFixture>, IDisposable +{ + private readonly WebApplicationFactory factory; + private TracerProvider tracerProvider; + + public BasicTests(WebApplicationFactory factory) + { + this.factory = factory; + } + + [Fact] + public void AddAspNetCoreInstrumentation_BadArgs() + { + TracerProviderBuilder builder = null; + Assert.Throws(() => builder.AddAspNetCoreInstrumentation()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StatusIsUnsetOn200Response(bool disableLogging) + { + var exportedItems = new List(); + void ConfigureTestServices(IServiceCollection services) + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems) + .Build(); + } + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + if (disableLogging) + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + } + }) + .CreateClient()) + { + // Act + using var response = await client.GetAsync(new Uri("/api/values", UriKind.Relative)); + + // Assert + response.EnsureSuccessStatusCode(); // Status Code 200-299 + + WaitForActivityExport(exportedItems, 1); + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + Assert.Equal(200, activity.GetTagValue(SemanticConventions.AttributeHttpResponseStatusCode)); + Assert.Equal(ActivityStatusCode.Unset, activity.Status); + ValidateAspNetCoreActivity(activity, "/api/values"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SuccessfulTemplateControllerCallGeneratesASpan(bool shouldEnrich) + { + var exportedItems = new List(); + void ConfigureTestServices(IServiceCollection services) + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation(options => + { + if (shouldEnrich) + { + options.EnrichWithHttpRequest = (activity, request) => { activity.SetTag("enrichedOnStart", "yes"); }; + options.EnrichWithHttpResponse = (activity, response) => { activity.SetTag("enrichedOnStop", "yes"); }; + } + }) + .AddInMemoryExporter(exportedItems) + .Build(); + } + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + // Act + using var response = await client.GetAsync(new Uri("/api/values", UriKind.Relative)); + + // Assert + response.EnsureSuccessStatusCode(); // Status Code 200-299 + + WaitForActivityExport(exportedItems, 1); + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + if (shouldEnrich) + { + Assert.NotEmpty(activity.Tags.Where(tag => tag.Key == "enrichedOnStart" && tag.Value == "yes")); + Assert.NotEmpty(activity.Tags.Where(tag => tag.Key == "enrichedOnStop" && tag.Value == "yes")); + } + + ValidateAspNetCoreActivity(activity, "/api/values"); + } + + [Fact] + public async Task SuccessfulTemplateControllerCallUsesParentContext() + { + var exportedItems = new List(); + var expectedTraceId = ActivityTraceId.CreateRandom(); + var expectedSpanId = ActivitySpanId.CreateRandom(); + + // Arrange + using (var testFactory = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems) + .Build(); + }); + + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + })) + { + using var client = testFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/values/2"); + request.Headers.Add("traceparent", $"00-{expectedTraceId}-{expectedSpanId}-01"); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.EnsureSuccessStatusCode(); // Status Code 200-299 + + WaitForActivityExport(exportedItems, 1); + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + Assert.Equal("Microsoft.AspNetCore.Hosting.HttpRequestIn", activity.OperationName); + + Assert.Equal(expectedTraceId, activity.Context.TraceId); + Assert.Equal(expectedSpanId, activity.ParentSpanId); + + ValidateAspNetCoreActivity(activity, "/api/values/2"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CustomPropagator(bool addSampler) + { + try + { + var exportedItems = new List(); + var expectedTraceId = ActivityTraceId.CreateRandom(); + var expectedSpanId = ActivitySpanId.CreateRandom(); + + var propagator = new CustomTextMapPropagator + { + TraceId = expectedTraceId, + SpanId = expectedSpanId, + }; + + // Arrange + using (var testFactory = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + Sdk.SetDefaultTextMapPropagator(propagator); + var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder(); + + if (addSampler) + { + tracerProviderBuilder + .SetSampler(new TestSampler(SamplingDecision.RecordAndSample, new Dictionary { { "SomeTag", "SomeKey" }, })); + } + + this.tracerProvider = tracerProviderBuilder + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems) + .Build(); + }); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + })) + { + using var client = testFactory.CreateClient(); + using var response = await client.GetAsync(new Uri("/api/values/2", UriKind.Relative)); + response.EnsureSuccessStatusCode(); // Status Code 200-299 + + WaitForActivityExport(exportedItems, 1); + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + Assert.True(activity.Duration != TimeSpan.Zero); + + Assert.Equal(expectedTraceId, activity.Context.TraceId); + Assert.Equal(expectedSpanId, activity.ParentSpanId); + + ValidateAspNetCoreActivity(activity, "/api/values/2"); + } + finally + { + Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator(new TextMapPropagator[] + { + new TraceContextPropagator(), + new BaggagePropagator(), + })); + } + } + + [Fact] + public async Task RequestNotCollectedWhenFilterIsApplied() + { + var exportedItems = new List(); + + void ConfigureTestServices(IServiceCollection services) + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation((opt) => opt.Filter = (ctx) => ctx.Request.Path != "/api/values/2") + .AddInMemoryExporter(exportedItems) + .Build(); + } + + // Arrange + using (var testFactory = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + })) + { + using var client = testFactory.CreateClient(); + + // Act + using var response1 = await client.GetAsync(new Uri("/api/values", UriKind.Relative)); + using var response2 = await client.GetAsync(new Uri("/api/values/2", UriKind.Relative)); + + // Assert + response1.EnsureSuccessStatusCode(); // Status Code 200-299 + response2.EnsureSuccessStatusCode(); // Status Code 200-299 + + WaitForActivityExport(exportedItems, 1); + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + ValidateAspNetCoreActivity(activity, "/api/values"); + } + + [Fact] + public async Task RequestNotCollectedWhenFilterThrowException() + { + var exportedItems = new List(); + + void ConfigureTestServices(IServiceCollection services) + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation((opt) => opt.Filter = (ctx) => + { + if (ctx.Request.Path == "/api/values/2") + { + throw new Exception("from InstrumentationFilter"); + } + else + { + return true; + } + }) + .AddInMemoryExporter(exportedItems) + .Build(); + } + + // Arrange + using (var testFactory = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + })) + { + using var client = testFactory.CreateClient(); + + // Act + using (var inMemoryEventListener = new InMemoryEventListener(AspNetCoreInstrumentationEventSource.Log)) + { + using var response1 = await client.GetAsync(new Uri("/api/values", UriKind.Relative)); + using var response2 = await client.GetAsync(new Uri("/api/values/2", UriKind.Relative)); + + response1.EnsureSuccessStatusCode(); // Status Code 200-299 + response2.EnsureSuccessStatusCode(); // Status Code 200-299 + Assert.Single(inMemoryEventListener.Events.Where((e) => e.EventId == 3)); + } + + WaitForActivityExport(exportedItems, 1); + } + + // As InstrumentationFilter threw, we continue as if the + // InstrumentationFilter did not exist. + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + ValidateAspNetCoreActivity(activity, "/api/values"); + } + + [Theory] + [InlineData(SamplingDecision.Drop)] + [InlineData(SamplingDecision.RecordOnly)] + [InlineData(SamplingDecision.RecordAndSample)] + public async Task ExtractContextIrrespectiveOfSamplingDecision(SamplingDecision samplingDecision) + { + try + { + var expectedTraceId = ActivityTraceId.CreateRandom(); + var expectedParentSpanId = ActivitySpanId.CreateRandom(); + var expectedTraceState = "rojo=1,congo=2"; + var activityContext = new ActivityContext(expectedTraceId, expectedParentSpanId, ActivityTraceFlags.Recorded, expectedTraceState, true); + var expectedBaggage = Baggage.SetBaggage("key1", "value1").SetBaggage("key2", "value2"); + Sdk.SetDefaultTextMapPropagator(new ExtractOnlyPropagator(activityContext, expectedBaggage)); + + // Arrange + using var testFactory = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => { this.tracerProvider = Sdk.CreateTracerProviderBuilder().SetSampler(new TestSampler(samplingDecision)).AddAspNetCoreInstrumentation().Build(); }); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }); + using var client = testFactory.CreateClient(); + + // Test TraceContext Propagation + var request = new HttpRequestMessage(HttpMethod.Get, "/api/GetChildActivityTraceContext"); + var response = await client.SendAsync(request); + var childActivityTraceContext = JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()); + + response.EnsureSuccessStatusCode(); + + Assert.Equal(expectedTraceId.ToString(), childActivityTraceContext["TraceId"]); + Assert.Equal(expectedTraceState, childActivityTraceContext["TraceState"]); + Assert.NotEqual(expectedParentSpanId.ToString(), childActivityTraceContext["ParentSpanId"]); // there is a new activity created in instrumentation therefore the ParentSpanId is different that what is provided in the headers + + // Test Baggage Context Propagation + request = new HttpRequestMessage(HttpMethod.Get, "/api/GetChildActivityBaggageContext"); + + response = await client.SendAsync(request); + var childActivityBaggageContext = JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()); + + response.EnsureSuccessStatusCode(); + + Assert.Single(childActivityBaggageContext, item => item.Key == "key1" && item.Value == "value1"); + Assert.Single(childActivityBaggageContext, item => item.Key == "key2" && item.Value == "value2"); + } + finally + { + Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator(new TextMapPropagator[] + { + new TraceContextPropagator(), + new BaggagePropagator(), + })); + } + } + + [Fact] + public async Task ExtractContextIrrespectiveOfTheFilterApplied() + { + try + { + var expectedTraceId = ActivityTraceId.CreateRandom(); + var expectedParentSpanId = ActivitySpanId.CreateRandom(); + var expectedTraceState = "rojo=1,congo=2"; + var activityContext = new ActivityContext(expectedTraceId, expectedParentSpanId, ActivityTraceFlags.Recorded, expectedTraceState); + var expectedBaggage = Baggage.SetBaggage("key1", "value1").SetBaggage("key2", "value2"); + Sdk.SetDefaultTextMapPropagator(new ExtractOnlyPropagator(activityContext, expectedBaggage)); + + // Arrange + bool isFilterCalled = false; + using var testFactory = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation(options => + { + options.Filter = context => + { + isFilterCalled = true; + return false; + }; + }) + .Build(); + }); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }); + using var client = testFactory.CreateClient(); + + // Test TraceContext Propagation + var request = new HttpRequestMessage(HttpMethod.Get, "/api/GetChildActivityTraceContext"); + var response = await client.SendAsync(request); + + // Ensure that filter was called + Assert.True(isFilterCalled); + + var childActivityTraceContext = JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()); + + response.EnsureSuccessStatusCode(); + + Assert.Equal(expectedTraceId.ToString(), childActivityTraceContext["TraceId"]); + Assert.Equal(expectedTraceState, childActivityTraceContext["TraceState"]); + Assert.NotEqual(expectedParentSpanId.ToString(), childActivityTraceContext["ParentSpanId"]); // there is a new activity created in instrumentation therefore the ParentSpanId is different that what is provided in the headers + + // Test Baggage Context Propagation + request = new HttpRequestMessage(HttpMethod.Get, "/api/GetChildActivityBaggageContext"); + + response = await client.SendAsync(request); + var childActivityBaggageContext = JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()); + + response.EnsureSuccessStatusCode(); + + Assert.Single(childActivityBaggageContext, item => item.Key == "key1" && item.Value == "value1"); + Assert.Single(childActivityBaggageContext, item => item.Key == "key2" && item.Value == "value2"); + } + finally + { + Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator(new TextMapPropagator[] + { + new TraceContextPropagator(), + new BaggagePropagator(), + })); + } + } + + [Fact] + public async Task BaggageIsNotClearedWhenActivityStopped() + { + int? baggageCountAfterStart = null; + int? baggageCountAfterStop = null; + using EventWaitHandle stopSignal = new EventWaitHandle(false, EventResetMode.ManualReset); + + void ConfigureTestServices(IServiceCollection services) + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation( + new TestHttpInListener(new AspNetCoreTraceInstrumentationOptions()) + { + OnEventWrittenCallback = (name, payload) => + { + switch (name) + { + case HttpInListener.OnStartEvent: + { + baggageCountAfterStart = Baggage.Current.Count; + } + + break; + case HttpInListener.OnStopEvent: + { + baggageCountAfterStop = Baggage.Current.Count; + stopSignal.Set(); + } + + break; + } + }, + }) + .Build(); + } + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/values"); + + request.Headers.TryAddWithoutValidation("baggage", "TestKey1=123,TestKey2=456"); + + // Act + using var response = await client.SendAsync(request); + } + + stopSignal.WaitOne(5000); + + // Assert + Assert.NotNull(baggageCountAfterStart); + Assert.Equal(2, baggageCountAfterStart); + Assert.NotNull(baggageCountAfterStop); + Assert.Equal(2, baggageCountAfterStop); + } + + [Theory] + [InlineData(SamplingDecision.Drop, false, false)] + [InlineData(SamplingDecision.RecordOnly, true, true)] + [InlineData(SamplingDecision.RecordAndSample, true, true)] + public async Task FilterAndEnrichAreOnlyCalledWhenSampled(SamplingDecision samplingDecision, bool shouldFilterBeCalled, bool shouldEnrichBeCalled) + { + bool filterCalled = false; + bool enrichWithHttpRequestCalled = false; + bool enrichWithHttpResponseCalled = false; + void ConfigureTestServices(IServiceCollection services) + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .SetSampler(new TestSampler(samplingDecision)) + .AddAspNetCoreInstrumentation(options => + { + options.Filter = (context) => + { + filterCalled = true; + return true; + }; + options.EnrichWithHttpRequest = (activity, request) => + { + enrichWithHttpRequestCalled = true; + }; + options.EnrichWithHttpResponse = (activity, request) => + { + enrichWithHttpResponseCalled = true; + }; + }) + .Build(); + } + + // Arrange + using var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient(); + + // Act + using var response = await client.GetAsync(new Uri("/api/values", UriKind.Relative)); + + // Assert + Assert.Equal(shouldFilterBeCalled, filterCalled); + Assert.Equal(shouldEnrichBeCalled, enrichWithHttpRequestCalled); + Assert.Equal(shouldEnrichBeCalled, enrichWithHttpResponseCalled); + } + + [Fact] + public async Task ActivitiesStartedInMiddlewareShouldNotBeUpdated() + { + var exportedItems = new List(); + + var activitySourceName = "TestMiddlewareActivitySource"; + var activityName = "TestMiddlewareActivity"; + + void ConfigureTestServices(IServiceCollection services) + { + services.AddSingleton(new TestTestActivityMiddleware(activitySourceName, activityName)); + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddSource(activitySourceName) + .AddInMemoryExporter(exportedItems) + .Build(); + } + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + using var response = await client.GetAsync(new Uri("/api/values/2", UriKind.Relative)); + response.EnsureSuccessStatusCode(); + WaitForActivityExport(exportedItems, 2); + } + + Assert.Equal(2, exportedItems.Count); + + var middlewareActivity = exportedItems[0]; + + var aspnetcoreframeworkactivity = exportedItems[1]; + + // Middleware activity name should not be changed + Assert.Equal(ActivityKind.Internal, middlewareActivity.Kind); + Assert.Equal(activityName, middlewareActivity.OperationName); + Assert.Equal(activityName, middlewareActivity.DisplayName); + + // tag http.method should be added on activity started by asp.net core + Assert.Equal("GET", aspnetcoreframeworkactivity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod) as string); + Assert.Equal("Microsoft.AspNetCore.Hosting.HttpRequestIn", aspnetcoreframeworkactivity.OperationName); + } + + [Theory] + [InlineData("CONNECT", "CONNECT", null, "CONNECT")] + [InlineData("DELETE", "DELETE", null, "DELETE")] + [InlineData("GET", "GET", null, "GET")] + [InlineData("PUT", "PUT", null, "PUT")] + [InlineData("HEAD", "HEAD", null, "HEAD")] + [InlineData("OPTIONS", "OPTIONS", null, "OPTIONS")] + [InlineData("PATCH", "PATCH", null, "PATCH")] + [InlineData("Get", "GET", "Get", "GET")] + [InlineData("POST", "POST", null, "POST")] + [InlineData("TRACE", "TRACE", null, "TRACE")] + [InlineData("CUSTOM", "_OTHER", "CUSTOM", "HTTP")] + public async Task HttpRequestMethodAndActivityDisplayIsSetAsPerSpec(string originalMethod, string expectedMethod, string expectedOriginalMethod, string expectedDisplayName) + { + var exportedItems = new List(); + + void ConfigureTestServices(IServiceCollection services) + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems) + .Build(); + } + + // Arrange + using var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient(); + + var message = new HttpRequestMessage(); + + message.Method = new HttpMethod(originalMethod); + + try + { + using var response = await client.SendAsync(message); + response.EnsureSuccessStatusCode(); + } + catch + { + // ignore error. + } + + WaitForActivityExport(exportedItems, 1); + + Assert.Single(exportedItems); + + var activity = exportedItems[0]; + + Assert.Equal(expectedMethod, activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + Assert.Equal(expectedOriginalMethod, activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethodOriginal)); + Assert.Equal(expectedDisplayName, activity.DisplayName); + } + + [Fact] + public async Task ActivitiesStartedInMiddlewareBySettingHostActivityToNullShouldNotBeUpdated() + { + var exportedItems = new List(); + + var activitySourceName = "TestMiddlewareActivitySource"; + var activityName = "TestMiddlewareActivity"; + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices((IServiceCollection services) => + { + services.AddSingleton(new TestNullHostActivityMiddlewareImpl(activitySourceName, activityName)); + services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation() + .AddSource(activitySourceName) + .AddInMemoryExporter(exportedItems)); + }); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + using var response = await client.GetAsync(new Uri("/api/values/2", UriKind.Relative)); + response.EnsureSuccessStatusCode(); + WaitForActivityExport(exportedItems, 2); + } + + Assert.Equal(2, exportedItems.Count); + + var middlewareActivity = exportedItems[0]; + + var aspnetcoreframeworkactivity = exportedItems[1]; + + // Middleware activity name should not be changed + Assert.Equal(ActivityKind.Internal, middlewareActivity.Kind); + Assert.Equal(activityName, middlewareActivity.OperationName); + Assert.Equal(activityName, middlewareActivity.DisplayName); + + // tag http.method should be added on activity started by asp.net core + Assert.Equal("GET", aspnetcoreframeworkactivity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod) as string); + Assert.Equal("Microsoft.AspNetCore.Hosting.HttpRequestIn", aspnetcoreframeworkactivity.OperationName); + } + +#if NET7_0_OR_GREATER + [Fact] + public async Task UserRegisteredActivitySourceIsUsedForActivityCreationByAspNetCore() + { + var exportedItems = new List(); + void ConfigureTestServices(IServiceCollection services) + { + services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems)); + + // Register ActivitySource here so that it will be used + // by ASP.NET Core to create activities + // https://github.com/dotnet/aspnetcore/blob/0e5cbf447d329a1e7d69932c3decd1c70a00fbba/src/Hosting/Hosting/src/Internal/WebHost.cs#L152 + services.AddSingleton(sp => new ActivitySource("UserRegisteredActivitySource")); + } + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + // Act + using var response = await client.GetAsync(new Uri("/api/values", UriKind.Relative)); + + // Assert + response.EnsureSuccessStatusCode(); // Status Code 200-299 + + WaitForActivityExport(exportedItems, 1); + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + Assert.Equal("UserRegisteredActivitySource", activity.Source.Name); + } +#endif + + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task ShouldExportActivityWithOneOrMoreExceptionFilters(int mode) + { + var exportedItems = new List(); + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices( + (s) => this.ConfigureExceptionFilters(s, mode, ref exportedItems)); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + // Act + using var response = await client.GetAsync(new Uri("/api/error", UriKind.Relative)); + + WaitForActivityExport(exportedItems, 1); + } + + // Assert + AssertException(exportedItems); + } + + [Fact] + public async Task DiagnosticSourceCallbacksAreReceivedOnlyForSubscribedEvents() + { + int numberOfUnSubscribedEvents = 0; + int numberofSubscribedEvents = 0; + + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation( + new TestHttpInListener(new AspNetCoreTraceInstrumentationOptions()) + { + OnEventWrittenCallback = (name, payload) => + { + switch (name) + { + case HttpInListener.OnStartEvent: + { + numberofSubscribedEvents++; + } + + break; + case HttpInListener.OnStopEvent: + { + numberofSubscribedEvents++; + } + + break; + default: + { + numberOfUnSubscribedEvents++; + } + + break; + } + }, + }) + .Build(); + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/values"); + + // Act + using var response = await client.SendAsync(request); + } + + Assert.Equal(0, numberOfUnSubscribedEvents); + Assert.Equal(2, numberofSubscribedEvents); + } + + [Fact] + public async Task DiagnosticSourceExceptionCallbackIsReceivedForUnHandledException() + { + int numberOfUnSubscribedEvents = 0; + int numberofSubscribedEvents = 0; + int numberOfExceptionCallbacks = 0; + + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation( + new TestHttpInListener(new AspNetCoreTraceInstrumentationOptions()) + { + OnEventWrittenCallback = (name, payload) => + { + switch (name) + { + case HttpInListener.OnStartEvent: + { + numberofSubscribedEvents++; + } + + break; + case HttpInListener.OnStopEvent: + { + numberofSubscribedEvents++; + } + + break; + + // TODO: Add test case for validating name for both the types + // of exception event. + case HttpInListener.OnUnhandledHostingExceptionEvent: + case HttpInListener.OnUnHandledDiagnosticsExceptionEvent: + { + numberofSubscribedEvents++; + numberOfExceptionCallbacks++; + } + + break; + default: + { + numberOfUnSubscribedEvents++; + } + + break; + } + }, + }) + .Build(); + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/error"); + + // Act + using var response = await client.SendAsync(request); + } + catch + { + // ignore exception + } + } + + Assert.Equal(1, numberOfExceptionCallbacks); + Assert.Equal(0, numberOfUnSubscribedEvents); + Assert.Equal(3, numberofSubscribedEvents); + } + + [Fact] + public async Task DiagnosticSourceExceptionCallBackIsNotReceivedForExceptionsHandledInMiddleware() + { + int numberOfUnSubscribedEvents = 0; + int numberOfSubscribedEvents = 0; + int numberOfExceptionCallbacks = 0; + bool exceptionHandled = false; + + // configure SDK + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation( + new TestHttpInListener(new AspNetCoreTraceInstrumentationOptions()) + { + OnEventWrittenCallback = (name, payload) => + { + switch (name) + { + case HttpInListener.OnStartEvent: + { + numberOfSubscribedEvents++; + } + + break; + case HttpInListener.OnStopEvent: + { + numberOfSubscribedEvents++; + } + + break; + + // TODO: Add test case for validating name for both the types + // of exception event. + case HttpInListener.OnUnhandledHostingExceptionEvent: + case HttpInListener.OnUnHandledDiagnosticsExceptionEvent: + { + numberOfSubscribedEvents++; + numberOfExceptionCallbacks++; + } + + break; + default: + { + numberOfUnSubscribedEvents++; + } + + break; + } + }, + }) + .Build(); + + TestMiddleware.Create(builder => builder + .UseExceptionHandler(handler => + handler.Run(async (ctx) => + { + exceptionHandled = true; + await ctx.Response.WriteAsync("handled"); + }))); + + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/error"); + using var response = await client.SendAsync(request); + } + catch + { + // ignore exception + } + } + + Assert.Equal(0, numberOfExceptionCallbacks); + Assert.Equal(0, numberOfUnSubscribedEvents); + Assert.Equal(2, numberOfSubscribedEvents); + Assert.True(exceptionHandled); + } + + [Fact] + public async Task NoSiblingActivityCreatedWhenTraceFlagsNone() + { + using var localTracerProvider = Sdk.CreateTracerProviderBuilder() + .SetSampler(new AlwaysOnSampler()) + .AddAspNetCoreInstrumentation() + .Build(); + + using var testFactory = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .Build(); + }); + + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }); + using var client = testFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/GetActivityEquality"); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + request.Headers.Add("traceparent", $"00-{traceId}-{spanId}-00"); + + var response = await client.SendAsync(request); + var result = bool.Parse(await response.Content.ReadAsStringAsync()); + + Assert.True(response.IsSuccessStatusCode); + + // Confirm that Activity.Current and IHttpActivityFeature activity are same + Assert.True(result); + } + + [Theory] + [InlineData("?a", "?a", false)] + [InlineData("?a=bdjdjh", "?a=Redacted", false)] + [InlineData("?a=b&", "?a=Redacted&", false)] + [InlineData("?c=b&", "?c=Redacted&", false)] + [InlineData("?c=a", "?c=Redacted", false)] + [InlineData("?a=b&c", "?a=Redacted&c", false)] + [InlineData("?a=b&c=1123456&", "?a=Redacted&c=Redacted&", false)] + [InlineData("?a=b&c=1&a1", "?a=Redacted&c=Redacted&a1", false)] + [InlineData("?a=ghgjgj&c=1deedd&a1=", "?a=Redacted&c=Redacted&a1=Redacted", false)] + [InlineData("?a=b&c=11&a1=&", "?a=Redacted&c=Redacted&a1=Redacted&", false)] + [InlineData("?c&c&c&", "?c&c&c&", false)] + [InlineData("?a&a&a&a", "?a&a&a&a", false)] + [InlineData("?&&&&&&&", "?&&&&&&&", false)] + [InlineData("?c", "?c", false)] + [InlineData("?a", "?a", true)] + [InlineData("?a=bdfdfdf", "?a=bdfdfdf", true)] + [InlineData("?a=b&", "?a=b&", true)] + [InlineData("?c=b&", "?c=b&", true)] + [InlineData("?c=a", "?c=a", true)] + [InlineData("?a=b&c", "?a=b&c", true)] + [InlineData("?a=b&c=111111&", "?a=b&c=111111&", true)] + [InlineData("?a=b&c=1&a1", "?a=b&c=1&a1", true)] + [InlineData("?a=b&c=1&a1=", "?a=b&c=1&a1=", true)] + [InlineData("?a=b123&c=11&a1=&", "?a=b123&c=11&a1=&", true)] + [InlineData("?c&c&c&", "?c&c&c&", true)] + [InlineData("?a&a&a&a", "?a&a&a&a", true)] + [InlineData("?&&&&&&&", "?&&&&&&&", true)] + [InlineData("?c", "?c", true)] + [InlineData("?c=%26&", "?c=Redacted&", false)] + public async Task ValidateUrlQueryRedaction(string urlQuery, string expectedUrlQuery, bool disableQueryRedaction) + { + var exportedItems = new List(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION"] = disableQueryRedaction.ToString() }) + .Build(); + + var path = "/api/values" + urlQuery; + + // Arrange + using var traceprovider = Sdk.CreateTracerProviderBuilder() + .ConfigureServices(services => services.AddSingleton(configuration)) + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems) + .Build(); + + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + try + { + using var response = await client.GetAsync(new Uri(path, UriKind.Relative)); + } + catch (Exception) + { + // ignore errors + } + + WaitForActivityExport(exportedItems, 1); + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + Assert.Equal(expectedUrlQuery, activity.GetTagValue(SemanticConventions.AttributeUrlQuery)); + } + + public void Dispose() + { + this.tracerProvider?.Dispose(); + } + + private static void WaitForActivityExport(List exportedItems, int count) + { + // 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 + Assert.True(SpinWait.SpinUntil( + () => + { + Thread.Sleep(10); + return exportedItems.Count >= count; + }, + TimeSpan.FromSeconds(1))); + } + + private static void ValidateAspNetCoreActivity(Activity activityToValidate, string expectedHttpPath) + { + Assert.Equal(ActivityKind.Server, activityToValidate.Kind); +#if NET7_0_OR_GREATER + Assert.Equal(HttpInListener.AspNetCoreActivitySourceName, activityToValidate.Source.Name); + Assert.Empty(activityToValidate.Source.Version); +#else + Assert.Equal(HttpInListener.ActivitySourceName, activityToValidate.Source.Name); + Assert.Equal(HttpInListener.Version.ToString(), activityToValidate.Source.Version); +#endif + Assert.Equal(expectedHttpPath, activityToValidate.GetTagValue(SemanticConventions.AttributeUrlPath) as string); + } + + private static void AssertException(List exportedItems) + { + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + var exMessage = "something's wrong!"; + Assert.Single(activity.Events); + Assert.Equal("System.Exception", activity.Events.First().Tags.FirstOrDefault(t => t.Key == SemanticConventions.AttributeExceptionType).Value); + Assert.Equal(exMessage, activity.Events.First().Tags.FirstOrDefault(t => t.Key == SemanticConventions.AttributeExceptionMessage).Value); + + ValidateAspNetCoreActivity(activity, "/api/error"); + } + + private void ConfigureExceptionFilters(IServiceCollection services, int mode, ref List exportedItems) + { + switch (mode) + { + case 1: + services.AddMvc(x => x.Filters.Add()); + break; + case 2: + services.AddMvc(x => x.Filters.Add()); + services.AddMvc(x => x.Filters.Add()); + break; + default: + break; + } + + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation(x => x.RecordException = true) + .AddInMemoryExporter(exportedItems) + .Build(); + } + + private class ExtractOnlyPropagator(ActivityContext activityContext, Baggage baggage) : TextMapPropagator + { + private readonly ActivityContext activityContext = activityContext; + private readonly Baggage baggage = baggage; + + public override ISet Fields => throw new NotImplementedException(); + + public override PropagationContext Extract(PropagationContext context, T carrier, Func> getter) + { + return new PropagationContext(this.activityContext, this.baggage); + } + + public override void Inject(PropagationContext context, T carrier, Action setter) + { + throw new NotImplementedException(); + } + } + + private class TestSampler(SamplingDecision samplingDecision, IEnumerable> attributes = null) : Sampler + { + private readonly SamplingDecision samplingDecision = samplingDecision; + private readonly IEnumerable> attributes = attributes; + + public override SamplingResult ShouldSample(in SamplingParameters samplingParameters) + { + return new SamplingResult(this.samplingDecision, this.attributes); + } + } + + private class TestHttpInListener(AspNetCoreTraceInstrumentationOptions options) : HttpInListener(options) + { + public Action OnEventWrittenCallback; + + public override void OnEventWritten(string name, object payload) + { + base.OnEventWritten(name, payload); + + this.OnEventWrittenCallback?.Invoke(name, payload); + } + } + + private class TestNullHostActivityMiddlewareImpl(string activitySourceName, string activityName) : TestActivityMiddleware + { + private readonly ActivitySource activitySource = new(activitySourceName); + private readonly string activityName = activityName; + private Activity activity; + + public override void PreProcess(HttpContext context) + { + // Setting the host activity i.e. activity started by asp.net core + // to null here will have no impact on middleware activity. + // This also means that asp.net core activity will not be found + // during OnEventWritten event. + Activity.Current = null; + this.activity = this.activitySource.StartActivity(this.activityName); + } + + public override void PostProcess(HttpContext context) + { + this.activity?.Stop(); + } + } + + private class TestTestActivityMiddleware(string activitySourceName, string activityName) : TestActivityMiddleware + { + private readonly ActivitySource activitySource = new(activitySourceName); + private readonly string activityName = activityName; + private Activity activity; + + public override void PreProcess(HttpContext context) + { + this.activity = this.activitySource.StartActivity(this.activityName); + } + + public override void PostProcess(HttpContext context) + { + this.activity?.Stop(); + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/DependencyInjectionConfigTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/DependencyInjectionConfigTests.cs new file mode 100644 index 0000000000..a877d060a3 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/DependencyInjectionConfigTests.cs @@ -0,0 +1,54 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Tests; + +[Collection("AspNetCore")] +public class DependencyInjectionConfigTests + : IClassFixture> +{ + private readonly WebApplicationFactory factory; + + public DependencyInjectionConfigTests(WebApplicationFactory factory) + { + this.factory = factory; + } + + [Theory] + [InlineData(null)] + [InlineData("CustomName")] + public void TestTracingOptionsDIConfig(string name) + { + name ??= Options.DefaultName; + + bool optionsPickedFromDI = false; + void ConfigureTestServices(IServiceCollection services) + { + services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation(name, configureAspNetCoreTraceInstrumentationOptions: null)); + + services.Configure(name, options => + { + optionsPickedFromDI = true; + }); + } + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + builder.ConfigureTestServices(ConfigureTestServices)) + .CreateClient()) + { + } + + Assert.True(optionsPickedFromDI); + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/EventSourceTest.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/EventSourceTest.cs new file mode 100644 index 0000000000..5bae1e25f4 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/EventSourceTest.cs @@ -0,0 +1,17 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Instrumentation.AspNetCore.Implementation; +using OpenTelemetry.Tests; +using Xunit; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Tests; + +public class EventSourceTest +{ + [Fact] + public void EventSourceTest_AspNetCoreInstrumentationEventSource() + { + EventSourceTestHelper.MethodsAreImplementedConsistentlyWithTheirAttributes(AspNetCoreInstrumentationEventSource.Log); + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests.cs new file mode 100644 index 0000000000..f6bc29a386 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests.cs @@ -0,0 +1,172 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Trace; +using TestApp.AspNetCore; +using Xunit; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Tests; + +[Collection("AspNetCore")] +public class IncomingRequestsCollectionsIsAccordingToTheSpecTests + : IClassFixture> +{ + private readonly WebApplicationFactory factory; + + public IncomingRequestsCollectionsIsAccordingToTheSpecTests(WebApplicationFactory factory) + { + this.factory = factory; + } + + [Theory] + [InlineData("/api/values", null, "user-agent", 200, null)] + [InlineData("/api/values", null, null, 200, null)] + [InlineData("/api/exception", null, null, 503, null)] + [InlineData("/api/exception", null, null, 503, null, true)] + public async Task SuccessfulTemplateControllerCallGeneratesASpan_New( + string urlPath, + string query, + string userAgent, + int statusCode, + string reasonPhrase, + bool recordException = false) + { + var exportedItems = new List(); + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices((IServiceCollection services) => + { + services.AddSingleton(new ExceptionTestCallbackMiddleware(statusCode, reasonPhrase)); + services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation(options => + { + options.RecordException = recordException; + }) + .AddInMemoryExporter(exportedItems)); + }); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + try + { + if (!string.IsNullOrEmpty(userAgent)) + { + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + } + + // Act + var path = urlPath; + if (query != null) + { + path += query; + } + + using var response = await client.GetAsync(new Uri(path, UriKind.Relative)); + } + catch (Exception) + { + // ignore errors + } + + for (var i = 0; i < 10; i++) + { + if (exportedItems.Count == 1) + { + break; + } + + // 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)); + } + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + Assert.Equal(ActivityKind.Server, activity.Kind); + Assert.Equal("localhost", activity.GetTagValue(SemanticConventions.AttributeServerAddress)); + Assert.Equal("GET", activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + Assert.Equal("1.1", activity.GetTagValue(SemanticConventions.AttributeNetworkProtocolVersion)); + Assert.Equal("http", activity.GetTagValue(SemanticConventions.AttributeUrlScheme)); + Assert.Equal(urlPath, activity.GetTagValue(SemanticConventions.AttributeUrlPath)); + Assert.Equal(query, activity.GetTagValue(SemanticConventions.AttributeUrlQuery)); + Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpResponseStatusCode)); + + if (statusCode == 503) + { + Assert.Equal(ActivityStatusCode.Error, activity.Status); + Assert.Equal("System.Exception", activity.GetTagValue(SemanticConventions.AttributeErrorType)); + } + else + { + Assert.Equal(ActivityStatusCode.Unset, activity.Status); + } + + // Instrumentation is not expected to set status description + // as the reason can be inferred from SemanticConventions.AttributeHttpStatusCode + Assert.Null(activity.StatusDescription); + + if (recordException) + { + Assert.Single(activity.Events); + Assert.Equal("exception", activity.Events.First().Name); + } + + ValidateTagValue(activity, SemanticConventions.AttributeUserAgentOriginal, userAgent); + + activity.Dispose(); + } + + private static void ValidateTagValue(Activity activity, string attribute, string expectedValue) + { + if (string.IsNullOrEmpty(expectedValue)) + { + Assert.Null(activity.GetTagValue(attribute)); + } + else + { + Assert.Equal(expectedValue, activity.GetTagValue(attribute)); + } + } + + internal class ExceptionTestCallbackMiddleware : TestCallbackMiddleware + { + private readonly int statusCode; + private readonly string reasonPhrase; + + public ExceptionTestCallbackMiddleware(int statusCode, string reasonPhrase) + { + this.statusCode = statusCode; + this.reasonPhrase = reasonPhrase; + } + + public override async Task ProcessAsync(HttpContext context) + { + context.Response.StatusCode = this.statusCode; + context.Response.HttpContext.Features.Get().ReasonPhrase = this.reasonPhrase; + await context.Response.WriteAsync("empty"); + + if (context.Request.Path.Value.EndsWith("exception", StringComparison.Ordinal)) + { + throw new Exception("exception description"); + } + + return false; + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs new file mode 100644 index 0000000000..21743e0faf --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs @@ -0,0 +1,418 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET8_0_OR_GREATER +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +#endif +using Microsoft.AspNetCore.Hosting; +#if NET8_0_OR_GREATER +using Microsoft.AspNetCore.Http; +#endif +using Microsoft.AspNetCore.Mvc.Testing; +#if NET8_0_OR_GREATER +using Microsoft.AspNetCore.RateLimiting; +#endif +#if NET8_0_OR_GREATER +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +#endif +using Microsoft.Extensions.Logging; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Tests; + +[Collection("AspNetCore")] +public class MetricTests(WebApplicationFactory factory) + : IClassFixture>, IDisposable +{ + private readonly WebApplicationFactory factory = factory; + private MeterProvider meterProvider; + + [Fact] + public void AddAspNetCoreInstrumentation_BadArgs() + { + MeterProviderBuilder builder = null; + Assert.Throws(builder.AddAspNetCoreInstrumentation); + } + +#if NET8_0_OR_GREATER + [Fact] + public async Task ValidateNet8MetricsAsync() + { + var exportedItems = new List(); + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems) + .Build(); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://*:0"); + var app = builder.Build(); + + app.MapGet("/", () => "Hello"); + + _ = app.RunAsync(); + + var url = app.Urls.ToArray()[0]; + var portNumber = url.Substring(url.LastIndexOf(':') + 1); + + using var client = new HttpClient(); + var res = await client.GetAsync(new Uri($"http://localhost:{portNumber}/")); + Assert.True(res.IsSuccessStatusCode); + + // We need to let metric 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 callbacks to complete + await Task.Delay(TimeSpan.FromSeconds(1)); + + this.meterProvider.Dispose(); + + var requestDurationMetric = exportedItems + .Count(item => item.Name == "http.server.request.duration"); + + var activeRequestsMetric = exportedItems. + Count(item => item.Name == "http.server.active_requests"); + + var routeMatchingMetric = exportedItems. + Count(item => item.Name == "aspnetcore.routing.match_attempts"); + + var kestrelActiveConnectionsMetric = exportedItems. + Count(item => item.Name == "kestrel.active_connections"); + + var kestrelQueuedConnectionMetric = exportedItems. + Count(item => item.Name == "kestrel.queued_connections"); + + Assert.Equal(1, requestDurationMetric); + Assert.Equal(1, activeRequestsMetric); + Assert.Equal(1, routeMatchingMetric); + Assert.Equal(1, kestrelActiveConnectionsMetric); + Assert.Equal(1, kestrelQueuedConnectionMetric); + + // TODO + // kestrel.queued_requests + // kestrel.upgraded_connections + // kestrel.rejected_connections + // kestrel.tls_handshake.duration + // kestrel.active_tls_handshakes + + await app.DisposeAsync(); + } + + [Fact] + public async Task ValidateNet8RateLimitingMetricsAsync() + { + var exportedItems = new List(); + + void ConfigureTestServices(IServiceCollection services) + { + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems) + .Build(); + + services.AddRateLimiter(_ => _ + .AddFixedWindowLimiter(policyName: "fixed", options => + { + options.PermitLimit = 4; + options.Window = TimeSpan.FromSeconds(12); + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + options.QueueLimit = 2; + })); + } + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://*:0"); + ConfigureTestServices(builder.Services); + + builder.Logging.ClearProviders(); + var app = builder.Build(); + + app.UseRateLimiter(); + + static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000"); + + app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}")) + .RequireRateLimiting("fixed"); + + _ = app.RunAsync(); + + var url = app.Urls.ToArray()[0]; + var portNumber = url.Substring(url.LastIndexOf(':') + 1); + + using var client = new HttpClient(); + var res = await client.GetAsync(new Uri($"http://localhost:{portNumber}/")); + Assert.NotNull(res); + + // We need to let metric 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 callbacks to complete + await Task.Delay(TimeSpan.FromSeconds(1)); + + this.meterProvider.Dispose(); + + var activeRequestLeasesMetric = exportedItems + .Where(item => item.Name == "aspnetcore.rate_limiting.active_request_leases") + .ToArray(); + + var requestLeaseDurationMetric = exportedItems. + Where(item => item.Name == "aspnetcore.rate_limiting.request_lease.duration") + .ToArray(); + + var limitingRequestsMetric = exportedItems. + Where(item => item.Name == "aspnetcore.rate_limiting.requests") + .ToArray(); + + Assert.Single(activeRequestLeasesMetric); + Assert.Single(requestLeaseDurationMetric); + Assert.Single(limitingRequestsMetric); + + // TODO + // aspnetcore.rate_limiting.request.time_in_queue + // aspnetcore.rate_limiting.queued_requests + + await app.DisposeAsync(); + } +#endif + + [Theory] + [InlineData("/api/values/2", "api/Values/{id}", null, 200)] + [InlineData("/api/Error", "api/Error", "System.Exception", 500)] + public async Task RequestMetricIsCaptured(string api, string expectedRoute, string expectedErrorType, int expectedStatusCode) + { + var metricItems = new List(); + + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(metricItems) + .Build(); + + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + try + { + using var response = await client.GetAsync(new Uri(api, UriKind.Relative)); + response.EnsureSuccessStatusCode(); + } + catch + { + // ignore error. + } + } + + // 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)); + + 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.Single(metricPoints); + + AssertMetricPoints( + metricPoints: metricPoints, + expectedRoutes: new List { expectedRoute }, + expectedErrorType, + expectedStatusCode, + expectedTagsCount: expectedErrorType == null ? 5 : 6); + } + + [Theory] + [InlineData("CONNECT", "CONNECT")] + [InlineData("DELETE", "DELETE")] + [InlineData("GET", "GET")] + [InlineData("PUT", "PUT")] + [InlineData("HEAD", "HEAD")] + [InlineData("OPTIONS", "OPTIONS")] + [InlineData("PATCH", "PATCH")] + [InlineData("Get", "GET")] + [InlineData("POST", "POST")] + [InlineData("TRACE", "TRACE")] + [InlineData("CUSTOM", "_OTHER")] + public async Task HttpRequestMethodIsCapturedAsPerSpec(string originalMethod, string expectedMethod) + { + var metricItems = new List(); + + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(metricItems) + .Build(); + + using var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient(); + + var message = new HttpRequestMessage(); + message.Method = new HttpMethod(originalMethod); + + try + { + using var response = await client.SendAsync(message); + } + catch + { + // ignore error. + } + + // 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)); + + 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.Single(metricPoints); + + var mp = metricPoints[0]; + + // Inspect Metric Attributes + var attributes = new Dictionary(); + foreach (var tag in mp.Tags) + { + attributes[tag.Key] = tag.Value; + } + + Assert.Contains(attributes, kvp => kvp.Key == SemanticConventions.AttributeHttpRequestMethod && kvp.Value.ToString() == expectedMethod); + + Assert.DoesNotContain(attributes, t => t.Key == SemanticConventions.AttributeHttpRequestMethodOriginal); + } + + public void Dispose() + { + this.meterProvider?.Dispose(); + GC.SuppressFinalize(this); + } + + private static List GetMetricPoints(Metric metric) + { + Assert.NotNull(metric); + Assert.True(metric.MetricType == MetricType.Histogram); + var metricPoints = new List(); + foreach (var p in metric.GetMetricPoints()) + { + metricPoints.Add(p); + } + + return metricPoints; + } + + private static void AssertMetricPoints( + List metricPoints, + List expectedRoutes, + string expectedErrorType, + int expectedStatusCode, + 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(metricPoint.Value, expectedStatusCode, expectedRoute, expectedErrorType, expectedTagsCount); + } + else + { + Assert.Fail($"A metric for route '{expectedRoute}' was not found"); + } + } + } + + private static void AssertMetricPoint( + MetricPoint metricPoint, + int expectedStatusCode, + string expectedRoute, + string expectedErrorType, + int expectedTagsCount) + { + 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.AttributeHttpRequestMethod, "GET"); + var scheme = new KeyValuePair(SemanticConventions.AttributeUrlScheme, "http"); + var statusCode = new KeyValuePair(SemanticConventions.AttributeHttpResponseStatusCode, expectedStatusCode); + 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); + + if (expectedErrorType != null) + { + var errorType = new KeyValuePair(SemanticConventions.AttributeErrorType, expectedErrorType); + + Assert.Contains(errorType, attributes); + } + + // Inspect Histogram Bounds + var histogramBuckets = metricPoint.GetHistogramBuckets(); + var histogramBounds = new List(); + foreach (var t in histogramBuckets) + { + histogramBounds.Add(t.ExplicitBound); + } + + // TODO: Remove the check for the older bounds once 1.7.0 is released. This is a temporary fix for instrumentation libraries CI workflow. + + var expectedHistogramBoundsOld = 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 }; + var expectedHistogramBoundsNew = new List { 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 }; + + var histogramBoundsMatchCorrectly = Enumerable.SequenceEqual(expectedHistogramBoundsOld, histogramBounds) || + Enumerable.SequenceEqual(expectedHistogramBoundsNew, histogramBounds); + + Assert.True(histogramBoundsMatchCorrectly); + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj new file mode 100644 index 0000000000..4371b4f6ab --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj @@ -0,0 +1,48 @@ + + + Unit test project for OpenTelemetry ASP.NET Core instrumentation + net8.0;net7.0;net6.0 + enable + + disable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RoutingTestCases.json + Always + + + diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.md b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.md new file mode 100644 index 0000000000..38ae9f93fd --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.md @@ -0,0 +1,204 @@ +# ASP.NET Core `http.route` tests + +This folder contains a test suite that validates the instrumentation produces +the expected `http.route` attribute on both the activity and metric it emits. +When available, the `http.route` is also a required component of the +`Activity.DisplayName`. + +The test suite covers a variety of different routing scenarios available for +ASP.NET Core: + +* [Conventional routing](https://learn.microsoft.com/aspnet/core/mvc/controllers/routing#conventional-routing) +* [Conventional routing using areas](https://learn.microsoft.com/aspnet/core/mvc/controllers/routing#areas) +* [Attribute routing](https://learn.microsoft.com/aspnet/core/mvc/controllers/routing#attribute-routing-for-rest-apis) +* [Razor pages](https://learn.microsoft.com/aspnet/core/razor-pages/razor-pages-conventions) +* [Minimal APIs](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/route-handlers) + +The individual test cases are defined in RoutingTestCases.json. + +The test suite is unique in that, when run, it generates README files for each +target framework which aids in documenting how the instrumentation behaves for +each test case. These files are source-controlled, so if the behavior of the +instrumentation changes, the README files will be updated to reflect the change. + +* [.NET 6](./README.net6.0.md) +* [.NET 7](./README.net7.0.md) +* [.NET 8](./README.net8.0.md) + +For each test case a request is made to an ASP.NET Core application with a +particular routing configuration. ASP.NET Core offers a +[variety of APIs](#aspnet-core-apis-for-retrieving-route-information) for +retrieving the route information of a given request. The README files include +detailed information documenting the route information available using the +various APIs in each test case. For example, here is the detailed result +generated for a test case: + +```json +{ + "IdealHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}", + "ActivityDisplayName": "/ConventionalRoute/ActionWithStringParameter/2", + "ActivityHttpRoute": "", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter/2?num=3", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "2" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +> [!NOTE] +> The test result currently includes an `IdealHttpRoute` property. This is +> temporary, and is meant to drive a conversation to determine the best way +> for generating the `http.route` attribute under different routing scenarios. +> In the example above, the path invoked is +> `/ConventionalRoute/ActionWithStringParameter/2?num=3`. Currently, we see +> that the `http.route` attribute on the metric emitted is +> `{controller=ConventionalRoute}/{action=Default}/{id?}` which was derived +> using `RoutePattern.RawText`. This is not ideal +> because the route template does not include the actual action that was +> invoked `ActionWithStringParameter`. The invoked action could be derived +> using either the `ControllerActionDescriptor` +> or `HttpContext.GetRouteData()`. + +## ASP.NET Core APIs for retrieving route information + +Included below are short snippets illustrating the use of the various +APIs available for retrieving route information. + +### Retrieving the route template + +The route template can be obtained from `HttpContext` by retrieving the +`RouteEndpoint` using the following two APIs. + +For attribute routing and minimal API scenarios, using the route template alone +is sufficient for deriving `http.route` in all test cases. + +The route template does not well describe the `http.route` in conventional +routing and some Razor page scenarios. + +#### [RoutePattern.RawText](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.routing.patterns.routepattern.rawtext) + +```csharp +(httpContext.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText; +``` + +#### [IRouteDiagnosticsMetadata.Route](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.metadata.iroutediagnosticsmetadata.route) + +This API was introduced in .NET 8. + +```csharp +httpContext.GetEndpoint()?.Metadata.GetMetadata()?.Route; +``` + +### RouteData + +`RouteData` can be retrieved from `HttpContext` using the `GetRouteData()` +extension method. The values obtained from `RouteData` identify the controller/ +action or Razor page invoked by the request. + +#### [HttpContext.GetRouteData()](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.routing.routinghttpcontextextensions.getroutedata) + +```csharp +foreach (var value in httpContext.GetRouteData().Values) +{ + Console.WriteLine($"{value.Key} = {value.Value?.ToString()}"); +} +``` + +For example, the above code produces something like: + +```text +controller = ConventionalRoute +action = ActionWithStringParameter +id = 2 +``` + +### Information from the ActionDescriptor + +For requests that invoke an action or Razor page, the `ActionDescriptor` can +be used to access route information. + +#### [AttributeRouteInfo.Template](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.mvc.routing.attributerouteinfo.template) + +The `AttributeRouteInfo.Template` is equivalent to using +[other APIs for retrieving the route template](#retrieving-the-route-template) +when using attribute routing. For conventional routing and Razor pages it will +be `null`. + +```csharp +actionDescriptor.AttributeRouteInfo?.Template; +``` + +#### [ControllerActionDescriptor](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.mvc.controllers.controlleractiondescriptor) + +For requests that invoke an action on a controller, the `ActionDescriptor` +will be of type `ControllerActionDescriptor` which includes the controller and +action name. + +```csharp +(actionDescriptor as ControllerActionDescriptor)?.ControllerName; +(actionDescriptor as ControllerActionDescriptor)?.ActionName; +``` + +#### [PageActionDescriptor](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.mvc.razorpages.pageactiondescriptor) + +For requests that invoke a Razor page, the `ActionDescriptor` +will be of type `PageActionDescriptor` which includes the path to the invoked +page. + +```csharp +(actionDescriptor as PageActionDescriptor)?.RelativePath; +(actionDescriptor as PageActionDescriptor)?.ViewEnginePath; +``` + +#### [Parameters](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.mvc.abstractions.actiondescriptor.parameters#microsoft-aspnetcore-mvc-abstractions-actiondescriptor-parameters) + +The `ActionDescriptor.Parameters` property is interesting because it describes +the actual parameters (type and name) of an invoked action method. Some APM +products use `ActionDescriptor.Parameters` to more precisely describe the +method an endpoint invokes since not all parameters may be present in the +route template. + +Consider the following action method: + +```csharp +public IActionResult SomeActionMethod(string id, int num) { ... } +``` + +Using conventional routing assuming a default route template +`{controller=ConventionalRoute}/{action=Default}/{id?}`, the `SomeActionMethod` +may match this route template. The route template describes the `id` parameter +but not the `num` parameter. + +```csharp +foreach (var parameter in actionDescriptor.Parameters) +{ + Console.WriteLine($"{parameter.Name}"); +} +``` + +The above code produces: + +```text +id +num +``` diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net6.0.md b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net6.0.md new file mode 100644 index 0000000000..6582c75715 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net6.0.md @@ -0,0 +1,612 @@ +# Test results for ASP.NET Core 6 + +| http.route | App | Test Name | +| - | - | - | +| :broken_heart: | ConventionalRouting | [Root path](#conventionalrouting-root-path) | +| :broken_heart: | ConventionalRouting | [Non-default action with route parameter and query string](#conventionalrouting-non-default-action-with-route-parameter-and-query-string) | +| :broken_heart: | ConventionalRouting | [Non-default action with query string](#conventionalrouting-non-default-action-with-query-string) | +| :green_heart: | ConventionalRouting | [Not Found (404)](#conventionalrouting-not-found-404) | +| :green_heart: | ConventionalRouting | [Route template with parameter constraint](#conventionalrouting-route-template-with-parameter-constraint) | +| :green_heart: | ConventionalRouting | [Path that does not match parameter constraint](#conventionalrouting-path-that-does-not-match-parameter-constraint) | +| :broken_heart: | ConventionalRouting | [Area using `area:exists`, default controller/action](#conventionalrouting-area-using-areaexists-default-controlleraction) | +| :broken_heart: | ConventionalRouting | [Area using `area:exists`, non-default action](#conventionalrouting-area-using-areaexists-non-default-action) | +| :broken_heart: | ConventionalRouting | [Area w/o `area:exists`, default controller/action](#conventionalrouting-area-wo-areaexists-default-controlleraction) | +| :green_heart: | AttributeRouting | [Default action](#attributerouting-default-action) | +| :green_heart: | AttributeRouting | [Action without parameter](#attributerouting-action-without-parameter) | +| :green_heart: | AttributeRouting | [Action with parameter](#attributerouting-action-with-parameter) | +| :green_heart: | AttributeRouting | [Action with parameter before action name in template](#attributerouting-action-with-parameter-before-action-name-in-template) | +| :green_heart: | AttributeRouting | [Action invoked resulting in 400 Bad Request](#attributerouting-action-invoked-resulting-in-400-bad-request) | +| :broken_heart: | RazorPages | [Root path](#razorpages-root-path) | +| :broken_heart: | RazorPages | [Index page](#razorpages-index-page) | +| :broken_heart: | RazorPages | [Throws exception](#razorpages-throws-exception) | +| :green_heart: | RazorPages | [Static content](#razorpages-static-content) | +| :green_heart: | MinimalApi | [Action without parameter](#minimalapi-action-without-parameter) | +| :green_heart: | MinimalApi | [Action with parameter](#minimalapi-action-with-parameter) | +| :green_heart: | ExceptionMiddleware | [Exception Handled by Exception Handler Middleware](#exceptionmiddleware-exception-handled-by-exception-handler-middleware) | + +## ConventionalRouting: Root path + +```json +{ + "IdealHttpRoute": "ConventionalRoute/Default/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "Default" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "Default" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Non-default action with route parameter and query string + +```json +{ + "IdealHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter/2?num=3", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "2" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Non-default action with query string + +```json +{ + "IdealHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter?num=3", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Not Found (404) + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/NotFound", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## ConventionalRouting: Route template with parameter constraint + +```json +{ + "IdealHttpRoute": "SomePath/{id}/{num:int}", + "ActivityDisplayName": "GET SomePath/{id}/{num:int}", + "ActivityHttpRoute": "SomePath/{id}/{num:int}", + "MetricHttpRoute": "SomePath/{id}/{num:int}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePath/SomeString/2", + "RoutePattern.RawText": "SomePath/{id}/{num:int}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "SomeString", + "num": "2" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Path that does not match parameter constraint + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePath/SomeString/NotAnInt", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## ConventionalRouting: Area using `area:exists`, default controller/action + +```json +{ + "IdealHttpRoute": "{area:exists}/ControllerForMyArea/Default/{id?}", + "ActivityDisplayName": "GET {area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "ActivityHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "MetricHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MyArea", + "RoutePattern.RawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ControllerForMyArea", + "action": "Default", + "area": "MyArea" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ControllerForMyArea", + "ActionName": "Default" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Area using `area:exists`, non-default action + +```json +{ + "IdealHttpRoute": "{area:exists}/ControllerForMyArea/NonDefault/{id?}", + "ActivityDisplayName": "GET {area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "ActivityHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "MetricHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MyArea/ControllerForMyArea/NonDefault", + "RoutePattern.RawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ControllerForMyArea", + "area": "MyArea", + "action": "NonDefault" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ControllerForMyArea", + "ActionName": "NonDefault" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Area w/o `area:exists`, default controller/action + +```json +{ + "IdealHttpRoute": "SomePrefix/AnotherArea/Index/{id?}", + "ActivityDisplayName": "GET SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "ActivityHttpRoute": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "MetricHttpRoute": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePrefix", + "RoutePattern.RawText": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "area": "AnotherArea", + "controller": "AnotherArea", + "action": "Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AnotherArea", + "ActionName": "Index" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Default action + +```json +{ + "IdealHttpRoute": "AttributeRoute", + "ActivityDisplayName": "GET AttributeRoute", + "ActivityHttpRoute": "AttributeRoute", + "MetricHttpRoute": "AttributeRoute", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute", + "RoutePattern.RawText": "AttributeRoute", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute", + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action without parameter + +```json +{ + "IdealHttpRoute": "AttributeRoute/Get", + "ActivityDisplayName": "GET AttributeRoute/Get", + "ActivityHttpRoute": "AttributeRoute/Get", + "MetricHttpRoute": "AttributeRoute/Get", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/Get", + "RoutePattern.RawText": "AttributeRoute/Get", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/Get", + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action with parameter + +```json +{ + "IdealHttpRoute": "AttributeRoute/Get/{id}", + "ActivityDisplayName": "GET AttributeRoute/Get/{id}", + "ActivityHttpRoute": "AttributeRoute/Get/{id}", + "MetricHttpRoute": "AttributeRoute/Get/{id}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/Get/12", + "RoutePattern.RawText": "AttributeRoute/Get/{id}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute", + "id": "12" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/Get/{id}", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action with parameter before action name in template + +```json +{ + "IdealHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityDisplayName": "GET AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "MetricHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/12/GetWithActionNameInDifferentSpotInTemplate", + "RoutePattern.RawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "GetWithActionNameInDifferentSpotInTemplate", + "controller": "AttributeRoute", + "id": "12" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "GetWithActionNameInDifferentSpotInTemplate" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action invoked resulting in 400 Bad Request + +```json +{ + "IdealHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityDisplayName": "GET AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "MetricHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/NotAnInt/GetWithActionNameInDifferentSpotInTemplate", + "RoutePattern.RawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "GetWithActionNameInDifferentSpotInTemplate", + "controller": "AttributeRoute", + "id": "NotAnInt" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "GetWithActionNameInDifferentSpotInTemplate" + }, + "PageActionDescriptor": null + } + } +} +``` + +## RazorPages: Root path + +```json +{ + "IdealHttpRoute": "/Index", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/", + "RoutePattern.RawText": "", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "page": "/Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/Index.cshtml", + "ViewEnginePath": "/Index" + } + } + } +} +``` + +## RazorPages: Index page + +```json +{ + "IdealHttpRoute": "/Index", + "ActivityDisplayName": "GET Index", + "ActivityHttpRoute": "Index", + "MetricHttpRoute": "Index", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/Index", + "RoutePattern.RawText": "Index", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "page": "/Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "Index", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/Index.cshtml", + "ViewEnginePath": "/Index" + } + } + } +} +``` + +## RazorPages: Throws exception + +```json +{ + "IdealHttpRoute": "/PageThatThrowsException", + "ActivityDisplayName": "GET PageThatThrowsException", + "ActivityHttpRoute": "PageThatThrowsException", + "MetricHttpRoute": "PageThatThrowsException", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/PageThatThrowsException", + "RoutePattern.RawText": "PageThatThrowsException", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "page": "/PageThatThrowsException" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "PageThatThrowsException", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/PageThatThrowsException.cshtml", + "ViewEnginePath": "/PageThatThrowsException" + } + } + } +} +``` + +## RazorPages: Static content + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/js/site.js", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action without parameter + +```json +{ + "IdealHttpRoute": "/MinimalApi", + "ActivityDisplayName": "GET /MinimalApi", + "ActivityHttpRoute": "/MinimalApi", + "MetricHttpRoute": "/MinimalApi", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApi", + "RoutePattern.RawText": "/MinimalApi", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action with parameter + +```json +{ + "IdealHttpRoute": "/MinimalApi/{id}", + "ActivityDisplayName": "GET /MinimalApi/{id}", + "ActivityHttpRoute": "/MinimalApi/{id}", + "MetricHttpRoute": "/MinimalApi/{id}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApi/123", + "RoutePattern.RawText": "/MinimalApi/{id}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "id": "123" + }, + "ActionDescriptor": null + } +} +``` + +## ExceptionMiddleware: Exception Handled by Exception Handler Middleware + +```json +{ + "IdealHttpRoute": "/Exception", + "ActivityDisplayName": "GET /Exception", + "ActivityHttpRoute": "/Exception", + "MetricHttpRoute": "/Exception", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/Exception", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net7.0.md b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net7.0.md new file mode 100644 index 0000000000..49d8224155 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net7.0.md @@ -0,0 +1,654 @@ +# Test results for ASP.NET Core 7 + +| http.route | App | Test Name | +| - | - | - | +| :broken_heart: | ConventionalRouting | [Root path](#conventionalrouting-root-path) | +| :broken_heart: | ConventionalRouting | [Non-default action with route parameter and query string](#conventionalrouting-non-default-action-with-route-parameter-and-query-string) | +| :broken_heart: | ConventionalRouting | [Non-default action with query string](#conventionalrouting-non-default-action-with-query-string) | +| :green_heart: | ConventionalRouting | [Not Found (404)](#conventionalrouting-not-found-404) | +| :green_heart: | ConventionalRouting | [Route template with parameter constraint](#conventionalrouting-route-template-with-parameter-constraint) | +| :green_heart: | ConventionalRouting | [Path that does not match parameter constraint](#conventionalrouting-path-that-does-not-match-parameter-constraint) | +| :broken_heart: | ConventionalRouting | [Area using `area:exists`, default controller/action](#conventionalrouting-area-using-areaexists-default-controlleraction) | +| :broken_heart: | ConventionalRouting | [Area using `area:exists`, non-default action](#conventionalrouting-area-using-areaexists-non-default-action) | +| :broken_heart: | ConventionalRouting | [Area w/o `area:exists`, default controller/action](#conventionalrouting-area-wo-areaexists-default-controlleraction) | +| :green_heart: | AttributeRouting | [Default action](#attributerouting-default-action) | +| :green_heart: | AttributeRouting | [Action without parameter](#attributerouting-action-without-parameter) | +| :green_heart: | AttributeRouting | [Action with parameter](#attributerouting-action-with-parameter) | +| :green_heart: | AttributeRouting | [Action with parameter before action name in template](#attributerouting-action-with-parameter-before-action-name-in-template) | +| :green_heart: | AttributeRouting | [Action invoked resulting in 400 Bad Request](#attributerouting-action-invoked-resulting-in-400-bad-request) | +| :broken_heart: | RazorPages | [Root path](#razorpages-root-path) | +| :broken_heart: | RazorPages | [Index page](#razorpages-index-page) | +| :broken_heart: | RazorPages | [Throws exception](#razorpages-throws-exception) | +| :green_heart: | RazorPages | [Static content](#razorpages-static-content) | +| :green_heart: | MinimalApi | [Action without parameter](#minimalapi-action-without-parameter) | +| :green_heart: | MinimalApi | [Action with parameter](#minimalapi-action-with-parameter) | +| :green_heart: | MinimalApi | [Action without parameter (MapGroup)](#minimalapi-action-without-parameter-mapgroup) | +| :green_heart: | MinimalApi | [Action with parameter (MapGroup)](#minimalapi-action-with-parameter-mapgroup) | +| :green_heart: | ExceptionMiddleware | [Exception Handled by Exception Handler Middleware](#exceptionmiddleware-exception-handled-by-exception-handler-middleware) | + +## ConventionalRouting: Root path + +```json +{ + "IdealHttpRoute": "ConventionalRoute/Default/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "Default" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "Default" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Non-default action with route parameter and query string + +```json +{ + "IdealHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter/2?num=3", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "2" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Non-default action with query string + +```json +{ + "IdealHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter?num=3", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Not Found (404) + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/NotFound", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## ConventionalRouting: Route template with parameter constraint + +```json +{ + "IdealHttpRoute": "SomePath/{id}/{num:int}", + "ActivityDisplayName": "GET SomePath/{id}/{num:int}", + "ActivityHttpRoute": "SomePath/{id}/{num:int}", + "MetricHttpRoute": "SomePath/{id}/{num:int}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePath/SomeString/2", + "RoutePattern.RawText": "SomePath/{id}/{num:int}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "SomeString", + "num": "2" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Path that does not match parameter constraint + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePath/SomeString/NotAnInt", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## ConventionalRouting: Area using `area:exists`, default controller/action + +```json +{ + "IdealHttpRoute": "{area:exists}/ControllerForMyArea/Default/{id?}", + "ActivityDisplayName": "GET {area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "ActivityHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "MetricHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MyArea", + "RoutePattern.RawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ControllerForMyArea", + "action": "Default", + "area": "MyArea" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ControllerForMyArea", + "ActionName": "Default" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Area using `area:exists`, non-default action + +```json +{ + "IdealHttpRoute": "{area:exists}/ControllerForMyArea/NonDefault/{id?}", + "ActivityDisplayName": "GET {area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "ActivityHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "MetricHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MyArea/ControllerForMyArea/NonDefault", + "RoutePattern.RawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "controller": "ControllerForMyArea", + "area": "MyArea", + "action": "NonDefault" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ControllerForMyArea", + "ActionName": "NonDefault" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Area w/o `area:exists`, default controller/action + +```json +{ + "IdealHttpRoute": "SomePrefix/AnotherArea/Index/{id?}", + "ActivityDisplayName": "GET SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "ActivityHttpRoute": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "MetricHttpRoute": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePrefix", + "RoutePattern.RawText": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "area": "AnotherArea", + "controller": "AnotherArea", + "action": "Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AnotherArea", + "ActionName": "Index" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Default action + +```json +{ + "IdealHttpRoute": "AttributeRoute", + "ActivityDisplayName": "GET AttributeRoute", + "ActivityHttpRoute": "AttributeRoute", + "MetricHttpRoute": "AttributeRoute", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute", + "RoutePattern.RawText": "AttributeRoute", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute", + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action without parameter + +```json +{ + "IdealHttpRoute": "AttributeRoute/Get", + "ActivityDisplayName": "GET AttributeRoute/Get", + "ActivityHttpRoute": "AttributeRoute/Get", + "MetricHttpRoute": "AttributeRoute/Get", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/Get", + "RoutePattern.RawText": "AttributeRoute/Get", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/Get", + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action with parameter + +```json +{ + "IdealHttpRoute": "AttributeRoute/Get/{id}", + "ActivityDisplayName": "GET AttributeRoute/Get/{id}", + "ActivityHttpRoute": "AttributeRoute/Get/{id}", + "MetricHttpRoute": "AttributeRoute/Get/{id}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/Get/12", + "RoutePattern.RawText": "AttributeRoute/Get/{id}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute", + "id": "12" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/Get/{id}", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action with parameter before action name in template + +```json +{ + "IdealHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityDisplayName": "GET AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "MetricHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/12/GetWithActionNameInDifferentSpotInTemplate", + "RoutePattern.RawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "GetWithActionNameInDifferentSpotInTemplate", + "controller": "AttributeRoute", + "id": "12" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "GetWithActionNameInDifferentSpotInTemplate" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action invoked resulting in 400 Bad Request + +```json +{ + "IdealHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityDisplayName": "GET AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "MetricHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/NotAnInt/GetWithActionNameInDifferentSpotInTemplate", + "RoutePattern.RawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "action": "GetWithActionNameInDifferentSpotInTemplate", + "controller": "AttributeRoute", + "id": "NotAnInt" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "GetWithActionNameInDifferentSpotInTemplate" + }, + "PageActionDescriptor": null + } + } +} +``` + +## RazorPages: Root path + +```json +{ + "IdealHttpRoute": "/Index", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/", + "RoutePattern.RawText": "", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "page": "/Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/Index.cshtml", + "ViewEnginePath": "/Index" + } + } + } +} +``` + +## RazorPages: Index page + +```json +{ + "IdealHttpRoute": "/Index", + "ActivityDisplayName": "GET Index", + "ActivityHttpRoute": "Index", + "MetricHttpRoute": "Index", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/Index", + "RoutePattern.RawText": "Index", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "page": "/Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "Index", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/Index.cshtml", + "ViewEnginePath": "/Index" + } + } + } +} +``` + +## RazorPages: Throws exception + +```json +{ + "IdealHttpRoute": "/PageThatThrowsException", + "ActivityDisplayName": "GET PageThatThrowsException", + "ActivityHttpRoute": "PageThatThrowsException", + "MetricHttpRoute": "PageThatThrowsException", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/PageThatThrowsException", + "RoutePattern.RawText": "PageThatThrowsException", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "page": "/PageThatThrowsException" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "PageThatThrowsException", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/PageThatThrowsException.cshtml", + "ViewEnginePath": "/PageThatThrowsException" + } + } + } +} +``` + +## RazorPages: Static content + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/js/site.js", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action without parameter + +```json +{ + "IdealHttpRoute": "/MinimalApi", + "ActivityDisplayName": "GET /MinimalApi", + "ActivityHttpRoute": "/MinimalApi", + "MetricHttpRoute": "/MinimalApi", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApi", + "RoutePattern.RawText": "/MinimalApi", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action with parameter + +```json +{ + "IdealHttpRoute": "/MinimalApi/{id}", + "ActivityDisplayName": "GET /MinimalApi/{id}", + "ActivityHttpRoute": "/MinimalApi/{id}", + "MetricHttpRoute": "/MinimalApi/{id}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApi/123", + "RoutePattern.RawText": "/MinimalApi/{id}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "id": "123" + }, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action without parameter (MapGroup) + +```json +{ + "IdealHttpRoute": "/MinimalApiUsingMapGroup/", + "ActivityDisplayName": "GET /MinimalApiUsingMapGroup/", + "ActivityHttpRoute": "/MinimalApiUsingMapGroup/", + "MetricHttpRoute": "/MinimalApiUsingMapGroup/", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApiUsingMapGroup", + "RoutePattern.RawText": "/MinimalApiUsingMapGroup/", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action with parameter (MapGroup) + +```json +{ + "IdealHttpRoute": "/MinimalApiUsingMapGroup/{id}", + "ActivityDisplayName": "GET /MinimalApiUsingMapGroup/{id}", + "ActivityHttpRoute": "/MinimalApiUsingMapGroup/{id}", + "MetricHttpRoute": "/MinimalApiUsingMapGroup/{id}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApiUsingMapGroup/123", + "RoutePattern.RawText": "/MinimalApiUsingMapGroup/{id}", + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": { + "id": "123" + }, + "ActionDescriptor": null + } +} +``` + +## ExceptionMiddleware: Exception Handled by Exception Handler Middleware + +```json +{ + "IdealHttpRoute": "/Exception", + "ActivityDisplayName": "GET /Exception", + "ActivityHttpRoute": "/Exception", + "MetricHttpRoute": "/Exception", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/Exception", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net8.0.md b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net8.0.md new file mode 100644 index 0000000000..40b63a1ca4 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.net8.0.md @@ -0,0 +1,654 @@ +# Test results for ASP.NET Core 8 + +| http.route | App | Test Name | +| - | - | - | +| :broken_heart: | ConventionalRouting | [Root path](#conventionalrouting-root-path) | +| :broken_heart: | ConventionalRouting | [Non-default action with route parameter and query string](#conventionalrouting-non-default-action-with-route-parameter-and-query-string) | +| :broken_heart: | ConventionalRouting | [Non-default action with query string](#conventionalrouting-non-default-action-with-query-string) | +| :green_heart: | ConventionalRouting | [Not Found (404)](#conventionalrouting-not-found-404) | +| :green_heart: | ConventionalRouting | [Route template with parameter constraint](#conventionalrouting-route-template-with-parameter-constraint) | +| :green_heart: | ConventionalRouting | [Path that does not match parameter constraint](#conventionalrouting-path-that-does-not-match-parameter-constraint) | +| :broken_heart: | ConventionalRouting | [Area using `area:exists`, default controller/action](#conventionalrouting-area-using-areaexists-default-controlleraction) | +| :broken_heart: | ConventionalRouting | [Area using `area:exists`, non-default action](#conventionalrouting-area-using-areaexists-non-default-action) | +| :broken_heart: | ConventionalRouting | [Area w/o `area:exists`, default controller/action](#conventionalrouting-area-wo-areaexists-default-controlleraction) | +| :green_heart: | AttributeRouting | [Default action](#attributerouting-default-action) | +| :green_heart: | AttributeRouting | [Action without parameter](#attributerouting-action-without-parameter) | +| :green_heart: | AttributeRouting | [Action with parameter](#attributerouting-action-with-parameter) | +| :green_heart: | AttributeRouting | [Action with parameter before action name in template](#attributerouting-action-with-parameter-before-action-name-in-template) | +| :green_heart: | AttributeRouting | [Action invoked resulting in 400 Bad Request](#attributerouting-action-invoked-resulting-in-400-bad-request) | +| :broken_heart: | RazorPages | [Root path](#razorpages-root-path) | +| :broken_heart: | RazorPages | [Index page](#razorpages-index-page) | +| :broken_heart: | RazorPages | [Throws exception](#razorpages-throws-exception) | +| :green_heart: | RazorPages | [Static content](#razorpages-static-content) | +| :green_heart: | MinimalApi | [Action without parameter](#minimalapi-action-without-parameter) | +| :green_heart: | MinimalApi | [Action with parameter](#minimalapi-action-with-parameter) | +| :green_heart: | MinimalApi | [Action without parameter (MapGroup)](#minimalapi-action-without-parameter-mapgroup) | +| :green_heart: | MinimalApi | [Action with parameter (MapGroup)](#minimalapi-action-with-parameter-mapgroup) | +| :green_heart: | ExceptionMiddleware | [Exception Handled by Exception Handler Middleware](#exceptionmiddleware-exception-handled-by-exception-handler-middleware) | + +## ConventionalRouting: Root path + +```json +{ + "IdealHttpRoute": "ConventionalRoute/Default/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "Default" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "Default" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Non-default action with route parameter and query string + +```json +{ + "IdealHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter/2?num=3", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "2" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Non-default action with query string + +```json +{ + "IdealHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}", + "ActivityDisplayName": "GET {controller=ConventionalRoute}/{action=Default}/{id?}", + "ActivityHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "MetricHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter?num=3", + "RoutePattern.RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Not Found (404) + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/ConventionalRoute/NotFound", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## ConventionalRouting: Route template with parameter constraint + +```json +{ + "IdealHttpRoute": "SomePath/{id}/{num:int}", + "ActivityDisplayName": "GET SomePath/{id}/{num:int}", + "ActivityHttpRoute": "SomePath/{id}/{num:int}", + "MetricHttpRoute": "SomePath/{id}/{num:int}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePath/SomeString/2", + "RoutePattern.RawText": "SomePath/{id}/{num:int}", + "IRouteDiagnosticsMetadata.Route": "SomePath/{id}/{num:int}", + "HttpContext.GetRouteData()": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "SomeString", + "num": "2" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [ + "id", + "num" + ], + "ControllerActionDescriptor": { + "ControllerName": "ConventionalRoute", + "ActionName": "ActionWithStringParameter" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Path that does not match parameter constraint + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePath/SomeString/NotAnInt", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## ConventionalRouting: Area using `area:exists`, default controller/action + +```json +{ + "IdealHttpRoute": "{area:exists}/ControllerForMyArea/Default/{id?}", + "ActivityDisplayName": "GET {area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "ActivityHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "MetricHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MyArea", + "RoutePattern.RawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "HttpContext.GetRouteData()": { + "controller": "ControllerForMyArea", + "action": "Default", + "area": "MyArea" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ControllerForMyArea", + "ActionName": "Default" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Area using `area:exists`, non-default action + +```json +{ + "IdealHttpRoute": "{area:exists}/ControllerForMyArea/NonDefault/{id?}", + "ActivityDisplayName": "GET {area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "ActivityHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "MetricHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MyArea/ControllerForMyArea/NonDefault", + "RoutePattern.RawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "IRouteDiagnosticsMetadata.Route": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "HttpContext.GetRouteData()": { + "controller": "ControllerForMyArea", + "area": "MyArea", + "action": "NonDefault" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "ControllerForMyArea", + "ActionName": "NonDefault" + }, + "PageActionDescriptor": null + } + } +} +``` + +## ConventionalRouting: Area w/o `area:exists`, default controller/action + +```json +{ + "IdealHttpRoute": "SomePrefix/AnotherArea/Index/{id?}", + "ActivityDisplayName": "GET SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "ActivityHttpRoute": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "MetricHttpRoute": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/SomePrefix", + "RoutePattern.RawText": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "IRouteDiagnosticsMetadata.Route": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "HttpContext.GetRouteData()": { + "area": "AnotherArea", + "controller": "AnotherArea", + "action": "Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": null, + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AnotherArea", + "ActionName": "Index" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Default action + +```json +{ + "IdealHttpRoute": "AttributeRoute", + "ActivityDisplayName": "GET AttributeRoute", + "ActivityHttpRoute": "AttributeRoute", + "MetricHttpRoute": "AttributeRoute", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute", + "RoutePattern.RawText": "AttributeRoute", + "IRouteDiagnosticsMetadata.Route": "AttributeRoute", + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute", + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action without parameter + +```json +{ + "IdealHttpRoute": "AttributeRoute/Get", + "ActivityDisplayName": "GET AttributeRoute/Get", + "ActivityHttpRoute": "AttributeRoute/Get", + "MetricHttpRoute": "AttributeRoute/Get", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/Get", + "RoutePattern.RawText": "AttributeRoute/Get", + "IRouteDiagnosticsMetadata.Route": "AttributeRoute/Get", + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/Get", + "Parameters": [], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action with parameter + +```json +{ + "IdealHttpRoute": "AttributeRoute/Get/{id}", + "ActivityDisplayName": "GET AttributeRoute/Get/{id}", + "ActivityHttpRoute": "AttributeRoute/Get/{id}", + "MetricHttpRoute": "AttributeRoute/Get/{id}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/Get/12", + "RoutePattern.RawText": "AttributeRoute/Get/{id}", + "IRouteDiagnosticsMetadata.Route": "AttributeRoute/Get/{id}", + "HttpContext.GetRouteData()": { + "action": "Get", + "controller": "AttributeRoute", + "id": "12" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/Get/{id}", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "Get" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action with parameter before action name in template + +```json +{ + "IdealHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityDisplayName": "GET AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "MetricHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/12/GetWithActionNameInDifferentSpotInTemplate", + "RoutePattern.RawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "IRouteDiagnosticsMetadata.Route": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "HttpContext.GetRouteData()": { + "action": "GetWithActionNameInDifferentSpotInTemplate", + "controller": "AttributeRoute", + "id": "12" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "GetWithActionNameInDifferentSpotInTemplate" + }, + "PageActionDescriptor": null + } + } +} +``` + +## AttributeRouting: Action invoked resulting in 400 Bad Request + +```json +{ + "IdealHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityDisplayName": "GET AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActivityHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "MetricHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/AttributeRoute/NotAnInt/GetWithActionNameInDifferentSpotInTemplate", + "RoutePattern.RawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "IRouteDiagnosticsMetadata.Route": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "HttpContext.GetRouteData()": { + "action": "GetWithActionNameInDifferentSpotInTemplate", + "controller": "AttributeRoute", + "id": "NotAnInt" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "Parameters": [ + "id" + ], + "ControllerActionDescriptor": { + "ControllerName": "AttributeRoute", + "ActionName": "GetWithActionNameInDifferentSpotInTemplate" + }, + "PageActionDescriptor": null + } + } +} +``` + +## RazorPages: Root path + +```json +{ + "IdealHttpRoute": "/Index", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/", + "RoutePattern.RawText": "", + "IRouteDiagnosticsMetadata.Route": "", + "HttpContext.GetRouteData()": { + "page": "/Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/Index.cshtml", + "ViewEnginePath": "/Index" + } + } + } +} +``` + +## RazorPages: Index page + +```json +{ + "IdealHttpRoute": "/Index", + "ActivityDisplayName": "GET Index", + "ActivityHttpRoute": "Index", + "MetricHttpRoute": "Index", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/Index", + "RoutePattern.RawText": "Index", + "IRouteDiagnosticsMetadata.Route": "Index", + "HttpContext.GetRouteData()": { + "page": "/Index" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "Index", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/Index.cshtml", + "ViewEnginePath": "/Index" + } + } + } +} +``` + +## RazorPages: Throws exception + +```json +{ + "IdealHttpRoute": "/PageThatThrowsException", + "ActivityDisplayName": "GET PageThatThrowsException", + "ActivityHttpRoute": "PageThatThrowsException", + "MetricHttpRoute": "PageThatThrowsException", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/PageThatThrowsException", + "RoutePattern.RawText": "PageThatThrowsException", + "IRouteDiagnosticsMetadata.Route": "PageThatThrowsException", + "HttpContext.GetRouteData()": { + "page": "/PageThatThrowsException" + }, + "ActionDescriptor": { + "AttributeRouteInfo.Template": "PageThatThrowsException", + "Parameters": [], + "ControllerActionDescriptor": null, + "PageActionDescriptor": { + "RelativePath": "/Pages/PageThatThrowsException.cshtml", + "ViewEnginePath": "/PageThatThrowsException" + } + } + } +} +``` + +## RazorPages: Static content + +```json +{ + "IdealHttpRoute": "", + "ActivityDisplayName": "GET", + "ActivityHttpRoute": "", + "MetricHttpRoute": "", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/js/site.js", + "RoutePattern.RawText": null, + "IRouteDiagnosticsMetadata.Route": null, + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action without parameter + +```json +{ + "IdealHttpRoute": "/MinimalApi", + "ActivityDisplayName": "GET /MinimalApi", + "ActivityHttpRoute": "/MinimalApi", + "MetricHttpRoute": "/MinimalApi", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApi", + "RoutePattern.RawText": "/MinimalApi", + "IRouteDiagnosticsMetadata.Route": "/MinimalApi", + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action with parameter + +```json +{ + "IdealHttpRoute": "/MinimalApi/{id}", + "ActivityDisplayName": "GET /MinimalApi/{id}", + "ActivityHttpRoute": "/MinimalApi/{id}", + "MetricHttpRoute": "/MinimalApi/{id}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApi/123", + "RoutePattern.RawText": "/MinimalApi/{id}", + "IRouteDiagnosticsMetadata.Route": "/MinimalApi/{id}", + "HttpContext.GetRouteData()": { + "id": "123" + }, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action without parameter (MapGroup) + +```json +{ + "IdealHttpRoute": "/MinimalApiUsingMapGroup/", + "ActivityDisplayName": "GET /MinimalApiUsingMapGroup/", + "ActivityHttpRoute": "/MinimalApiUsingMapGroup/", + "MetricHttpRoute": "/MinimalApiUsingMapGroup/", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApiUsingMapGroup", + "RoutePattern.RawText": "/MinimalApiUsingMapGroup/", + "IRouteDiagnosticsMetadata.Route": "/MinimalApiUsingMapGroup/", + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` + +## MinimalApi: Action with parameter (MapGroup) + +```json +{ + "IdealHttpRoute": "/MinimalApiUsingMapGroup/{id}", + "ActivityDisplayName": "GET /MinimalApiUsingMapGroup/{id}", + "ActivityHttpRoute": "/MinimalApiUsingMapGroup/{id}", + "MetricHttpRoute": "/MinimalApiUsingMapGroup/{id}", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/MinimalApiUsingMapGroup/123", + "RoutePattern.RawText": "/MinimalApiUsingMapGroup/{id}", + "IRouteDiagnosticsMetadata.Route": "/MinimalApiUsingMapGroup/{id}", + "HttpContext.GetRouteData()": { + "id": "123" + }, + "ActionDescriptor": null + } +} +``` + +## ExceptionMiddleware: Exception Handled by Exception Handler Middleware + +```json +{ + "IdealHttpRoute": "/Exception", + "ActivityDisplayName": "GET /Exception", + "ActivityHttpRoute": "/Exception", + "MetricHttpRoute": "/Exception", + "RouteInfo": { + "HttpMethod": "GET", + "Path": "/Exception", + "RoutePattern.RawText": "/Exception", + "IRouteDiagnosticsMetadata.Route": "/Exception", + "HttpContext.GetRouteData()": {}, + "ActionDescriptor": null + } +} +``` diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestCases.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestCases.cs new file mode 100644 index 0000000000..44d0e84e43 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestCases.cs @@ -0,0 +1,45 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RouteTests; + +public static class RoutingTestCases +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + }; + + public static IEnumerable GetTestCases() + { + var assembly = Assembly.GetExecutingAssembly(); + var input = JsonSerializer.Deserialize( + assembly.GetManifestResourceStream("RoutingTestCases.json")!, + JsonSerializerOptions); + return GetArgumentsFromTestCaseObject(input!); + } + + private static List GetArgumentsFromTestCaseObject(IEnumerable input) + { + var result = new List(); + + foreach (var testCase in input) + { + if (testCase.MinimumDotnetVersion.HasValue && Environment.Version.Major < testCase.MinimumDotnetVersion.Value) + { + continue; + } + + result.Add(new object[] { testCase }); + } + + return result; + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestCases.json b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestCases.json new file mode 100644 index 0000000000..2d1fa584ee --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestCases.json @@ -0,0 +1,211 @@ +[ + { + "name": "Root path", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/", + "expectedStatusCode": 200, + "currentHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "expectedHttpRoute": "ConventionalRoute/Default/{id?}" + }, + { + "name": "Non-default action with route parameter and query string", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/ConventionalRoute/ActionWithStringParameter/2?num=3", + "expectedStatusCode": 200, + "currentHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "expectedHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}" + }, + { + "name": "Non-default action with query string", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/ConventionalRoute/ActionWithStringParameter?num=3", + "expectedStatusCode": 200, + "currentHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "expectedHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}" + }, + { + "name": "Not Found (404)", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/ConventionalRoute/NotFound", + "expectedStatusCode": 404, + "currentHttpRoute": null, + "expectedHttpRoute": "" + }, + { + "name": "Route template with parameter constraint", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/SomePath/SomeString/2", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "SomePath/{id}/{num:int}" + }, + { + "name": "Path that does not match parameter constraint", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/SomePath/SomeString/NotAnInt", + "expectedStatusCode": 404, + "currentHttpRoute": null, + "expectedHttpRoute": "" + }, + { + "name": "Area using `area:exists`, default controller/action", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/MyArea", + "expectedStatusCode": 200, + "currentHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "expectedHttpRoute": "{area:exists}/ControllerForMyArea/Default/{id?}" + }, + { + "name": "Area using `area:exists`, non-default action", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/MyArea/ControllerForMyArea/NonDefault", + "expectedStatusCode": 200, + "currentHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "expectedHttpRoute": "{area:exists}/ControllerForMyArea/NonDefault/{id?}" + }, + { + "name": "Area w/o `area:exists`, default controller/action", + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/SomePrefix", + "expectedStatusCode": 200, + "currentHttpRoute": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "expectedHttpRoute": "SomePrefix/AnotherArea/Index/{id?}" + }, + { + "name": "Default action", + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "AttributeRoute" + }, + { + "name": "Action without parameter", + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute/Get", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "AttributeRoute/Get" + }, + { + "name": "Action with parameter", + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute/Get/12", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "AttributeRoute/Get/{id}" + }, + { + "name": "Action with parameter before action name in template", + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute/12/GetWithActionNameInDifferentSpotInTemplate", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate" + }, + { + "name": "Action invoked resulting in 400 Bad Request", + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute/NotAnInt/GetWithActionNameInDifferentSpotInTemplate", + "expectedStatusCode": 400, + "currentHttpRoute": null, + "expectedHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate" + }, + { + "name": "Root path", + "testApplicationScenario": "RazorPages", + "httpMethod": "GET", + "path": "/", + "expectedStatusCode": 200, + "currentHttpRoute": "", + "expectedHttpRoute": "/Index" + }, + { + "name": "Index page", + "testApplicationScenario": "RazorPages", + "httpMethod": "GET", + "path": "/Index", + "expectedStatusCode": 200, + "currentHttpRoute": "Index", + "expectedHttpRoute": "/Index" + }, + { + "name": "Throws exception", + "testApplicationScenario": "RazorPages", + "httpMethod": "GET", + "path": "/PageThatThrowsException", + "expectedStatusCode": 500, + "currentHttpRoute": "PageThatThrowsException", + "expectedHttpRoute": "/PageThatThrowsException" + }, + { + "name": "Static content", + "testApplicationScenario": "RazorPages", + "httpMethod": "GET", + "path": "/js/site.js", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "" + }, + { + "name": "Action without parameter", + "testApplicationScenario": "MinimalApi", + "httpMethod": "GET", + "path": "/MinimalApi", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "/MinimalApi" + }, + { + "name": "Action with parameter", + "testApplicationScenario": "MinimalApi", + "httpMethod": "GET", + "path": "/MinimalApi/123", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "/MinimalApi/{id}" + }, + { + "name": "Action without parameter (MapGroup)", + "minimumDotnetVersion": 7, + "testApplicationScenario": "MinimalApi", + "httpMethod": "GET", + "path": "/MinimalApiUsingMapGroup", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "/MinimalApiUsingMapGroup/" + }, + { + "name": "Action with parameter (MapGroup)", + "minimumDotnetVersion": 7, + "testApplicationScenario": "MinimalApi", + "httpMethod": "GET", + "path": "/MinimalApiUsingMapGroup/123", + "expectedStatusCode": 200, + "currentHttpRoute": null, + "expectedHttpRoute": "/MinimalApiUsingMapGroup/{id}" + }, + { + "name": "Exception Handled by Exception Handler Middleware", + "testApplicationScenario": "ExceptionMiddleware", + "httpMethod": "GET", + "path": "/Exception", + "expectedStatusCode": 500, + "currentHttpRoute": null, + "expectedHttpRoute": "/Exception" + } +] diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestFixture.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestFixture.cs new file mode 100644 index 0000000000..40d6d51209 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestFixture.cs @@ -0,0 +1,115 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Globalization; +using System.Text; +using Microsoft.AspNetCore.Builder; +using RouteTests.TestApplication; +using Xunit; + +namespace RouteTests; + +public class RoutingTestFixture : IAsyncLifetime +{ + private static readonly HttpClient HttpClient = new(); + private readonly Dictionary apps = new(); + private readonly RouteInfoDiagnosticObserver diagnostics = new(); + private readonly List testResults = new(); + + public RoutingTestFixture() + { + foreach (var scenario in Enum.GetValues()) + { + var app = TestApplicationFactory.CreateApplication(scenario); + if (app != null) + { + this.apps.Add(scenario, app); + } + } + + foreach (var app in this.apps) + { + app.Value.RunAsync(); + } + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + foreach (var app in this.apps) + { + await app.Value.DisposeAsync(); + } + + HttpClient.Dispose(); + this.diagnostics.Dispose(); + + this.GenerateReadme(); + } + + public async Task MakeRequest(TestApplicationScenario scenario, string path) + { + var app = this.apps[scenario]; + var baseUrl = app.Urls.First(); + var url = $"{baseUrl}{path}"; + await HttpClient.GetAsync(new Uri(url)); + } + + public void AddTestResult(RoutingTestResult result) + { + this.testResults.Add(result); + } + + private void GenerateReadme() + { + var sb = new StringBuilder(); + sb.AppendLine($"# Test results for ASP.NET Core {Environment.Version.Major}"); + sb.AppendLine(); + sb.AppendLine("| http.route | App | Test Name |"); + sb.AppendLine("| - | - | - |"); + + for (var i = 0; i < this.testResults.Count; ++i) + { + var result = this.testResults[i]; + var emoji = result.TestCase.CurrentHttpRoute == null ? ":green_heart:" : ":broken_heart:"; + sb.AppendLine($"| {emoji} | {result.TestCase.TestApplicationScenario} | [{result.TestCase.Name}]({GenerateLinkFragment(result.TestCase.TestApplicationScenario, result.TestCase.Name)}) |"); + } + + for (var i = 0; i < this.testResults.Count; ++i) + { + var result = this.testResults[i]; + sb.AppendLine(); + sb.AppendLine($"## {result.TestCase.TestApplicationScenario}: {result.TestCase.Name}"); + sb.AppendLine(); + sb.AppendLine("```json"); + sb.AppendLine(result.ToString()); + sb.AppendLine("```"); + } + + var readmeFileName = $"README.net{Environment.Version.Major}.0.md"; + File.WriteAllText(Path.Combine("..", "..", "..", "RouteTests", readmeFileName), sb.ToString()); + + // Generates a link fragment that should comply with markdownlint rule MD051 + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md + static string GenerateLinkFragment(TestApplicationScenario scenario, string name) + { + var chars = name.ToCharArray() + .Where(c => (!char.IsPunctuation(c) && c != '`') || c == '-') + .Select(c => c switch + { + '-' => '-', + ' ' => '-', + _ => char.ToLower(c, CultureInfo.InvariantCulture), + }) + .ToArray(); + + return $"#{scenario.ToString().ToLower(CultureInfo.CurrentCulture)}-{new string(chars)}"; + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestResult.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestResult.cs new file mode 100644 index 0000000000..f1df77ba10 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTestResult.cs @@ -0,0 +1,33 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Text.Json; +using System.Text.Json.Serialization; +using RouteTests.TestApplication; + +namespace RouteTests; + +public class RoutingTestResult +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new() { WriteIndented = true }; + + public string? IdealHttpRoute { get; set; } + + public string ActivityDisplayName { get; set; } = string.Empty; + + public string? ActivityHttpRoute { get; set; } + + public string? MetricHttpRoute { get; set; } + + public RouteInfo RouteInfo { get; set; } = new RouteInfo(); + + [JsonIgnore] + public TestCase TestCase { get; set; } = new TestCase(); + + public override string ToString() + { + return JsonSerializer.Serialize(this, JsonSerializerOptions); + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTests.cs new file mode 100644 index 0000000000..e140a3bb60 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTests.cs @@ -0,0 +1,140 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Diagnostics; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using RouteTests.TestApplication; +using Xunit; + +namespace RouteTests; + +[Collection("AspNetCore")] +public class RoutingTests : IClassFixture +{ + private const string HttpStatusCode = "http.response.status_code"; + private const string HttpMethod = "http.request.method"; + private const string HttpRoute = "http.route"; + + private readonly RoutingTestFixture fixture; + private readonly List exportedActivities = new(); + private readonly List exportedMetrics = new(); + + public RoutingTests(RoutingTestFixture fixture) + { + this.fixture = fixture; + } + + public static IEnumerable TestData => RoutingTestCases.GetTestCases(); + + [Theory] + [MemberData(nameof(TestData))] + public async Task TestHttpRoute(TestCase testCase) + { + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(this.exportedActivities) + .Build()!; + + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(this.exportedMetrics) + .Build()!; + + await this.fixture.MakeRequest(testCase.TestApplicationScenario, testCase.Path); + + for (var i = 0; i < 10; i++) + { + if (this.exportedActivities.Count > 0) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(1)); + } + + meterProvider.ForceFlush(); + + var durationMetric = this.exportedMetrics.Single(x => x.Name == "http.server.request.duration" || x.Name == "http.server.duration"); + var metricPoints = new List(); + foreach (var mp in durationMetric.GetMetricPoints()) + { + metricPoints.Add(mp); + } + + var activity = Assert.Single(this.exportedActivities); + var metricPoint = Assert.Single(metricPoints); + + GetTagsFromActivity(activity, out var activityHttpStatusCode, out var activityHttpMethod, out var activityHttpRoute); + GetTagsFromMetricPoint(Environment.Version.Major < 8, metricPoint, out var metricHttpStatusCode, out var metricHttpMethod, out var metricHttpRoute); + + Assert.Equal(testCase.ExpectedStatusCode, activityHttpStatusCode); + Assert.Equal(testCase.ExpectedStatusCode, metricHttpStatusCode); + Assert.Equal(testCase.HttpMethod, activityHttpMethod); + Assert.Equal(testCase.HttpMethod, metricHttpMethod); + + // TODO: The CurrentHttpRoute property will go away. It They only serve to capture status quo. + // If CurrentHttpRoute is null, then that means we already conform to the correct behavior. + var expectedHttpRoute = testCase.CurrentHttpRoute != null ? testCase.CurrentHttpRoute : testCase.ExpectedHttpRoute; + Assert.Equal(expectedHttpRoute, activityHttpRoute); + Assert.Equal(expectedHttpRoute, metricHttpRoute); + + // Activity.DisplayName should be a combination of http.method + http.route attributes, see: + // https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md#name + var expectedActivityDisplayName = string.IsNullOrEmpty(expectedHttpRoute) + ? testCase.HttpMethod + : $"{testCase.HttpMethod} {expectedHttpRoute}"; + + Assert.Equal(expectedActivityDisplayName, activity.DisplayName); + + var testResult = new RoutingTestResult + { + IdealHttpRoute = testCase.ExpectedHttpRoute, + ActivityDisplayName = activity.DisplayName, + ActivityHttpRoute = activityHttpRoute, + MetricHttpRoute = metricHttpRoute, + TestCase = testCase, + RouteInfo = RouteInfo.Current, + }; + + this.fixture.AddTestResult(testResult); + } + + private static void GetTagsFromActivity(Activity activity, out int httpStatusCode, out string httpMethod, out string? httpRoute) + { + var expectedStatusCodeKey = HttpStatusCode; + var expectedHttpMethodKey = HttpMethod; + httpStatusCode = Convert.ToInt32(activity.GetTagItem(expectedStatusCodeKey)); + httpMethod = (activity.GetTagItem(expectedHttpMethodKey) as string)!; + httpRoute = activity.GetTagItem(HttpRoute) as string ?? string.Empty; + } + + private static void GetTagsFromMetricPoint(bool useLegacyConventions, MetricPoint metricPoint, out int httpStatusCode, out string httpMethod, out string? httpRoute) + { + var expectedStatusCodeKey = HttpStatusCode; + var expectedHttpMethodKey = HttpMethod; + + httpStatusCode = 0; + httpMethod = string.Empty; + httpRoute = string.Empty; + + foreach (var tag in metricPoint.Tags) + { + if (tag.Key.Equals(expectedStatusCodeKey)) + { + httpStatusCode = Convert.ToInt32(tag.Value); + } + else if (tag.Key.Equals(expectedHttpMethodKey)) + { + httpMethod = (tag.Value as string)!; + } + else if (tag.Key.Equals(HttpRoute)) + { + httpRoute = tag.Value as string; + } + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/ActionDescriptorInfo.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/ActionDescriptorInfo.cs new file mode 100644 index 0000000000..20fc1f281b --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/ActionDescriptorInfo.cs @@ -0,0 +1,52 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RouteTests.TestApplication; + +public class ActionDescriptorInfo +{ + public ActionDescriptorInfo() + { + } + + public ActionDescriptorInfo(ActionDescriptor actionDescriptor) + { + this.AttributeRouteInfo = actionDescriptor.AttributeRouteInfo?.Template; + + this.ActionParameters = new List(); + foreach (var item in actionDescriptor.Parameters) + { + this.ActionParameters.Add(item.Name); + } + + if (actionDescriptor is PageActionDescriptor pad) + { + this.PageActionDescriptorSummary = new PageActionDescriptorInfo(pad.RelativePath, pad.ViewEnginePath); + } + + if (actionDescriptor is ControllerActionDescriptor cad) + { + this.ControllerActionDescriptorSummary = new ControllerActionDescriptorInfo(cad.ControllerName, cad.ActionName); + } + } + + [JsonPropertyName("AttributeRouteInfo.Template")] + public string? AttributeRouteInfo { get; set; } + + [JsonPropertyName("Parameters")] +#pragma warning disable CA2227 + public IList? ActionParameters { get; set; } +#pragma warning restore CA2227 + + [JsonPropertyName("ControllerActionDescriptor")] + public ControllerActionDescriptorInfo? ControllerActionDescriptorSummary { get; set; } + + [JsonPropertyName("PageActionDescriptor")] + public PageActionDescriptorInfo? PageActionDescriptorSummary { get; set; } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Areas/AnotherArea/Controllers/AnotherAreaController.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Areas/AnotherArea/Controllers/AnotherAreaController.cs new file mode 100644 index 0000000000..7754255edf --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Areas/AnotherArea/Controllers/AnotherAreaController.cs @@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +using Microsoft.AspNetCore.Mvc; + +namespace RouteTests.Controllers; + +[Area("AnotherArea")] +public class AnotherAreaController : Controller +{ + public IActionResult Index() => this.Ok(); +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Areas/MyArea/Controllers/ControllerForMyAreaController.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Areas/MyArea/Controllers/ControllerForMyAreaController.cs new file mode 100644 index 0000000000..762f4a95e8 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Areas/MyArea/Controllers/ControllerForMyAreaController.cs @@ -0,0 +1,16 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +using Microsoft.AspNetCore.Mvc; + +namespace RouteTests.Controllers; + +[Area("MyArea")] +public class ControllerForMyAreaController : Controller +{ + public IActionResult Default() => this.Ok(); + + public IActionResult NonDefault() => this.Ok(); +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/ControllerActionDescriptorInfo.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/ControllerActionDescriptorInfo.cs new file mode 100644 index 0000000000..ae5ef6b907 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/ControllerActionDescriptorInfo.cs @@ -0,0 +1,26 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable +using System.Text.Json.Serialization; + +namespace RouteTests.TestApplication; + +public class ControllerActionDescriptorInfo +{ + public ControllerActionDescriptorInfo() + { + } + + public ControllerActionDescriptorInfo(string controllerName, string actionName) + { + this.ControllerActionDescriptorControllerName = controllerName; + this.ControllerActionDescriptorActionName = actionName; + } + + [JsonPropertyName("ControllerName")] + public string ControllerActionDescriptorControllerName { get; set; } = string.Empty; + + [JsonPropertyName("ActionName")] + public string ControllerActionDescriptorActionName { get; set; } = string.Empty; +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Controllers/AttributeRouteController.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Controllers/AttributeRouteController.cs new file mode 100644 index 0000000000..b1e5783b0a --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Controllers/AttributeRouteController.cs @@ -0,0 +1,23 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +using Microsoft.AspNetCore.Mvc; + +namespace RouteTests.Controllers; + +[ApiController] +[Route("[controller]")] +public class AttributeRouteController : ControllerBase +{ + [HttpGet] + [HttpGet("[action]")] + public IActionResult Get() => this.Ok(); + + [HttpGet("[action]/{id}")] + public IActionResult Get(int id) => this.Ok(); + + [HttpGet("{id}/[action]")] + public IActionResult GetWithActionNameInDifferentSpotInTemplate(int id) => this.Ok(); +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Controllers/ConventionalRouteController.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Controllers/ConventionalRouteController.cs new file mode 100644 index 0000000000..977ee36a13 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Controllers/ConventionalRouteController.cs @@ -0,0 +1,17 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +using Microsoft.AspNetCore.Mvc; + +namespace RouteTests.Controllers; + +public class ConventionalRouteController : Controller +{ + public IActionResult Default() => this.Ok(); + + public IActionResult ActionWithParameter(int id) => this.Ok(); + + public IActionResult ActionWithStringParameter(string id, int num) => this.Ok(); +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/PageActionDescriptorInfo.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/PageActionDescriptorInfo.cs new file mode 100644 index 0000000000..dda48ae462 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/PageActionDescriptorInfo.cs @@ -0,0 +1,26 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable +using System.Text.Json.Serialization; + +namespace RouteTests.TestApplication; + +public class PageActionDescriptorInfo +{ + public PageActionDescriptorInfo() + { + } + + public PageActionDescriptorInfo(string relativePath, string viewEnginePath) + { + this.PageActionDescriptorRelativePath = relativePath; + this.PageActionDescriptorViewEnginePath = viewEnginePath; + } + + [JsonPropertyName("RelativePath")] + public string PageActionDescriptorRelativePath { get; set; } = string.Empty; + + [JsonPropertyName("ViewEnginePath")] + public string PageActionDescriptorViewEnginePath { get; set; } = string.Empty; +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Pages/Index.cshtml b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Pages/Index.cshtml new file mode 100644 index 0000000000..51c350f956 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Pages/Index.cshtml @@ -0,0 +1,2 @@ +@page +Hello, OpenTelemetry! diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Pages/PageThatThrowsException.cshtml b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Pages/PageThatThrowsException.cshtml new file mode 100644 index 0000000000..cf6ac0d5b8 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/Pages/PageThatThrowsException.cshtml @@ -0,0 +1,4 @@ +@page +@{ + throw new Exception("Oops."); +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfo.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfo.cs new file mode 100644 index 0000000000..08433fc666 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfo.cs @@ -0,0 +1,60 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; +#if NET8_0_OR_GREATER +using Microsoft.AspNetCore.Http.Metadata; +#endif +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; + +namespace RouteTests.TestApplication; + +public class RouteInfo +{ + public static RouteInfo Current { get; set; } = new(); + + public string? HttpMethod { get; set; } + + public string? Path { get; set; } + + [JsonPropertyName("RoutePattern.RawText")] + public string? RawText { get; set; } + + [JsonPropertyName("IRouteDiagnosticsMetadata.Route")] + public string? RouteDiagnosticMetadata { get; set; } + + [JsonPropertyName("HttpContext.GetRouteData()")] +#pragma warning disable CA2227 + public IDictionary? RouteData { get; set; } +#pragma warning restore CA2227 + + public ActionDescriptorInfo? ActionDescriptor { get; set; } + + public void SetValues(HttpContext context) + { + this.HttpMethod = context.Request.Method; + this.Path = $"{context.Request.Path}{context.Request.QueryString}"; + var endpoint = context.GetEndpoint(); + this.RawText = (endpoint as RouteEndpoint)?.RoutePattern.RawText; +#if NET8_0_OR_GREATER + this.RouteDiagnosticMetadata = endpoint?.Metadata.GetMetadata()?.Route; +#endif + this.RouteData = new Dictionary(); + foreach (var value in context.GetRouteData().Values) + { + this.RouteData[value.Key] = value.Value?.ToString(); + } + } + + public void SetValues(ActionDescriptor actionDescriptor) + { + if (this.ActionDescriptor == null) + { + this.ActionDescriptor = new ActionDescriptorInfo(actionDescriptor); + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfoDiagnosticObserver.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfoDiagnosticObserver.cs new file mode 100644 index 0000000000..3b3feb59e1 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfoDiagnosticObserver.cs @@ -0,0 +1,110 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Diagnostics; + +namespace RouteTests.TestApplication; + +/// +/// This observer captures all the available route information for a request. +/// This route information is used for generating a README file for analyzing +/// what information is available in different scenarios. +/// +internal sealed class RouteInfoDiagnosticObserver : IDisposable, IObserver, IObserver> +{ + internal const string OnStartEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Start"; + internal const string OnStopEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop"; + internal const string OnMvcBeforeActionEvent = "Microsoft.AspNetCore.Mvc.BeforeAction"; + + private readonly List listenerSubscriptions = new(); + private IDisposable? allSourcesSubscription; + private long disposed; + + public RouteInfoDiagnosticObserver() + { + this.allSourcesSubscription = DiagnosticListener.AllListeners.Subscribe(this); + } + + public void OnNext(DiagnosticListener value) + { + if (value.Name == "Microsoft.AspNetCore") + { + var subscription = value.Subscribe(this); + + lock (this.listenerSubscriptions) + { + this.listenerSubscriptions.Add(subscription); + } + } + } + + public void OnNext(KeyValuePair value) + { + HttpContext? context; + BeforeActionEventData? actionMethodEventData; + RouteInfo? info; + + switch (value.Key) + { + case OnStartEvent: + context = value.Value as HttpContext; + Debug.Assert(context != null, "HttpContext was null"); + info = new RouteInfo(); + info.SetValues(context); + RouteInfo.Current = info; + break; + case OnMvcBeforeActionEvent: + actionMethodEventData = value.Value as BeforeActionEventData; + Debug.Assert(actionMethodEventData != null, $"expected {nameof(BeforeActionEventData)}"); + RouteInfo.Current.SetValues(actionMethodEventData.HttpContext); + RouteInfo.Current.SetValues(actionMethodEventData.ActionDescriptor); + break; + case OnStopEvent: + context = value.Value as HttpContext; + Debug.Assert(context != null, "HttpContext was null"); + RouteInfo.Current.SetValues(context); + break; + default: + break; + } + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (Interlocked.CompareExchange(ref this.disposed, 1, 0) == 1) + { + return; + } + + lock (this.listenerSubscriptions) + { + foreach (var listenerSubscription in this.listenerSubscriptions) + { + listenerSubscription?.Dispose(); + } + + this.listenerSubscriptions.Clear(); + } + + this.allSourcesSubscription?.Dispose(); + this.allSourcesSubscription = null; + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/TestApplicationFactory.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/TestApplicationFactory.cs new file mode 100644 index 0000000000..b030ab7f42 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/TestApplicationFactory.cs @@ -0,0 +1,199 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Diagnostics; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; + +namespace RouteTests.TestApplication; + +public enum TestApplicationScenario +{ + /// + /// An application that uses conventional routing. + /// + ConventionalRouting, + + /// + /// An application that uses attribute routing. + /// + AttributeRouting, + + /// + /// A Minimal API application. + /// + MinimalApi, + + /// + /// An Razor Pages application. + /// + RazorPages, + + /// + /// Application with Exception Handling Middleware. + /// + ExceptionMiddleware, +} + +internal class TestApplicationFactory +{ + private static readonly string AspNetCoreTestsPath = new FileInfo(typeof(RoutingTests)!.Assembly!.Location)!.Directory!.Parent!.Parent!.Parent!.FullName; + private static readonly string ContentRootPath = Path.Combine(AspNetCoreTestsPath, "RouteTests", "TestApplication"); + + public static WebApplication? CreateApplication(TestApplicationScenario config) + { + Debug.Assert(Directory.Exists(ContentRootPath), $"Cannot find ContentRootPath: {ContentRootPath}"); + switch (config) + { + case TestApplicationScenario.ConventionalRouting: + return CreateConventionalRoutingApplication(); + case TestApplicationScenario.AttributeRouting: + return CreateAttributeRoutingApplication(); + case TestApplicationScenario.MinimalApi: + return CreateMinimalApiApplication(); + case TestApplicationScenario.RazorPages: + return CreateRazorPagesApplication(); + case TestApplicationScenario.ExceptionMiddleware: + return CreateExceptionHandlerApplication(); + default: + throw new ArgumentException($"Invalid {nameof(TestApplicationScenario)}"); + } + } + + private static WebApplication CreateConventionalRoutingApplication() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = ContentRootPath }); + builder.Logging.ClearProviders(); + + builder.Services + .AddControllersWithViews() + .AddApplicationPart(typeof(RoutingTests).Assembly); + + var app = builder.Build(); + app.Urls.Clear(); + app.Urls.Add("http://[::1]:0"); + app.UseStaticFiles(); + app.UseRouting(); + + app.MapAreaControllerRoute( + name: "AnotherArea", + areaName: "AnotherArea", + pattern: "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}"); + + app.MapControllerRoute( + name: "MyArea", + pattern: "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}"); + + app.MapControllerRoute( + name: "FixedRouteWithConstraints", + pattern: "SomePath/{id}/{num:int}", + defaults: new { controller = "ConventionalRoute", action = "ActionWithStringParameter" }); + + app.MapControllerRoute( + name: "default", + pattern: "{controller=ConventionalRoute}/{action=Default}/{id?}"); + + return app; + } + + private static WebApplication CreateAttributeRoutingApplication() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + + builder.Services + .AddControllers() + .AddApplicationPart(typeof(RoutingTests).Assembly); + + var app = builder.Build(); + app.Urls.Clear(); + app.Urls.Add("http://[::1]:0"); + app.MapControllers(); + + return app; + } + + private static WebApplication CreateMinimalApiApplication() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + + var app = builder.Build(); + app.Urls.Clear(); + app.Urls.Add("http://[::1]:0"); + + app.MapGet("/MinimalApi", () => Results.Ok()); + app.MapGet("/MinimalApi/{id}", (int id) => Results.Ok()); + +#if NET7_0_OR_GREATER + var api = app.MapGroup("/MinimalApiUsingMapGroup"); + api.MapGet("/", () => Results.Ok()); + api.MapGet("/{id}", (int id) => Results.Ok()); +#endif + + return app; + } + + private static WebApplication CreateRazorPagesApplication() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = ContentRootPath }); + builder.Logging.ClearProviders(); + + builder.Services + .AddRazorPages() + .AddRazorRuntimeCompilation(options => + { + options.FileProviders.Add(new PhysicalFileProvider(ContentRootPath)); + }) + .AddApplicationPart(typeof(RoutingTests).Assembly); + + var app = builder.Build(); + app.Urls.Clear(); + app.Urls.Add("http://[::1]:0"); + app.UseStaticFiles(); + app.UseRouting(); + app.MapRazorPages(); + + return app; + } + + private static WebApplication CreateExceptionHandlerApplication() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + + var app = builder.Build(); + + app.UseExceptionHandler(exceptionHandlerApp => + { + exceptionHandlerApp.Run(async context => + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + var exceptionHandlerPathFeature = context.Features.Get(); + await context.Response.WriteAsync(exceptionHandlerPathFeature?.Error.Message ?? "An exception was thrown."); + }); + }); + + app.Urls.Clear(); + app.Urls.Add("http://[::1]:0"); + + // TODO: Remove this condition once ASP.NET Core 8.0.2. + // Currently, .NET 8 has a different behavior than .NET 6 and 7. + // This is because ASP.NET Core 8+ has native metric instrumentation. + // When ASP.NET Core 8.0.2 is released then its behavior will align with .NET 6/7. + // See: https://github.com/dotnet/aspnetcore/issues/52648#issuecomment-1853432776 +#if !NET8_0_OR_GREATER + app.MapGet("/Exception", (ctx) => throw new ApplicationException()); +#else + app.MapGet("/Exception", () => Results.Content(content: "Error", contentType: null, contentEncoding: null, statusCode: 500)); +#endif + + return app; + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/wwwroot/js/site.js b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/wwwroot/js/site.js new file mode 100644 index 0000000000..0937657353 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/wwwroot/js/site.js @@ -0,0 +1,4 @@ +// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification +// for details on configuring this project to bundle and minify static web assets. + +// Write your JavaScript code. diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestCase.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestCase.cs new file mode 100644 index 0000000000..36710fe4a9 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestCase.cs @@ -0,0 +1,32 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable +using RouteTests.TestApplication; + +namespace RouteTests; + +public class TestCase +{ + public string Name { get; set; } = string.Empty; + + public int? MinimumDotnetVersion { get; set; } + + public TestApplicationScenario TestApplicationScenario { get; set; } + + public string? HttpMethod { get; set; } + + public string Path { get; set; } = string.Empty; + + public int ExpectedStatusCode { get; set; } + + public string? ExpectedHttpRoute { get; set; } + + public string? CurrentHttpRoute { get; set; } + + public override string ToString() + { + // This is used by Visual Studio's test runner to identify the test case. + return $"{this.TestApplicationScenario}: {this.Name}"; + } +} diff --git a/test/OpenTelemetry.Instrumentation.Http.Benchmark/README.md b/test/OpenTelemetry.Instrumentation.Http.Benchmark/README.md index b855fb74fd..2cc3e82fbc 100644 --- a/test/OpenTelemetry.Instrumentation.Http.Benchmark/README.md +++ b/test/OpenTelemetry.Instrumentation.Http.Benchmark/README.md @@ -1,4 +1,4 @@ -# OpenTelemetry GenevaExporter Benchmarks +# OpenTelemetry HTTP Instrumentation Benchmarks Navigate to `./test/OpenTelemetry.Instrumentation.Http.Benchmark` directory and run the following command: diff --git a/test/TestApp.AspNetCore/ActivityMiddleware.cs b/test/TestApp.AspNetCore/ActivityMiddleware.cs new file mode 100644 index 0000000000..99f70a5aa3 --- /dev/null +++ b/test/TestApp.AspNetCore/ActivityMiddleware.cs @@ -0,0 +1,31 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace TestApp.AspNetCore; + +internal class ActivityMiddleware +{ + private readonly TestActivityMiddleware testActivityMiddleware; + private readonly RequestDelegate next; + + public ActivityMiddleware(RequestDelegate next, TestActivityMiddleware testActivityMiddleware) + { + this.next = next; + this.testActivityMiddleware = testActivityMiddleware; + } + + public async Task InvokeAsync(HttpContext context) + { + if (this.testActivityMiddleware != null) + { + this.testActivityMiddleware.PreProcess(context); + } + + await this.next(context); + + if (this.testActivityMiddleware != null) + { + this.testActivityMiddleware.PostProcess(context); + } + } +} diff --git a/test/TestApp.AspNetCore/CallbackMiddleware.cs b/test/TestApp.AspNetCore/CallbackMiddleware.cs new file mode 100644 index 0000000000..9ee236845d --- /dev/null +++ b/test/TestApp.AspNetCore/CallbackMiddleware.cs @@ -0,0 +1,24 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace TestApp.AspNetCore; + +public class CallbackMiddleware +{ + private readonly TestCallbackMiddleware testCallbackMiddleware; + private readonly RequestDelegate next; + + public CallbackMiddleware(RequestDelegate next, TestCallbackMiddleware testCallbackMiddleware) + { + this.next = next; + this.testCallbackMiddleware = testCallbackMiddleware; + } + + public async Task InvokeAsync(HttpContext context) + { + if (this.testCallbackMiddleware == null || await this.testCallbackMiddleware.ProcessAsync(context)) + { + await this.next(context); + } + } +} diff --git a/test/TestApp.AspNetCore/Controllers/ChildActivityController.cs b/test/TestApp.AspNetCore/Controllers/ChildActivityController.cs new file mode 100644 index 0000000000..b55927000f --- /dev/null +++ b/test/TestApp.AspNetCore/Controllers/ChildActivityController.cs @@ -0,0 +1,47 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using OpenTelemetry; + +namespace TestApp.AspNetCore.Controllers; + +public class ChildActivityController : Controller +{ + [HttpGet] + [Route("api/GetChildActivityTraceContext")] + public Dictionary GetChildActivityTraceContext() + { + var result = new Dictionary(); + var activity = new Activity("ActivityInsideHttpRequest"); + activity.Start(); + result["TraceId"] = activity.Context.TraceId.ToString(); + result["ParentSpanId"] = activity.ParentSpanId.ToString(); + if (activity.Context.TraceState != null) + { + result["TraceState"] = activity.Context.TraceState; + } + + activity.Stop(); + return result; + } + + [HttpGet] + [Route("api/GetChildActivityBaggageContext")] + public IReadOnlyDictionary GetChildActivityBaggageContext() + { + var result = Baggage.Current.GetBaggage(); + return result; + } + + [HttpGet] + [Route("api/GetActivityEquality")] + public bool GetActivityEquality() + { + var activity = this.HttpContext.Features.Get()?.Activity; + var equal = Activity.Current == activity; + return equal; + } +} diff --git a/test/TestApp.AspNetCore/Controllers/ErrorController.cs b/test/TestApp.AspNetCore/Controllers/ErrorController.cs new file mode 100644 index 0000000000..24c904cfe9 --- /dev/null +++ b/test/TestApp.AspNetCore/Controllers/ErrorController.cs @@ -0,0 +1,17 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.AspNetCore.Mvc; + +namespace TestApp.AspNetCore.Controllers; + +[Route("api/[controller]")] +public class ErrorController : Controller +{ + // GET api/error + [HttpGet] + public string Get() + { + throw new Exception("something's wrong!"); + } +} diff --git a/test/TestApp.AspNetCore/Controllers/ValuesController.cs b/test/TestApp.AspNetCore/Controllers/ValuesController.cs new file mode 100644 index 0000000000..27a9ab0d2d --- /dev/null +++ b/test/TestApp.AspNetCore/Controllers/ValuesController.cs @@ -0,0 +1,42 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.AspNetCore.Mvc; + +namespace TestApp.AspNetCore.Controllers; + +[Route("api/[controller]")] +public class ValuesController : Controller +{ + // GET api/values + [HttpGet] + public IEnumerable Get() + { + return new string[] { "value1", "value2" }; + } + + // GET api/values/5 + [HttpGet("{id}")] + public string Get(int id) + { + return "value"; + } + + // POST api/values + [HttpPost] + public void Post([FromBody] string value) + { + } + + // PUT api/values/5 + [HttpPut("{id}")] + public void Put(int id, [FromBody] string value) + { + } + + // DELETE api/values/5 + [HttpDelete("{id}")] + public void Delete(int id) + { + } +} diff --git a/test/TestApp.AspNetCore/Filters/ExceptionFilter1.cs b/test/TestApp.AspNetCore/Filters/ExceptionFilter1.cs new file mode 100644 index 0000000000..1f05886069 --- /dev/null +++ b/test/TestApp.AspNetCore/Filters/ExceptionFilter1.cs @@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.AspNetCore.Mvc.Filters; + +namespace TestApp.AspNetCore.Filters; + +public class ExceptionFilter1 : IExceptionFilter +{ + public void OnException(ExceptionContext context) + { + // test the behaviour when an application has two ExceptionFilters defined + } +} diff --git a/test/TestApp.AspNetCore/Filters/ExceptionFilter2.cs b/test/TestApp.AspNetCore/Filters/ExceptionFilter2.cs new file mode 100644 index 0000000000..fc81905c65 --- /dev/null +++ b/test/TestApp.AspNetCore/Filters/ExceptionFilter2.cs @@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.AspNetCore.Mvc.Filters; + +namespace TestApp.AspNetCore.Filters; + +public class ExceptionFilter2 : IExceptionFilter +{ + public void OnException(ExceptionContext context) + { + // test the behaviour when an application has two ExceptionFilters defined + } +} diff --git a/test/TestApp.AspNetCore/Program.cs b/test/TestApp.AspNetCore/Program.cs new file mode 100644 index 0000000000..5cbe2b5e3a --- /dev/null +++ b/test/TestApp.AspNetCore/Program.cs @@ -0,0 +1,54 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using TestApp.AspNetCore; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + + builder.Services.AddControllers(); + + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + + builder.Services.AddSwaggerGen(); + + builder.Services.AddMvc(); + + builder.Services.AddSingleton(); + + builder.Services.AddSingleton( + new TestCallbackMiddleware()); + + builder.Services.AddSingleton( + new TestActivityMiddleware()); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + app.MapControllers(); + + app.UseMiddleware(); + + app.UseMiddleware(); + + app.AddTestMiddleware(); + + app.Run(); + } +} diff --git a/test/TestApp.AspNetCore/Properties/launchSettings.json b/test/TestApp.AspNetCore/Properties/launchSettings.json new file mode 100644 index 0000000000..f627182e41 --- /dev/null +++ b/test/TestApp.AspNetCore/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "TestApp.AspNetCore": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:58211;http://localhost:58212" + } + } +} \ No newline at end of file diff --git a/test/TestApp.AspNetCore/TestActivityMiddleware.cs b/test/TestApp.AspNetCore/TestActivityMiddleware.cs new file mode 100644 index 0000000000..be1e2e5a1e --- /dev/null +++ b/test/TestApp.AspNetCore/TestActivityMiddleware.cs @@ -0,0 +1,17 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace TestApp.AspNetCore; + +public class TestActivityMiddleware +{ + public virtual void PreProcess(HttpContext context) + { + // Do nothing + } + + public virtual void PostProcess(HttpContext context) + { + // Do nothing + } +} diff --git a/test/TestApp.AspNetCore/TestApp.AspNetCore.csproj b/test/TestApp.AspNetCore/TestApp.AspNetCore.csproj new file mode 100644 index 0000000000..ce4a4b6852 --- /dev/null +++ b/test/TestApp.AspNetCore/TestApp.AspNetCore.csproj @@ -0,0 +1,13 @@ + + + + net8.0;net7.0;net6.0 + enable + + + + + + + + diff --git a/test/TestApp.AspNetCore/TestCallbackMiddleware.cs b/test/TestApp.AspNetCore/TestCallbackMiddleware.cs new file mode 100644 index 0000000000..ba11577ff0 --- /dev/null +++ b/test/TestApp.AspNetCore/TestCallbackMiddleware.cs @@ -0,0 +1,12 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace TestApp.AspNetCore; + +public class TestCallbackMiddleware +{ + public virtual async Task ProcessAsync(HttpContext context) + { + return await Task.FromResult(true); + } +} diff --git a/test/TestApp.AspNetCore/TestMiddleware.cs b/test/TestApp.AspNetCore/TestMiddleware.cs new file mode 100644 index 0000000000..39acf58db3 --- /dev/null +++ b/test/TestApp.AspNetCore/TestMiddleware.cs @@ -0,0 +1,24 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace TestApp.AspNetCore; + +public static class TestMiddleware +{ + private static readonly AsyncLocal?> Current = new(); + + public static IApplicationBuilder AddTestMiddleware(this IApplicationBuilder builder) + { + if (Current.Value is { } configure) + { + configure(builder); + } + + return builder; + } + + public static void Create(Action action) + { + Current.Value = action; + } +} diff --git a/test/TestApp.AspNetCore/appsettings.Development.json b/test/TestApp.AspNetCore/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/test/TestApp.AspNetCore/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/TestApp.AspNetCore/appsettings.json b/test/TestApp.AspNetCore/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/test/TestApp.AspNetCore/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}