From b1fb1356f2e24ccb04edfa2f4298f93ec9157605 Mon Sep 17 00:00:00 2001 From: Russ Cam Date: Fri, 19 Feb 2021 10:54:31 +1000 Subject: [PATCH 1/2] Get transaction name from Web API controller route template This commit updates ElasticApmModule to get the transaction name for a Web API controller routed with attribute routing from the "MS_SubRoutes" route data value. A delegate to get the template from IHttpRouteData when System.Web.Http is referenced. Closes #1176 --- .../AspNetFullFrameworkSampleApp.csproj | 1 + .../AttributeRoutingWebApiController.cs | 21 ++++++ .../ElasticApmModule.cs | 64 ++++++++++++++++++- .../Reflection/ExpressionBuilder.cs | 38 +++++++++++ .../TestsBase.cs | 3 + .../TransactionNameTests.cs | 15 +++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 sample/AspNetFullFrameworkSampleApp/Controllers/AttributeRoutingWebApiController.cs create mode 100644 src/Elastic.Apm/Reflection/ExpressionBuilder.cs diff --git a/sample/AspNetFullFrameworkSampleApp/AspNetFullFrameworkSampleApp.csproj b/sample/AspNetFullFrameworkSampleApp/AspNetFullFrameworkSampleApp.csproj index 3d6f77ef7..746d160db 100644 --- a/sample/AspNetFullFrameworkSampleApp/AspNetFullFrameworkSampleApp.csproj +++ b/sample/AspNetFullFrameworkSampleApp/AspNetFullFrameworkSampleApp.csproj @@ -142,6 +142,7 @@ + diff --git a/sample/AspNetFullFrameworkSampleApp/Controllers/AttributeRoutingWebApiController.cs b/sample/AspNetFullFrameworkSampleApp/Controllers/AttributeRoutingWebApiController.cs new file mode 100644 index 000000000..77e845f2b --- /dev/null +++ b/sample/AspNetFullFrameworkSampleApp/Controllers/AttributeRoutingWebApiController.cs @@ -0,0 +1,21 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Web.Http; + +namespace AspNetFullFrameworkSampleApp.Controllers +{ + [RoutePrefix(RoutePrefix)] + public class AttributeRoutingWebApiController : ApiController + { + public const string RoutePrefix = "api/AttributeRoutingWebApi"; + public const string Route = "{id}"; + + [HttpGet] + [Route(Route)] + public IHttpActionResult Get(string id) => + Ok($"attributed routed web api controller {id}"); + } +} diff --git a/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs b/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs index 97bac37ae..b758988d1 100644 --- a/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs +++ b/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System; +using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; @@ -15,6 +16,7 @@ using Elastic.Apm.Helpers; using Elastic.Apm.Logging; using Elastic.Apm.Model; +using Elastic.Apm.Reflection; namespace Elastic.Apm.AspNetFullFramework { @@ -30,6 +32,8 @@ public class ElasticApmModule : IHttpModule private readonly string _dbgInstanceName; private HttpApplication _application; private IApmLogger _logger; + private Type _httpRouteDataInterfaceType; + private Func _routeDataTemplateGetter; public ElasticApmModule() => // ReSharper disable once ImpureMethodCallOnReadonlyValueField @@ -77,6 +81,7 @@ private void InitImpl(HttpApplication application) PlatformDetection.DotNetRuntimeDescription, HttpRuntime.IISVersion); } + _routeDataTemplateGetter = CreateWebApiAttributeRouteTemplateGetter(); _application = application; _application.BeginRequest += OnBeginRequest; _application.EndRequest += OnEndRequest; @@ -285,8 +290,31 @@ private void ProcessEndRequest(object sender) else routeData = values; - _logger?.Trace()?.Log("Calculating transaction name based on route data"); - var name = Transaction.GetNameFromRouteContext(routeData); + string name = null; + + // if we're dealing with Web API attribute routing, get transaction name from the route template + if (routeData.TryGetValue("MS_SubRoutes", out var template) && _httpRouteDataInterfaceType != null) + { + if (template is IEnumerable enumerable) + { + var enumerator = enumerable.GetEnumerator(); + if (enumerator.MoveNext()) + { + var subRoute = enumerator.Current; + if (subRoute != null && _httpRouteDataInterfaceType.IsInstanceOfType(subRoute)) + { + _logger?.Trace()?.Log("Calculating transaction name from web api attribute routing"); + name = _routeDataTemplateGetter(subRoute); + } + } + } + } + else + { + _logger?.Trace()?.Log("Calculating transaction name based on route data"); + name = Transaction.GetNameFromRouteContext(routeData); + } + if (!string.IsNullOrWhiteSpace(name)) transaction.Name = $"{context.Request.HttpMethod} {name}"; } else @@ -427,5 +455,37 @@ private static void SafeAgentSetup(string dbgInstanceName) agentComponents.Dispose(); } } + + /// + /// Compiles a delegate from a lambda expression to get the route template from HttpRouteData when + /// System.Web.Http is referenced. + /// + private Func CreateWebApiAttributeRouteTemplateGetter() + { + _httpRouteDataInterfaceType = Type.GetType("System.Web.Http.Routing.IHttpRouteData,System.Web.Http"); + if (_httpRouteDataInterfaceType != null) + { + var routePropertyInfo = _httpRouteDataInterfaceType.GetProperty("Route"); + if (routePropertyInfo != null) + { + var routeType = routePropertyInfo.PropertyType; + var routeTemplatePropertyInfo = routeType.GetProperty("RouteTemplate"); + if (routeTemplatePropertyInfo != null) + { + var routePropertyGetter = ExpressionBuilder.BuildPropertyGetter(_httpRouteDataInterfaceType, routePropertyInfo); + var routeTemplatePropertyGetter = ExpressionBuilder.BuildPropertyGetter(routeType, routeTemplatePropertyInfo); + return routeData => + { + var route = routePropertyGetter(routeData); + return route is null + ? null + : routeTemplatePropertyGetter(route) as string; + }; + } + } + } + + return null; + } } } diff --git a/src/Elastic.Apm/Reflection/ExpressionBuilder.cs b/src/Elastic.Apm/Reflection/ExpressionBuilder.cs new file mode 100644 index 000000000..c4c8ba4c5 --- /dev/null +++ b/src/Elastic.Apm/Reflection/ExpressionBuilder.cs @@ -0,0 +1,38 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Linq.Expressions; +using System.Reflection; + +namespace Elastic.Apm.Reflection +{ + internal class ExpressionBuilder + { + /// + /// Builds a delegate to get a property of type from an object + /// of type + /// + public static Func BuildPropertyGetter(string propertyName) + { + var parameterExpression = Expression.Parameter(typeof(TObject), "value"); + var memberExpression = Expression.Property(parameterExpression, propertyName); + return Expression.Lambda>(memberExpression, parameterExpression).Compile(); + } + + /// + /// Builds a delegate to get a property from an object. is cast to , + /// with the returned property cast to . + /// + public static Func BuildPropertyGetter(Type type, PropertyInfo propertyInfo) + { + var parameterExpression = Expression.Parameter(typeof(object), "value"); + var parameterCastExpression = Expression.Convert(parameterExpression, type); + var memberExpression = Expression.Property(parameterCastExpression, propertyInfo); + var returnCastExpression = Expression.Convert(memberExpression, typeof(object)); + return Expression.Lambda>(returnCastExpression, parameterExpression).Compile(); + } + } +} diff --git a/test/Elastic.Apm.AspNetFullFramework.Tests/TestsBase.cs b/test/Elastic.Apm.AspNetFullFramework.Tests/TestsBase.cs index 671e2af2e..065cad948 100644 --- a/test/Elastic.Apm.AspNetFullFramework.Tests/TestsBase.cs +++ b/test/Elastic.Apm.AspNetFullFramework.Tests/TestsBase.cs @@ -187,6 +187,9 @@ internal static class SampleAppUrlPaths internal static readonly SampleAppUrlPathData WebApiPage = new SampleAppUrlPathData(WebApiController.Path, 200); + internal static SampleAppUrlPathData AttributeRoutingWebApiPage(string id) => + new SampleAppUrlPathData(AttributeRoutingWebApiController.RoutePrefix + "/" + id, 200); + internal static readonly SampleAppUrlPathData WebformsPage = new SampleAppUrlPathData(nameof(Webforms) + ".aspx", 200); diff --git a/test/Elastic.Apm.AspNetFullFramework.Tests/TransactionNameTests.cs b/test/Elastic.Apm.AspNetFullFramework.Tests/TransactionNameTests.cs index 4e8e6b570..1c2e7209a 100644 --- a/test/Elastic.Apm.AspNetFullFramework.Tests/TransactionNameTests.cs +++ b/test/Elastic.Apm.AspNetFullFramework.Tests/TransactionNameTests.cs @@ -8,6 +8,7 @@ using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; +using AspNetFullFrameworkSampleApp.Controllers; using FluentAssertions; using Xunit; using Xunit.Abstractions; @@ -118,6 +119,20 @@ await WaitAndCustomVerifyReceivedData(receivedData => }); } + [AspNetFullFrameworkFact] + public async Task Name_Should_Be_RouteTemplate_When_WebApi_Attribute_Routing() + { + var pathData = SampleAppUrlPaths.AttributeRoutingWebApiPage("foo"); + await SendGetRequestToSampleAppAndVerifyResponse(pathData.Uri, pathData.StatusCode); + + await WaitAndCustomVerifyReceivedData(receivedData => + { + receivedData.Transactions.Count.Should().Be(1); + var transaction = receivedData.Transactions.Single(); + transaction.Name.Should().Be($"GET {AttributeRoutingWebApiController.RoutePrefix}/{AttributeRoutingWebApiController.Route}"); + }); + } + [AspNetFullFrameworkFact] public async Task Name_Should_Be_Path_When_Webforms_Page() { From 93c162a211fe590bb870033f3770db16ac94a7c8 Mon Sep 17 00:00:00 2001 From: Russ Cam Date: Tue, 9 Mar 2021 10:18:56 +1000 Subject: [PATCH 2/2] update VS choco packages --- .ci/windows/msbuild-tools.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ci/windows/msbuild-tools.ps1 b/.ci/windows/msbuild-tools.ps1 index d75a0f729..b761d1c03 100644 --- a/.ci/windows/msbuild-tools.ps1 +++ b/.ci/windows/msbuild-tools.ps1 @@ -3,12 +3,12 @@ # # Install visualstudio2019buildtools -choco install visualstudio2019buildtools -m -y --no-progress --force -r --version=16.8.5.0 +choco install visualstudio2019buildtools -m -y --no-progress --force -r --version=16.9.0.0 if ($LASTEXITCODE -ne 0) { Write-Host "visualstudio2019buildtools installation failed." exit 1 } -choco install visualstudio2019professional -m -y --no-progress --force -r --version=16.8.5.0 --package-parameters "--includeRecommended --includeOptional" +choco install visualstudio2019professional -m -y --no-progress --force -r --version=16.9.0.0 --package-parameters "--includeRecommended --includeOptional" if ($LASTEXITCODE -ne 0) { Write-Host "visualstudio2019professional installation failed." exit 1