diff --git a/Directory.Packages.props b/Directory.Packages.props index a2245fd9315..ff2112729a5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -95,13 +95,16 @@ + + + diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj index f7400d295f8..214c232d21a 100644 --- a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/OpenTelemetry.Instrumentation.AspNetCore.Tests.csproj @@ -7,6 +7,7 @@ + @@ -36,4 +37,11 @@ + + + + RouteTests.testcases.json + Always + + diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Areas/AnotherArea/Controllers/AnotherAreaController.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Areas/AnotherArea/Controllers/AnotherAreaController.cs new file mode 100644 index 00000000000..e9a2565304c --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Areas/AnotherArea/Controllers/AnotherAreaController.cs @@ -0,0 +1,27 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#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/Areas/MyArea/Controllers/ControllerForMyAreaController.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Areas/MyArea/Controllers/ControllerForMyAreaController.cs new file mode 100644 index 00000000000..99933dd34cf --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Areas/MyArea/Controllers/ControllerForMyAreaController.cs @@ -0,0 +1,29 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#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/AspNetCoreDiagnosticObserver.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/AspNetCoreDiagnosticObserver.cs new file mode 100644 index 00000000000..de560214dff --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/AspNetCoreDiagnosticObserver.cs @@ -0,0 +1,119 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable enable + +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Diagnostics; + +namespace RouteTests; + +internal sealed class AspNetCoreDiagnosticObserver : 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; + private IDisposable? allSourcesSubscription; + private long disposed; + + public AspNetCoreDiagnosticObserver() + { + this.listenerSubscriptions = new List(); + 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); + context.Items["RouteInfo"] = info; + break; + case OnMvcBeforeActionEvent: + actionMethodEventData = value.Value as BeforeActionEventData; + Debug.Assert(actionMethodEventData != null, $"expected {nameof(BeforeActionEventData)}"); + info = actionMethodEventData.HttpContext.Items["RouteInfo"] as RouteInfo; + Debug.Assert(info != null, "RouteInfo object not present in context.Items"); + info.SetValues(actionMethodEventData.HttpContext); + info.SetValues(actionMethodEventData.ActionDescriptor); + break; + case OnStopEvent: + // Can't update RouteInfo here because the response is already written. + 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/Controllers/AttributeRouteController.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Controllers/AttributeRouteController.cs new file mode 100644 index 00000000000..28271f9553e --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Controllers/AttributeRouteController.cs @@ -0,0 +1,36 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#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/Controllers/ConventionalRouteController.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Controllers/ConventionalRouteController.cs new file mode 100644 index 00000000000..8dedc92cf04 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Controllers/ConventionalRouteController.cs @@ -0,0 +1,30 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#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/DebugInfo.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/DebugInfo.cs new file mode 100644 index 00000000000..d60f07bc73a --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/DebugInfo.cs @@ -0,0 +1,40 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable enable + +namespace RouteTests; + +public class DebugInfo +{ + public string? RawText { get; set; } + + public string? RouteDiagnosticMetadata { get; set; } + + public IDictionary? RouteData { get; set; } + + public string? AttributeRouteInfo { get; set; } + + public IList? ActionParameters { get; set; } + + public string? PageActionDescriptorRelativePath { get; set; } + + public string? PageActionDescriptorViewEnginePath { get; set; } + + public string? ControllerActionDescriptorControllerName { get; set; } + + public string? ControllerActionDescriptorActionName { get; set; } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Pages/Index.cshtml b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Pages/Index.cshtml new file mode 100644 index 00000000000..2ef32804b3b --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Pages/Index.cshtml @@ -0,0 +1,2 @@ +@page +Hello, OpenTelemetry! diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Pages/PageThatThrowsException.cshtml b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Pages/PageThatThrowsException.cshtml new file mode 100644 index 00000000000..e95a1f02393 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/Pages/PageThatThrowsException.cshtml @@ -0,0 +1,4 @@ +@page +@{ + throw new Exception("Oops."); +} 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 00000000000..b06ff12a4c4 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/README.md @@ -0,0 +1,548 @@ +| | | display name | expected name (w/o http.method) | routing type | request | +| - | - | - | - | - | - | +| :broken_heart: | [1](#1) | / | ConventionalRoute/Default/{id?} | ConventionalRouting | GET / | / | +| :broken_heart: | [2](#2) | /ConventionalRoute/ActionWithStringParameter/2 | ConventionalRoute/ActionWithStringParameter/{id?} | ConventionalRouting | GET /ConventionalRoute/ActionWithStringParameter/2?num=3 | /ConventionalRoute/ActionWithStringParameter/2 | +| :broken_heart: | [3](#3) | /ConventionalRoute/ActionWithStringParameter | ConventionalRoute/ActionWithStringParameter/{id?} | ConventionalRouting | GET /ConventionalRoute/ActionWithStringParameter?num=3 | /ConventionalRoute/ActionWithStringParameter | +| :broken_heart: | [4](#4) | /ConventionalRoute/NotFound | | ConventionalRouting | GET /ConventionalRoute/NotFound | /ConventionalRoute/NotFound | +| :broken_heart: | [5](#5) | /SomePath/SomeString/2 | SomePath/{id}/{num:int} | ConventionalRouting | GET /SomePath/SomeString/2 | /SomePath/SomeString/2 | +| :broken_heart: | [6](#6) | /SomePath/SomeString/NotAnInt | | ConventionalRouting | GET /SomePath/SomeString/NotAnInt | /SomePath/SomeString/NotAnInt | +| :broken_heart: | [7](#7) | /MyArea | {area:exists}/ControllerForMyArea/Default/{id?} | ConventionalRouting | GET /MyArea | /MyArea | +| :broken_heart: | [8](#8) | /MyArea/ControllerForMyArea/NonDefault | {area:exists}/ControllerForMyArea/NonDefault/{id?} | ConventionalRouting | GET /MyArea/ControllerForMyArea/NonDefault | /MyArea/ControllerForMyArea/NonDefault | +| :broken_heart: | [9](#9) | /SomePrefix | SomePrefix/AnotherArea/Index/{id?} | ConventionalRouting | GET /SomePrefix | /SomePrefix | +| :green_heart: | [10](#10) | AttributeRoute | AttributeRoute | AttributeRouting | GET /AttributeRoute | AttributeRoute | +| :green_heart: | [11](#11) | AttributeRoute/Get | AttributeRoute/Get | AttributeRouting | GET /AttributeRoute/Get | AttributeRoute/Get | +| :green_heart: | [12](#12) | AttributeRoute/Get/{id} | AttributeRoute/Get/{id} | AttributeRouting | GET /AttributeRoute/Get/12 | AttributeRoute/Get/{id} | +| :green_heart: | [13](#13) | AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate | AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate | AttributeRouting | GET /AttributeRoute/12/GetWithActionNameInDifferentSpotInTemplate | AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate | +| :green_heart: | [14](#14) | AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate | AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate | AttributeRouting | GET /AttributeRoute/NotAnInt/GetWithActionNameInDifferentSpotInTemplate | AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate | +| :broken_heart: | [15](#15) | / | /Index | RazorPages | GET / | / | +| :broken_heart: | [16](#16) | Index | /Index | RazorPages | GET /Index | Index | +| :broken_heart: | [17](#17) | PageThatThrowsException | /PageThatThrowsException | RazorPages | GET /PageThatThrowsException | PageThatThrowsException | +| :broken_heart: | [18](#18) | /js/site.js | | RazorPages | GET /js/site.js | /js/site.js | +| :broken_heart: | [19](#19) | /MinimalApi | TBD | MinimalApi | GET /MinimalApi | /MinimalApi | +| :broken_heart: | [20](#20) | /MinimalApi/123 | TBD | MinimalApi | GET /MinimalApi/123 | /MinimalApi/123 | + +#### 1 + +```json +{ + "HttpMethod": "GET", + "Path": "/", + "HttpRouteByRawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "HttpRouteByControllerActionAndParameters": "ConventionalRoute/Default", + "HttpRouteByActionDescriptor": "ConventionalRoute/Default/{id?}", + "DebugInfo": { + "RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteDiagnosticMetadata": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteData": { + "controller": "ConventionalRoute", + "action": "Default" + }, + "AttributeRouteInfo": null, + "ActionParameters": [], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "ConventionalRoute", + "ControllerActionDescriptorActionName": "Default" + } +} +``` + +#### 2 + +```json +{ + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter/2?num=3", + "HttpRouteByRawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "HttpRouteByControllerActionAndParameters": "ConventionalRoute/ActionWithStringParameter/{id}/{num}", + "HttpRouteByActionDescriptor": "ConventionalRoute/ActionWithStringParameter/{id?}", + "DebugInfo": { + "RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteDiagnosticMetadata": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteData": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "2" + }, + "AttributeRouteInfo": null, + "ActionParameters": [ + "id", + "num" + ], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "ConventionalRoute", + "ControllerActionDescriptorActionName": "ActionWithStringParameter" + } +} +``` + +#### 3 + +```json +{ + "HttpMethod": "GET", + "Path": "/ConventionalRoute/ActionWithStringParameter?num=3", + "HttpRouteByRawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "HttpRouteByControllerActionAndParameters": "ConventionalRoute/ActionWithStringParameter/{id}/{num}", + "HttpRouteByActionDescriptor": "ConventionalRoute/ActionWithStringParameter/{id?}", + "DebugInfo": { + "RawText": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteDiagnosticMetadata": "{controller=ConventionalRoute}/{action=Default}/{id?}", + "RouteData": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter" + }, + "AttributeRouteInfo": null, + "ActionParameters": [ + "id", + "num" + ], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "ConventionalRoute", + "ControllerActionDescriptorActionName": "ActionWithStringParameter" + } +} +``` + +#### 4 + +```json +{ + "HttpMethod": "GET", + "Path": "/ConventionalRoute/NotFound", + "HttpRouteByRawText": null, + "HttpRouteByControllerActionAndParameters": "", + "HttpRouteByActionDescriptor": "", + "DebugInfo": { + "RawText": null, + "RouteDiagnosticMetadata": null, + "RouteData": {}, + "AttributeRouteInfo": null, + "ActionParameters": null, + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": null, + "ControllerActionDescriptorActionName": null + } +} +``` + +#### 5 + +```json +{ + "HttpMethod": "GET", + "Path": "/SomePath/SomeString/2", + "HttpRouteByRawText": "SomePath/{id}/{num:int}", + "HttpRouteByControllerActionAndParameters": "ConventionalRoute/ActionWithStringParameter/{id}/{num}", + "HttpRouteByActionDescriptor": "SomePath/{id}/{num:int}", + "DebugInfo": { + "RawText": "SomePath/{id}/{num:int}", + "RouteDiagnosticMetadata": "SomePath/{id}/{num:int}", + "RouteData": { + "controller": "ConventionalRoute", + "action": "ActionWithStringParameter", + "id": "SomeString", + "num": "2" + }, + "AttributeRouteInfo": null, + "ActionParameters": [ + "id", + "num" + ], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "ConventionalRoute", + "ControllerActionDescriptorActionName": "ActionWithStringParameter" + } +} +``` + +#### 6 + +```json +{ + "HttpMethod": "GET", + "Path": "/SomePath/SomeString/NotAnInt", + "HttpRouteByRawText": null, + "HttpRouteByControllerActionAndParameters": "", + "HttpRouteByActionDescriptor": "", + "DebugInfo": { + "RawText": null, + "RouteDiagnosticMetadata": null, + "RouteData": {}, + "AttributeRouteInfo": null, + "ActionParameters": null, + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": null, + "ControllerActionDescriptorActionName": null + } +} +``` + +#### 7 + +```json +{ + "HttpMethod": "GET", + "Path": "/MyArea", + "HttpRouteByRawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "HttpRouteByControllerActionAndParameters": "ControllerForMyArea/Default", + "HttpRouteByActionDescriptor": "{area:exists}/ControllerForMyArea/Default/{id?}", + "DebugInfo": { + "RawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteDiagnosticMetadata": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteData": { + "controller": "ControllerForMyArea", + "action": "Default", + "area": "MyArea" + }, + "AttributeRouteInfo": null, + "ActionParameters": [], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "ControllerForMyArea", + "ControllerActionDescriptorActionName": "Default" + } +} +``` + +#### 8 + +```json +{ + "HttpMethod": "GET", + "Path": "/MyArea/ControllerForMyArea/NonDefault", + "HttpRouteByRawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "HttpRouteByControllerActionAndParameters": "ControllerForMyArea/NonDefault", + "HttpRouteByActionDescriptor": "{area:exists}/ControllerForMyArea/NonDefault/{id?}", + "DebugInfo": { + "RawText": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteDiagnosticMetadata": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}", + "RouteData": { + "controller": "ControllerForMyArea", + "area": "MyArea", + "action": "NonDefault" + }, + "AttributeRouteInfo": null, + "ActionParameters": [], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "ControllerForMyArea", + "ControllerActionDescriptorActionName": "NonDefault" + } +} +``` + +#### 9 + +```json +{ + "HttpMethod": "GET", + "Path": "/SomePrefix", + "HttpRouteByRawText": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "HttpRouteByControllerActionAndParameters": "AnotherArea/Index", + "HttpRouteByActionDescriptor": "SomePrefix/AnotherArea/Index/{id?}", + "DebugInfo": { + "RawText": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "RouteDiagnosticMetadata": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}", + "RouteData": { + "area": "AnotherArea", + "controller": "AnotherArea", + "action": "Index" + }, + "AttributeRouteInfo": null, + "ActionParameters": [], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "AnotherArea", + "ControllerActionDescriptorActionName": "Index" + } +} +``` + +#### 10 + +```json +{ + "HttpMethod": "GET", + "Path": "/AttributeRoute", + "HttpRouteByRawText": "AttributeRoute", + "HttpRouteByControllerActionAndParameters": "AttributeRoute/Get", + "HttpRouteByActionDescriptor": "AttributeRoute", + "DebugInfo": { + "RawText": "AttributeRoute", + "RouteDiagnosticMetadata": "AttributeRoute", + "RouteData": { + "action": "Get", + "controller": "AttributeRoute" + }, + "AttributeRouteInfo": "AttributeRoute", + "ActionParameters": [], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "AttributeRoute", + "ControllerActionDescriptorActionName": "Get" + } +} +``` + +#### 11 + +```json +{ + "HttpMethod": "GET", + "Path": "/AttributeRoute/Get", + "HttpRouteByRawText": "AttributeRoute/Get", + "HttpRouteByControllerActionAndParameters": "AttributeRoute/Get", + "HttpRouteByActionDescriptor": "AttributeRoute/Get", + "DebugInfo": { + "RawText": "AttributeRoute/Get", + "RouteDiagnosticMetadata": "AttributeRoute/Get", + "RouteData": { + "action": "Get", + "controller": "AttributeRoute" + }, + "AttributeRouteInfo": "AttributeRoute/Get", + "ActionParameters": [], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "AttributeRoute", + "ControllerActionDescriptorActionName": "Get" + } +} +``` + +#### 12 + +```json +{ + "HttpMethod": "GET", + "Path": "/AttributeRoute/Get/12", + "HttpRouteByRawText": "AttributeRoute/Get/{id}", + "HttpRouteByControllerActionAndParameters": "AttributeRoute/Get/{id}", + "HttpRouteByActionDescriptor": "AttributeRoute/Get/{id}", + "DebugInfo": { + "RawText": "AttributeRoute/Get/{id}", + "RouteDiagnosticMetadata": "AttributeRoute/Get/{id}", + "RouteData": { + "action": "Get", + "controller": "AttributeRoute", + "id": "12" + }, + "AttributeRouteInfo": "AttributeRoute/Get/{id}", + "ActionParameters": [ + "id" + ], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "AttributeRoute", + "ControllerActionDescriptorActionName": "Get" + } +} +``` + +#### 13 + +```json +{ + "HttpMethod": "GET", + "Path": "/AttributeRoute/12/GetWithActionNameInDifferentSpotInTemplate", + "HttpRouteByRawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "HttpRouteByControllerActionAndParameters": "AttributeRoute/GetWithActionNameInDifferentSpotInTemplate/{id}", + "HttpRouteByActionDescriptor": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "DebugInfo": { + "RawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteDiagnosticMetadata": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteData": { + "action": "GetWithActionNameInDifferentSpotInTemplate", + "controller": "AttributeRoute", + "id": "12" + }, + "AttributeRouteInfo": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActionParameters": [ + "id" + ], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "AttributeRoute", + "ControllerActionDescriptorActionName": "GetWithActionNameInDifferentSpotInTemplate" + } +} +``` + +#### 14 + +```json +{ + "HttpMethod": "GET", + "Path": "/AttributeRoute/NotAnInt/GetWithActionNameInDifferentSpotInTemplate", + "HttpRouteByRawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "HttpRouteByControllerActionAndParameters": "AttributeRoute/GetWithActionNameInDifferentSpotInTemplate/{id}", + "HttpRouteByActionDescriptor": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "DebugInfo": { + "RawText": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteDiagnosticMetadata": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "RouteData": { + "action": "GetWithActionNameInDifferentSpotInTemplate", + "controller": "AttributeRoute", + "id": "NotAnInt" + }, + "AttributeRouteInfo": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate", + "ActionParameters": [ + "id" + ], + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": "AttributeRoute", + "ControllerActionDescriptorActionName": "GetWithActionNameInDifferentSpotInTemplate" + } +} +``` + +#### 15 + +```json +{ + "HttpMethod": "GET", + "Path": "/", + "HttpRouteByRawText": "", + "HttpRouteByControllerActionAndParameters": "", + "HttpRouteByActionDescriptor": "/Index", + "DebugInfo": { + "RawText": "", + "RouteDiagnosticMetadata": "", + "RouteData": { + "page": "/Index" + }, + "AttributeRouteInfo": "", + "ActionParameters": [], + "PageActionDescriptorRelativePath": "/Pages/Index.cshtml", + "PageActionDescriptorViewEnginePath": "/Index", + "ControllerActionDescriptorControllerName": null, + "ControllerActionDescriptorActionName": null + } +} +``` + +#### 16 + +```json +{ + "HttpMethod": "GET", + "Path": "/Index", + "HttpRouteByRawText": "Index", + "HttpRouteByControllerActionAndParameters": "", + "HttpRouteByActionDescriptor": "/Index", + "DebugInfo": { + "RawText": "Index", + "RouteDiagnosticMetadata": "Index", + "RouteData": { + "page": "/Index" + }, + "AttributeRouteInfo": "Index", + "ActionParameters": [], + "PageActionDescriptorRelativePath": "/Pages/Index.cshtml", + "PageActionDescriptorViewEnginePath": "/Index", + "ControllerActionDescriptorControllerName": null, + "ControllerActionDescriptorActionName": null + } +} +``` + +#### 17 + +```json +{ + "HttpMethod": "GET", + "Path": "/PageThatThrowsException", + "HttpRouteByRawText": "PageThatThrowsException", + "HttpRouteByControllerActionAndParameters": "", + "HttpRouteByActionDescriptor": "/PageThatThrowsException", + "DebugInfo": { + "RawText": "PageThatThrowsException", + "RouteDiagnosticMetadata": "PageThatThrowsException", + "RouteData": { + "page": "/PageThatThrowsException" + }, + "AttributeRouteInfo": "PageThatThrowsException", + "ActionParameters": [], + "PageActionDescriptorRelativePath": "/Pages/PageThatThrowsException.cshtml", + "PageActionDescriptorViewEnginePath": "/PageThatThrowsException", + "ControllerActionDescriptorControllerName": null, + "ControllerActionDescriptorActionName": null + } +} +``` + +#### 18 + +```json +{ + "HttpMethod": "GET", + "Path": "/js/site.js", + "HttpRouteByRawText": null, + "HttpRouteByControllerActionAndParameters": "", + "HttpRouteByActionDescriptor": "", + "DebugInfo": { + "RawText": null, + "RouteDiagnosticMetadata": null, + "RouteData": {}, + "AttributeRouteInfo": null, + "ActionParameters": null, + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": null, + "ControllerActionDescriptorActionName": null + } +} +``` + +#### 19 + +```json +{ + "HttpMethod": "GET", + "Path": "/MinimalApi", + "HttpRouteByRawText": null, + "HttpRouteByControllerActionAndParameters": "", + "HttpRouteByActionDescriptor": "", + "DebugInfo": { + "RawText": null, + "RouteDiagnosticMetadata": null, + "RouteData": {}, + "AttributeRouteInfo": null, + "ActionParameters": null, + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": null, + "ControllerActionDescriptorActionName": null + } +} +``` + +#### 20 + +```json +{ + "HttpMethod": "GET", + "Path": "/MinimalApi/123", + "HttpRouteByRawText": null, + "HttpRouteByControllerActionAndParameters": "", + "HttpRouteByActionDescriptor": "", + "DebugInfo": { + "RawText": null, + "RouteDiagnosticMetadata": null, + "RouteData": {}, + "AttributeRouteInfo": null, + "ActionParameters": null, + "PageActionDescriptorRelativePath": null, + "PageActionDescriptorViewEnginePath": null, + "ControllerActionDescriptorControllerName": null, + "ControllerActionDescriptorActionName": null + } +} +``` diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/ReadmeGenerator.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/ReadmeGenerator.cs new file mode 100644 index 00000000000..beefcaca3d6 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/ReadmeGenerator.cs @@ -0,0 +1,78 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable disable + +using System.Text; +using RouteTests; + +public class ReadmeGenerator +{ + public static void Main() + { + var sb = new StringBuilder(); + + var testCases = RoutingTests.TestData; + + var results = new List(); + + foreach (var item in testCases) + { + using var tests = new RoutingTests(); + var testCase = item[0] as RouteTestData.RouteTestCase; + var result = tests.TestRoutes(testCase!).GetAwaiter().GetResult(); + results.Add(result); + } + + sb.AppendLine("| | | display name | expected name (w/o http.method) | routing type | request |"); + sb.AppendLine("| - | - | - | - | - | - |"); + + for (var i = 0; i < results.Count; ++i) + { + var result = results[i]; + var emoji = result.ActivityDisplayName.Equals(result.TestCase.ExpectedHttpRoute, StringComparison.InvariantCulture) + ? ":green_heart:" + : ":broken_heart:"; + sb.Append($"| {emoji} | [{i + 1}](#{i + 1}) "); + sb.AppendLine(FormatTestResult(results[i])); + } + + for (var i = 0; i < results.Count; ++i) + { + sb.AppendLine(); + sb.AppendLine($"#### {i + 1}"); + sb.AppendLine(); + sb.AppendLine("```json"); + sb.AppendLine(results[i].RouteInfo.ToString()); + sb.AppendLine("```"); + } + + File.WriteAllText("README.md", sb.ToString()); + + string FormatTestResult(TestResult result) + { + var testCase = result.TestCase!; + + return $"| {string.Join( + " | ", + result.ActivityDisplayName, // TODO: should be result.HttpRoute, but http.route is not currently added to Activity + testCase.ExpectedHttpRoute, + testCase.TestApplicationScenario, + $"{testCase.HttpMethod} {testCase.Path}", + result.ActivityDisplayName)} |"; + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/ReadmeGenerator.csproj b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/ReadmeGenerator.csproj new file mode 100644 index 00000000000..2546cd772db --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/ReadmeGenerator.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + ReadmeGenerator + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + RouteTests.testcases.json + Always + + + + diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteInfo.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteInfo.cs new file mode 100644 index 00000000000..593358a9779 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteInfo.cs @@ -0,0 +1,137 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable enable + +using System.Text.Json; +using Microsoft.AspNetCore.Http; +#if NET8_0_OR_GREATER +using Microsoft.AspNetCore.Http.Metadata; +#endif +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Routing; + +namespace RouteTests; + +public class RouteInfo +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new() { WriteIndented = true }; + + public RouteInfo() + { + this.DebugInfo = new DebugInfo(); + } + + public string? HttpMethod { get; set; } + + public string? Path { get; set; } + + public string? HttpRouteByRawText => this.DebugInfo.RawText; + + public string HttpRouteByControllerActionAndParameters + { + get + { + var condition = !string.IsNullOrEmpty(this.DebugInfo.ControllerActionDescriptorActionName) + && !string.IsNullOrEmpty(this.DebugInfo.ControllerActionDescriptorActionName) + && this.DebugInfo.ActionParameters != null; + + if (!condition) + { + return string.Empty; + } + + var paramList = string.Join(string.Empty, this.DebugInfo.ActionParameters!.Select(p => $"/{{{p}}}")); + return $"{this.DebugInfo.ControllerActionDescriptorControllerName}/{this.DebugInfo.ControllerActionDescriptorActionName}{paramList}"; + } + } + + public string HttpRouteByActionDescriptor + { + get + { + var result = string.Empty; + + var hasControllerActionDescriptor = this.DebugInfo.ControllerActionDescriptorControllerName != null + && this.DebugInfo.ControllerActionDescriptorActionName != null; + + var hasPageActionDescriptor = this.DebugInfo.PageActionDescriptorRelativePath != null + && this.DebugInfo.PageActionDescriptorViewEnginePath != null; + + if (this.DebugInfo.RawText != null && hasControllerActionDescriptor) + { + var controllerRegex = new System.Text.RegularExpressions.Regex(@"\{controller=.*?\}+?"); + var actionRegex = new System.Text.RegularExpressions.Regex(@"\{action=.*?\}+?"); + result = controllerRegex.Replace(this.DebugInfo.RawText, this.DebugInfo.ControllerActionDescriptorControllerName!); + result = actionRegex.Replace(result, this.DebugInfo.ControllerActionDescriptorActionName!); + } + else if (this.DebugInfo.RawText != null && hasPageActionDescriptor) + { + result = this.DebugInfo.PageActionDescriptorViewEnginePath!; + } + + return result; + } + } + + public DebugInfo DebugInfo { 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.DebugInfo.RawText = (endpoint as RouteEndpoint)?.RoutePattern.RawText; +#if NET8_0_OR_GREATER + this.DebugInfo.RouteDiagnosticMetadata = endpoint?.Metadata.GetMetadata()?.Route; +#endif + this.DebugInfo.RouteData = new Dictionary(); + foreach (var value in context.GetRouteData().Values) + { + this.DebugInfo.RouteData[value.Key] = value.Value?.ToString(); + } + } + + public void SetValues(ActionDescriptor actionDescriptor) + { + this.DebugInfo.AttributeRouteInfo = actionDescriptor.AttributeRouteInfo?.Template; + + this.DebugInfo.ActionParameters = new List(); + foreach (var item in actionDescriptor.Parameters) + { + this.DebugInfo.ActionParameters.Add(item.Name); + } + + if (actionDescriptor is PageActionDescriptor pad) + { + this.DebugInfo.PageActionDescriptorRelativePath = pad.RelativePath; + this.DebugInfo.PageActionDescriptorViewEnginePath = pad.ViewEnginePath; + } + + if (actionDescriptor is ControllerActionDescriptor cad) + { + this.DebugInfo.ControllerActionDescriptorControllerName = cad.ControllerName; + this.DebugInfo.ControllerActionDescriptorActionName = cad.ActionName; + } + } + + public override string ToString() + { + return JsonSerializer.Serialize(this, JsonSerializerOptions); + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteInfoMiddleware.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteInfoMiddleware.cs new file mode 100644 index 00000000000..0623884e74d --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteInfoMiddleware.cs @@ -0,0 +1,83 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable disable + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using static System.Net.Mime.MediaTypeNames; + +namespace RouteTests; + +public class RouteInfoMiddleware +{ + private readonly RequestDelegate next; + + public RouteInfoMiddleware(RequestDelegate next) + { + this.next = next; + } + + public static void ConfigureExceptionHandler(IApplicationBuilder builder) + { + builder.Run(async context => + { + context.Response.Body = (context.Items["originBody"] as Stream)!; + + context.Response.ContentType = Application.Json; + + var info = context.Items["RouteInfo"] as RouteInfo; + Debug.Assert(info != null, "RouteInfo object not present in context.Items"); + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + string modifiedResponse = JsonSerializer.Serialize(info, jsonOptions); + await context.Response.WriteAsync(modifiedResponse); + }); + } + + public async Task InvokeAsync(HttpContext context) + { + var response = context.Response; + + var originBody = response.Body; + context.Items["originBody"] = originBody; + using var newBody = new MemoryStream(); + response.Body = newBody; + + await this.next(context); + + var stream = response.Body; + using var reader = new StreamReader(stream, leaveOpen: true); + var originalResponse = await reader.ReadToEndAsync(); + + var info = context.Items["RouteInfo"] as RouteInfo; + Debug.Assert(info != null, "RouteInfo object not present in context.Items"); + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + string modifiedResponse = JsonSerializer.Serialize(info, jsonOptions); + + stream.SetLength(0); + using var writer = new StreamWriter(stream, leaveOpen: true); + await writer.WriteAsync(modifiedResponse); + await writer.FlushAsync(); + response.ContentLength = stream.Length; + response.ContentType = "application/json"; + + newBody.Seek(0, SeekOrigin.Begin); + await newBody.CopyToAsync(originBody); + response.Body = originBody; + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteTestData.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteTestData.cs new file mode 100644 index 00000000000..f4f5b5c65d0 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RouteTestData.cs @@ -0,0 +1,82 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable enable + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RouteTests; + +public static class RouteTestData +{ + public static IEnumerable GetTestCases() + { + var assembly = Assembly.GetExecutingAssembly(); + var input = JsonSerializer.Deserialize( + assembly.GetManifestResourceStream("RouteTests.testcases.json")!, + new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + }); + return GetArgumentsFromTestCaseObject(input!); + } + + private static IEnumerable GetArgumentsFromTestCaseObject(IEnumerable input) + { + var result = new List(); + + if (input.Any(x => x.Debug)) + { + foreach (var testCase in input.Where(x => x.Debug)) + { + result.Add(new object[] + { + testCase, + }); + } + } + else + { + foreach (var testCase in input) + { + result.Add(new object[] + { + testCase, + }); + } + } + + return result; + } + + public class RouteTestCase + { + public bool Debug { get; set; } + + public TestApplicationScenario TestApplicationScenario { get; set; } + + public string? HttpMethod { get; set; } + + public string? Path { get; set; } + + public int ExpectedStatusCode { get; set; } + + public string? ExpectedHttpRoute { get; set; } + } +} 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 00000000000..d77cf674fff --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/RoutingTests.cs @@ -0,0 +1,175 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable enable + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Xunit; + +namespace RouteTests; + +public class RoutingTests : IDisposable +{ + private const string HttpStatusCode = "http.status_code"; + private const string HttpMethod = "http.method"; + private const string HttpRoute = "http.route"; + + private TracerProvider tracerProvider; + private MeterProvider meterProvider; + private WebApplication? app; + private HttpClient client; + private List exportedActivities; + private List exportedMetrics; + private AspNetCoreDiagnosticObserver diagnostics; + + public RoutingTests() + { + this.diagnostics = new AspNetCoreDiagnosticObserver(); + this.client = new HttpClient { BaseAddress = new Uri("http://localhost:5000") }; + + this.exportedActivities = new List(); + this.exportedMetrics = new List(); + + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(this.exportedActivities) + .Build()!; + + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(this.exportedMetrics) + .Build()!; + } + + public static IEnumerable TestData => RouteTestData.GetTestCases(); + +#pragma warning disable xUnit1028 + [Theory] + [MemberData(nameof(TestData))] + public async Task TestRoutes(RouteTestData.RouteTestCase testCase, bool skipAsserts = true) + { + this.app = TestApplicationFactory.CreateApplication(testCase.TestApplicationScenario); + var appTask = this.app.RunAsync(); + + var responseMessage = await this.client.GetAsync(testCase.Path).ConfigureAwait(false); + var response = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); + + var info = JsonSerializer.Deserialize(response); + + for (var i = 0; i < 10; i++) + { + if (this.exportedActivities.Count > 0) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + + this.meterProvider.ForceFlush(); + + Assert.Single(this.exportedActivities); + Assert.Single(this.exportedMetrics); + + var metricPoints = new List(); + foreach (var mp in this.exportedMetrics[0].GetMetricPoints()) + { + metricPoints.Add(mp); + } + + Assert.Single(metricPoints); + + var activity = this.exportedActivities[0]; + var metricPoint = metricPoints.First(); + + this.GetTagsFromActivity(activity, out var activityHttpStatusCode, out var activityHttpMethod, out var activityHttpRoute); + this.GetTagsFromMetricPoint(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); + + if (!skipAsserts) + { + Assert.Equal(testCase.ExpectedHttpRoute, activityHttpRoute); + Assert.Equal(testCase.ExpectedHttpRoute, metricHttpRoute); + + var expectedActivityDisplayName = string.IsNullOrEmpty(testCase.ExpectedHttpRoute) + ? testCase.HttpMethod + : $"{testCase.HttpMethod} {testCase.ExpectedHttpRoute}"; + Assert.Equal(expectedActivityDisplayName, activity.DisplayName); + } + + return new TestResult + { + ActivityDisplayName = activity.DisplayName, + HttpStatusCode = activityHttpStatusCode, + HttpMethod = activityHttpMethod, + HttpRoute = activityHttpRoute, + RouteInfo = info!, + TestCase = testCase, + }; + } +#pragma warning restore xUnit1028 + + public async void Dispose() + { + this.tracerProvider.Dispose(); + this.meterProvider.Dispose(); + this.diagnostics.Dispose(); + this.client.Dispose(); + if (this.app != null) + { + await this.app.DisposeAsync().ConfigureAwait(false); + } + } + + private void GetTagsFromActivity(Activity activity, out int httpStatusCode, out string httpMethod, out string? httpRoute) + { + httpStatusCode = Convert.ToInt32(activity.GetTagItem(HttpStatusCode)); + httpMethod = (activity.GetTagItem(HttpMethod) as string)!; + httpRoute = activity.GetTagItem(HttpRoute) as string ?? string.Empty; + } + + private void GetTagsFromMetricPoint(MetricPoint metricPoint, out int httpStatusCode, out string httpMethod, out string? httpRoute) + { + httpStatusCode = 0; + httpMethod = string.Empty; + httpRoute = string.Empty; + + foreach (var tag in metricPoint.Tags) + { + if (tag.Key.Equals(HttpStatusCode)) + { + httpStatusCode = Convert.ToInt32(tag.Value); + } + else if (tag.Key.Equals(HttpMethod)) + { + 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/TestApplicationFactory.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplicationFactory.cs new file mode 100644 index 00000000000..bac22256c21 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplicationFactory.cs @@ -0,0 +1,163 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable disable + +using System.Diagnostics; +using Microsoft.AspNetCore.Builder; +#if NET7_0_OR_GREATER +using Microsoft.AspNetCore.Http; +#endif +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; + +namespace RouteTests; + +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, +} + +internal class TestApplicationFactory +{ + private static readonly string AspNetCoreTestsPath = new FileInfo(typeof(RoutingTests)!.Assembly!.Location)!.Directory!.Parent!.Parent!.Parent!.FullName; + private static readonly string ContentRootPath = AspNetCoreTestsPath.EndsWith("RouteTests") + ? AspNetCoreTestsPath + : Path.Combine(AspNetCoreTestsPath, "RouteTests"); + + 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(); + default: + throw new ArgumentException($"Invalid {nameof(TestApplicationScenario)}"); + } + } + + private static WebApplication CreateConventionalRoutingApplication() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = ContentRootPath }); + builder.Services + .AddControllersWithViews() + .AddApplicationPart(typeof(RoutingTests).Assembly); + + var app = builder.Build(); + app.UseExceptionHandler(RouteInfoMiddleware.ConfigureExceptionHandler); + app.UseMiddleware(); + 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.Services + .AddControllers() + .AddApplicationPart(typeof(RoutingTests).Assembly); + + var app = builder.Build(); + app.UseExceptionHandler(RouteInfoMiddleware.ConfigureExceptionHandler); + app.UseMiddleware(); + app.MapControllers(); + + return app; + } + + private static WebApplication CreateMinimalApiApplication() + { + var builder = WebApplication.CreateBuilder(); // WebApplication.CreateSlimBuilder(); + + var app = builder.Build(); + app.UseExceptionHandler(RouteInfoMiddleware.ConfigureExceptionHandler); + app.UseMiddleware(); + +#if NET7_0_OR_GREATER + var api = app.MapGroup("/MinimalApi"); + 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.Services + .AddRazorPages() + .AddRazorRuntimeCompilation(options => + { + options.FileProviders.Add(new PhysicalFileProvider(ContentRootPath)); + }) + .AddApplicationPart(typeof(RoutingTests).Assembly); + + var app = builder.Build(); + app.UseExceptionHandler(RouteInfoMiddleware.ConfigureExceptionHandler); + app.UseMiddleware(); + app.UseStaticFiles(); + app.UseRouting(); + app.MapRazorPages(); + + return app; + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestResult.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestResult.cs new file mode 100644 index 00000000000..f94f8379c96 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestResult.cs @@ -0,0 +1,34 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable disable + +namespace RouteTests; + +public class TestResult +{ + public string ActivityDisplayName { get; set; } = string.Empty; + + public int HttpStatusCode { get; set; } + + public string HttpMethod { get; set; } = string.Empty; + + public string HttpRoute { get; set; } = string.Empty; + + public RouteInfo RouteInfo { get; set; } = new RouteInfo(); + + public RouteTestData.RouteTestCase TestCase { get; set; } = new RouteTestData.RouteTestCase(); +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/testcases.json b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/testcases.json new file mode 100644 index 00000000000..9057f8b2e2b --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/testcases.json @@ -0,0 +1,142 @@ +[ + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/", + "expectedStatusCode": 200, + "expectedHttpRoute": "ConventionalRoute/Default/{id?}" + }, + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/ConventionalRoute/ActionWithStringParameter/2?num=3", + "expectedStatusCode": 200, + "expectedHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}" + }, + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/ConventionalRoute/ActionWithStringParameter?num=3", + "expectedStatusCode": 200, + "expectedHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}" + }, + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/ConventionalRoute/NotFound", + "expectedStatusCode": 404, + "expectedHttpRoute": "" + }, + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/SomePath/SomeString/2", + "expectedStatusCode": 200, + "expectedHttpRoute": "SomePath/{id}/{num:int}" + }, + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/SomePath/SomeString/NotAnInt", + "expectedStatusCode": 404, + "expectedHttpRoute": "" + }, + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/MyArea", + "expectedStatusCode": 200, + "expectedHttpRoute": "{area:exists}/ControllerForMyArea/Default/{id?}" + }, + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/MyArea/ControllerForMyArea/NonDefault", + "expectedStatusCode": 200, + "expectedHttpRoute": "{area:exists}/ControllerForMyArea/NonDefault/{id?}" + }, + { + "testApplicationScenario": "ConventionalRouting", + "httpMethod": "GET", + "path": "/SomePrefix", + "expectedStatusCode": 200, + "expectedHttpRoute": "SomePrefix/AnotherArea/Index/{id?}" + }, + { + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute", + "expectedStatusCode": 200, + "expectedHttpRoute": "AttributeRoute" + }, + { + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute/Get", + "expectedStatusCode": 200, + "expectedHttpRoute": "AttributeRoute/Get" + }, + { + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute/Get/12", + "expectedStatusCode": 200, + "expectedHttpRoute": "AttributeRoute/Get/{id}" + }, + { + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute/12/GetWithActionNameInDifferentSpotInTemplate", + "expectedStatusCode": 200, + "expectedHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate" + }, + { + "testApplicationScenario": "AttributeRouting", + "httpMethod": "GET", + "path": "/AttributeRoute/NotAnInt/GetWithActionNameInDifferentSpotInTemplate", + "expectedStatusCode": 400, + "expectedHttpRoute": "AttributeRoute/{id}/GetWithActionNameInDifferentSpotInTemplate" + }, + { + "testApplicationScenario": "RazorPages", + "httpMethod": "GET", + "path": "/", + "expectedStatusCode": 200, + "expectedHttpRoute": "/Index" + }, + { + "testApplicationScenario": "RazorPages", + "httpMethod": "GET", + "path": "/Index", + "expectedStatusCode": 200, + "expectedHttpRoute": "/Index" + }, + { + "testApplicationScenario": "RazorPages", + "httpMethod": "GET", + "path": "/PageThatThrowsException", + "expectedStatusCode": 500, + "expectedHttpRoute": "/PageThatThrowsException" + }, + { + "testApplicationScenario": "RazorPages", + "httpMethod": "GET", + "path": "/js/site.js", + "expectedStatusCode": 200, + "expectedHttpRoute": "" + }, + { + "testApplicationScenario": "MinimalApi", + "httpMethod": "GET", + "path": "/MinimalApi", + "expectedStatusCode": 200, + "expectedHttpRoute": "TBD" + }, + { + "testApplicationScenario": "MinimalApi", + "httpMethod": "GET", + "path": "/MinimalApi/123", + "expectedStatusCode": 200, + "expectedHttpRoute": "TBD" + } +] diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/wwwroot/js/site.js b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/wwwroot/js/site.js new file mode 100644 index 00000000000..dcc7262061a --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/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.