diff --git a/tracer/build/supported_calltargets.g.json b/tracer/build/supported_calltargets.g.json index e88f055d8947..cde0f5b56113 100644 --- a/tracer/build/supported_calltargets.g.json +++ b/tracer/build/supported_calltargets.g.json @@ -1569,7 +1569,7 @@ "InstrumentationTypeName": "Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ControllerActionInvoker_InvokeAction_Integration", "IntegrationKind": 0, "IsAdoNetIntegration": false, - "InstrumentationCategory": 6 + "InstrumentationCategory": 7 }, { "IntegrationName": "AspNetWebApi2", @@ -1620,7 +1620,7 @@ "InstrumentationTypeName": "Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ReflectedHttpActionDescriptor_ExecuteAsync_Integration", "IntegrationKind": 0, "IsAdoNetIntegration": false, - "InstrumentationCategory": 6 + "InstrumentationCategory": 7 }, { "IntegrationName": "AspNetWebApi2", diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ActionDescriptorWithMethodInfo.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ActionDescriptorWithMethodInfo.cs new file mode 100644 index 000000000000..7cfe5eac00aa --- /dev/null +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ActionDescriptorWithMethodInfo.cs @@ -0,0 +1,25 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#if NETFRAMEWORK +#nullable enable + +using System.Reflection; +using Datadog.Trace.DuckTyping; + +namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet +{ + /// + /// Duck type for MVC/WebApi2 descriptors that expose a MethodInfo. + /// + [DuckCopy] + internal struct ActionDescriptorWithMethodInfo + { + [Duck] + public MethodInfo MethodInfo; + } +} +#endif + diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ControllerActionInvoker_InvokeAction_Integration.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ControllerActionInvoker_InvokeAction_Integration.cs index 370917f4cd3b..e611bbdc1439 100644 --- a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ControllerActionInvoker_InvokeAction_Integration.cs +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ControllerActionInvoker_InvokeAction_Integration.cs @@ -8,14 +8,18 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Reflection; using System.Web; using Datadog.Trace.AppSec; using Datadog.Trace.AppSec.Coordinator; using Datadog.Trace.AspNet; using Datadog.Trace.ClrProfiler.CallTarget; using Datadog.Trace.Configuration; +using Datadog.Trace.Debugger; +using Datadog.Trace.Debugger.SpanCodeOrigin; using Datadog.Trace.DuckTyping; using Datadog.Trace.Logging; +using Datadog.Trace.Vendors.Serilog.Events; namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet { @@ -31,7 +35,7 @@ namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet MinimumVersion = "4", MaximumVersion = "5", IntegrationName = IntegrationName, - InstrumentationCategory = InstrumentationCategory.AppSec | InstrumentationCategory.Iast)] + InstrumentationCategory = InstrumentationCategory.Tracing | InstrumentationCategory.AppSec | InstrumentationCategory.Iast)] // ReSharper disable once InconsistentNaming [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] @@ -70,9 +74,62 @@ internal static CallTargetState OnMethodBegin(TActionDescriptor actionDescriptor, SpanCodeOrigin codeOrigin) + { + if (actionDescriptor is null) + { + return; + } + + var httpContext = HttpContext.Current; + if (SharedItems.TryPeekScope(httpContext, AspNetMvcIntegration.HttpContextKey) is not { Root.Span: { } rootSpan }) + { + if (Log.IsEnabled(LogEventLevel.Debug)) + { + Log.Debug( + "Code origin is enabled but scope was not found in HttpContext (key: {HttpContextKey}, httpContextNull: {HttpContextIsNull}, itemsCount: {HttpContextItemsCount}, actionDescriptorType: {ActionDescriptorType}).", + AspNetMvcIntegration.HttpContextKey, + httpContext is null, + httpContext?.Items?.Count ?? 0, + actionDescriptor.GetType()); + } + + return; + } + + if (!actionDescriptor.TryDuckCast(out var reflected) + || reflected.MethodInfo is not { } actionMethod + || actionMethod.DeclaringType is not { } actionType) + { + if (Log.IsEnabled(LogEventLevel.Debug)) + { + Log.Debug( + "Code origin is enabled but could not extract action from ActionDescriptor type {ActionDescriptorType} or action MethodInfo has no DeclaringType.", + actionDescriptor.GetType()); + } + + return; + } + + codeOrigin.SetCodeOriginForEntrySpan(rootSpan, actionType, actionMethod); + } + /// /// OnMethodEnd callback /// diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ReflectedHttpActionDescriptor_ExecuteAsync_Integration.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ReflectedHttpActionDescriptor_ExecuteAsync_Integration.cs index 10634ea0386f..43e3d8d4ba1e 100644 --- a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ReflectedHttpActionDescriptor_ExecuteAsync_Integration.cs +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AspNet/ReflectedHttpActionDescriptor_ExecuteAsync_Integration.cs @@ -16,8 +16,11 @@ using Datadog.Trace.AspNet; using Datadog.Trace.ClrProfiler.CallTarget; using Datadog.Trace.Configuration; +using Datadog.Trace.Debugger; +using Datadog.Trace.Debugger.SpanCodeOrigin; using Datadog.Trace.DuckTyping; using Datadog.Trace.Logging; +using Datadog.Trace.Vendors.Serilog.Events; namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet { @@ -33,7 +36,7 @@ namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet MinimumVersion = "5.1", MaximumVersion = "5", IntegrationName = IntegrationName, - InstrumentationCategory = InstrumentationCategory.AppSec | InstrumentationCategory.Iast)] + InstrumentationCategory = InstrumentationCategory.Tracing | InstrumentationCategory.AppSec | InstrumentationCategory.Iast)] // ReSharper disable once InconsistentNaming [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] @@ -70,9 +73,62 @@ internal static CallTargetState OnMethodBegin(TTarget inst Log.Error(ex, "Error instrumenting method {MethodName}", "System.Web.Http.Controllers.ReflectedHttpActionDescriptor.ExecuteAsync()"); } + try + { + var codeOrigin = DebuggerManager.Instance.CodeOrigin; + if (codeOrigin is { Settings.CodeOriginForSpansEnabled: true }) + { + AddSpanCodeOrigin(instance, codeOrigin); + } + } + catch (Exception ex) when (BlockException.GetBlockException(ex) is null) + { + Log.Error(ex, "Error adding code origin for spans in {MethodName}", "System.Web.Http.Controllers.ReflectedHttpActionDescriptor.ExecuteAsync()"); + } + return CallTargetState.GetDefault(); } + private static void AddSpanCodeOrigin(TTarget instance, SpanCodeOrigin codeOrigin) + { + if (instance == null) + { + return; + } + + var httpContext = HttpContext.Current; + if (SharedItems.TryPeekScope(httpContext, AspNetWebApi2Integration.HttpContextKey) is not { Root.Span: { } rootSpan }) + { + if (Log.IsEnabled(LogEventLevel.Debug)) + { + Log.Debug( + "Code origin is enabled but scope was not found in HttpContext (key: {HttpContextKey}, httpContextNull: {HttpContextIsNull}, itemsCount: {HttpContextItemsCount}, actionDescriptorType: {ActionDescriptorType}).", + AspNetWebApi2Integration.HttpContextKey, + httpContext is null, + httpContext?.Items?.Count ?? 0, + instance.GetType()); + } + + return; + } + + if (!instance.TryDuckCast(out var reflected) + || reflected.MethodInfo is not { } actionMethod + || actionMethod.DeclaringType is not { } actionType) + { + if (Log.IsEnabled(LogEventLevel.Debug)) + { + Log.Debug( + "Code origin is enabled but could not extract action from HttpActionDescriptor type {ActionDescriptorType} or action has no DeclaringType.", + instance.GetType()); + } + + return; + } + + codeOrigin.SetCodeOriginForEntrySpan(rootSpan, actionType, actionMethod); + } + internal static TResponse? OnAsyncMethodEnd(TTarget instance, TResponse? response, Exception? exception, in CallTargetState state) { var security = Security.Instance; diff --git a/tracer/src/Datadog.Trace/Debugger/SpanCodeOrigin/EndpointDetector.cs b/tracer/src/Datadog.Trace/Debugger/SpanCodeOrigin/EndpointDetector.cs index 1dba428e478d..363a5741b09a 100644 --- a/tracer/src/Datadog.Trace/Debugger/SpanCodeOrigin/EndpointDetector.cs +++ b/tracer/src/Datadog.Trace/Debugger/SpanCodeOrigin/EndpointDetector.cs @@ -1,4 +1,4 @@ -// +// // Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. // @@ -18,20 +18,20 @@ namespace Datadog.Trace.Debugger.SpanCodeOrigin; internal static class EndpointDetector { - private static readonly HashSet ControllerAttributes = + private static readonly HashSet AspNetCoreControllerAttributes = [ "Microsoft.AspNetCore.Mvc.ApiControllerAttribute", "Microsoft.AspNetCore.Mvc.ControllerAttribute", "Microsoft.AspNetCore.Mvc.RouteAttribute" ]; - private static readonly HashSet ControllerBaseNames = + private static readonly HashSet AspNetCoreControllerBaseNames = [ "Microsoft.AspNetCore.Mvc.Controller", "Microsoft.AspNetCore.Mvc.ControllerBase" ]; - private static readonly HashSet ActionAttributes = + private static readonly HashSet AspNetCoreActionAttributes = [ "Microsoft.AspNetCore.Mvc.HttpGetAttribute", "Microsoft.AspNetCore.Mvc.HttpPostAttribute", @@ -43,6 +43,25 @@ internal static class EndpointDetector "Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute" ]; + private static readonly HashSet NetFrameworkControllerBaseNames = + [ + // MVC 4/5 + "System.Web.Mvc.Controller", + "System.Web.Mvc.ControllerBase", + + // Web API 2 + "System.Web.Http.ApiController" + ]; + + private static readonly HashSet NetFrameworkNonActionAttributes = + [ + // MVC 4/5 + "System.Web.Mvc.NonActionAttribute", + + // Web API 2 + "System.Web.Http.NonActionAttribute" + ]; + private static readonly HashSet SignalRHubBaseNames = [ "Microsoft.AspNetCore.SignalR.Hub", @@ -74,7 +93,9 @@ internal static ImmutableHashSet GetEndpointMethodTokens(DatadogMetadataRea bool isPageModel = false; bool isSignalRHub = false; bool isCompilerGeneratedType = false; - var isController = IsInheritFromTypesOrHasAttribute(typeDef, metadataReader, ControllerAttributes, ControllerBaseNames); + var isAspNetCoreController = IsInheritFromTypesOrHasAttribute(typeDef, metadataReader, AspNetCoreControllerAttributes, AspNetCoreControllerBaseNames); + var isNetFrameworkController = !isAspNetCoreController && IsInheritFromTypes(typeDef, metadataReader, NetFrameworkControllerBaseNames); + var isController = isAspNetCoreController || isNetFrameworkController; if (!isController) { isPageModel = IsInheritFromTypes(typeDef, metadataReader, PageModelBaseNames); @@ -101,7 +122,13 @@ internal static ImmutableHashSet GetEndpointMethodTokens(DatadogMetadataRea continue; } - if (isController && HasAttributeFromSet(methodDef.GetCustomAttributes(), metadataReader, ActionAttributes)) + if (isAspNetCoreController && HasAttributeFromSet(methodDef.GetCustomAttributes(), metadataReader, AspNetCoreActionAttributes)) + { + builder.Add(metadataReader.GetToken(methodHandle)); + continue; + } + + if (isNetFrameworkController && !HasAttributeFromSet(methodDef.GetCustomAttributes(), metadataReader, NetFrameworkNonActionAttributes)) { builder.Add(metadataReader.GetToken(methodHandle)); continue; @@ -141,7 +168,8 @@ private static bool IsValidMethod(MethodDefinition methodDef) { var attributes = methodDef.Attributes; return (attributes & MethodAttributes.Public) != 0 && - (attributes & MethodAttributes.Static) == 0; + (attributes & MethodAttributes.Static) == 0 && + (attributes & MethodAttributes.SpecialName) == 0; } private static bool IsInheritFromTypesOrHasAttribute(TypeDefinition typeDef, MetadataReader reader, HashSet attributesNames, HashSet baseTypeNames) diff --git a/tracer/src/Datadog.Tracer.Native/Generated/generated_calltargets.g.cpp b/tracer/src/Datadog.Tracer.Native/Generated/generated_calltargets.g.cpp index dff661caa48c..6cbb5e6e48fb 100644 --- a/tracer/src/Datadog.Tracer.Native/Generated/generated_calltargets.g.cpp +++ b/tracer/src/Datadog.Tracer.Native/Generated/generated_calltargets.g.cpp @@ -483,9 +483,9 @@ std::vector callTargets = #if _WIN32 {(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.Async.AsyncControllerActionInvoker"),(WCHAR*)WStr("BeginInvokeAction"),sig145,5,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.AsyncControllerActionInvoker_BeginInvokeAction_Integration"),CallTargetKind::Default,1,1}, {(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.Async.AsyncControllerActionInvoker"),(WCHAR*)WStr("EndInvokeAction"),sig124,2,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.AsyncControllerActionInvoker_EndInvokeAction_Integration"),CallTargetKind::Default,1,1}, -{(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.ControllerActionInvoker"),(WCHAR*)WStr("InvokeActionMethod"),sig394,4,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ControllerActionInvoker_InvokeAction_Integration"),CallTargetKind::Default,6,1}, +{(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.ControllerActionInvoker"),(WCHAR*)WStr("InvokeActionMethod"),sig394,4,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ControllerActionInvoker_InvokeAction_Integration"),CallTargetKind::Default,7,1}, {(WCHAR*)WStr("System.Web.Http"),(WCHAR*)WStr("System.Web.Http.ApiController"),(WCHAR*)WStr("ExecuteAsync"),sig299,3,5,1,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ApiController_ExecuteAsync_Integration"),CallTargetKind::Default,1,1}, -{(WCHAR*)WStr("System.Web.Http"),(WCHAR*)WStr("System.Web.Http.Controllers.ReflectedHttpActionDescriptor"),(WCHAR*)WStr("ExecuteAsync"),sig298,4,5,1,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ReflectedHttpActionDescriptor_ExecuteAsync_Integration"),CallTargetKind::Default,6,1}, +{(WCHAR*)WStr("System.Web.Http"),(WCHAR*)WStr("System.Web.Http.Controllers.ReflectedHttpActionDescriptor"),(WCHAR*)WStr("ExecuteAsync"),sig298,4,5,1,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ReflectedHttpActionDescriptor_ExecuteAsync_Integration"),CallTargetKind::Default,7,1}, {(WCHAR*)WStr("System.Web.Http"),(WCHAR*)WStr("System.Web.Http.ExceptionHandling.ExceptionHandlerExtensions"),(WCHAR*)WStr("HandleAsync"),sig300,4,5,1,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ExceptionHandlerExtensions_HandleAsync_Integration"),CallTargetKind::Default,1,1}, #endif {(WCHAR*)WStr("AWSSDK.DynamoDBv2"),(WCHAR*)WStr("Amazon.DynamoDBv2.AmazonDynamoDBClient"),(WCHAR*)WStr("BatchGetItem"),sig008,2,3,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AWS.DynamoDb.BatchGetItemIntegration"),CallTargetKind::Default,1,15}, diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/AspNet/AspNetMvc5CodeOriginTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/AspNet/AspNetMvc5CodeOriginTests.cs new file mode 100644 index 000000000000..59dbb681016c --- /dev/null +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/AspNet/AspNetMvc5CodeOriginTests.cs @@ -0,0 +1,114 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#if NETFRAMEWORK + +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Datadog.Trace.Configuration; +using Datadog.Trace.ExtensionMethods; +using Datadog.Trace.TestHelpers; +using VerifyTests; +using VerifyXunit; +using Xunit; +using Xunit.Abstractions; +#pragma warning disable SA1402 + +namespace Datadog.Trace.ClrProfiler.IntegrationTests +{ + [UsesVerify] + public abstract class AspNetMvc5CodeOriginTests : TracingIntegrationTest, IClassFixture, IAsyncLifetime + { + private readonly IisFixture _iisFixture; + private readonly string _testName; + private readonly bool _classicMode; + + protected AspNetMvc5CodeOriginTests(IisFixture iisFixture, ITestOutputHelper output, bool classicMode) + : base("AspNetMvc5", @"test\test-applications\aspnet", output) + { + _iisFixture = iisFixture; + _iisFixture.ShutdownPath = "/home/shutdown"; + _classicMode = classicMode; + _testName = $"{nameof(AspNetMvc5CodeOriginTests)}.{(classicMode ? "Classic" : "Integrated")}"; + + SetServiceVersion("1.0.0"); + SetEnvironmentVariable(ConfigurationKeys.Debugger.CodeOriginForSpansEnabled, "true"); + } + + public override Result ValidateIntegrationSpan(MockSpan span, string metadataSchemaVersion) => + span.Name switch + { + "aspnet.request" => span.IsAspNet(metadataSchemaVersion), + "aspnet-mvc.request" => span.IsAspNetMvc(metadataSchemaVersion), + _ => Result.DefaultSuccess, + }; + + [SkippableFact] + [Trait("Category", "EndToEnd")] + [Trait("RunOnWindows", "True")] + [Trait("LoadFromGAC", "True")] + public async Task AddsCodeOriginToAspNetRequestSpan() + { + const string path = "/Home/Index"; + const int statusCode = 200; + + var spans = await GetWebServerSpans( + path: _iisFixture.VirtualApplicationPath + path, + agent: _iisFixture.Agent, + httpPort: _iisFixture.HttpPort, + expectedHttpStatusCode: HttpStatusCode.OK, + expectedSpanCount: 2); + + var sanitisedPath = VerifyHelper.SanitisePathsForVerify(path); + var settings = VerifyHelper.GetSpanVerifierSettings(sanitisedPath, statusCode); + + AddCodeOriginScrubbers(settings); + + await Verifier.Verify(spans, settings) + .UseFileName($"{_testName}.__path={sanitisedPath}_statusCode={statusCode}"); + } + + public Task InitializeAsync() => _iisFixture.TryStartIis(this, _classicMode ? IisAppType.AspNetClassic : IisAppType.AspNetIntegrated); + + public Task DisposeAsync() => Task.CompletedTask; + + private static void AddCodeOriginScrubbers(VerifySettings settings) + { + // File paths are machine dependent, and line/column numbers are sensitive to unrelated edits. + // We scrub the values but keep the tags present in the snapshot. + settings.AddRegexScrubber( + new Regex(@"_dd\.code_origin\.frames\.(\d+)\.file: .*", VerifyHelper.RegOptions), + "_dd.code_origin.frames.$1.file: "); + + settings.AddRegexScrubber( + new Regex(@"_dd\.code_origin\.frames\.(\d+)\.(line|column): \d+", VerifyHelper.RegOptions), + "_dd.code_origin.frames.$1.$2: 0"); + } + } + + [Collection("IisTests")] + public class AspNetMvc5CodeOriginTestsClassic : AspNetMvc5CodeOriginTests + { + public AspNetMvc5CodeOriginTestsClassic(IisFixture iisFixture, ITestOutputHelper output) + : base(iisFixture, output, classicMode: true) + { + } + } + + [Collection("IisTests")] + public class AspNetMvc5CodeOriginTestsIntegrated : AspNetMvc5CodeOriginTests + { + public AspNetMvc5CodeOriginTestsIntegrated(IisFixture iisFixture, ITestOutputHelper output) + : base(iisFixture, output, classicMode: false) + { + } + } +} + +#endif + diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/AspNet/AspNetWebApi2CodeOriginTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/AspNet/AspNetWebApi2CodeOriginTests.cs new file mode 100644 index 000000000000..e9528be983d6 --- /dev/null +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/AspNet/AspNetWebApi2CodeOriginTests.cs @@ -0,0 +1,113 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#if NETFRAMEWORK +#pragma warning disable SA1402 // File may only contain a single class +#pragma warning disable SA1649 // File name must match first type name + +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Datadog.Trace.Configuration; +using Datadog.Trace.ExtensionMethods; +using Datadog.Trace.TestHelpers; +using VerifyTests; +using VerifyXunit; +using Xunit; +using Xunit.Abstractions; + +namespace Datadog.Trace.ClrProfiler.IntegrationTests +{ + [Collection("IisTests")] + public class AspNetWebApi2CodeOriginTestsClassic : AspNetWebApi2CodeOriginTests + { + public AspNetWebApi2CodeOriginTestsClassic(IisFixture iisFixture, ITestOutputHelper output) + : base(iisFixture, output, classicMode: true) + { + } + } + + [Collection("IisTests")] + public class AspNetWebApi2CodeOriginTestsIntegrated : AspNetWebApi2CodeOriginTests + { + public AspNetWebApi2CodeOriginTestsIntegrated(IisFixture iisFixture, ITestOutputHelper output) + : base(iisFixture, output, classicMode: false) + { + } + } + + [UsesVerify] + public abstract class AspNetWebApi2CodeOriginTests : TracingIntegrationTest, IClassFixture, IAsyncLifetime + { + private readonly IisFixture _iisFixture; + private readonly string _testName; + private readonly bool _classicMode; + + protected AspNetWebApi2CodeOriginTests(IisFixture iisFixture, ITestOutputHelper output, bool classicMode) + : base("AspNetMvc5", @"test\test-applications\aspnet", output) + { + _iisFixture = iisFixture; + _iisFixture.ShutdownPath = "/home/shutdown"; + _classicMode = classicMode; + _testName = $"{nameof(AspNetWebApi2CodeOriginTests)}.{(classicMode ? "Classic" : "Integrated")}"; + + SetServiceVersion("1.0.0"); + SetEnvironmentVariable(ConfigurationKeys.Debugger.CodeOriginForSpansEnabled, "true"); + } + + public override Result ValidateIntegrationSpan(MockSpan span, string metadataSchemaVersion) => + span.Name switch + { + "aspnet.request" => span.IsAspNet(metadataSchemaVersion), + "aspnet-webapi.request" => span.IsAspNetWebApi2(metadataSchemaVersion), + _ => Result.DefaultSuccess, + }; + + [SkippableFact] + [Trait("Category", "EndToEnd")] + [Trait("RunOnWindows", "True")] + [Trait("LoadFromGAC", "True")] + public async Task AddsCodeOriginToAspNetRequestSpan() + { + const string path = "/api/environment"; + const int statusCode = 200; + + var spans = await GetWebServerSpans( + path: _iisFixture.VirtualApplicationPath + path, + agent: _iisFixture.Agent, + httpPort: _iisFixture.HttpPort, + expectedHttpStatusCode: HttpStatusCode.OK, + expectedSpanCount: 2); + + var sanitisedPath = VerifyHelper.SanitisePathsForVerify(path); + var settings = VerifyHelper.GetSpanVerifierSettings(sanitisedPath, statusCode); + + AddCodeOriginScrubbers(settings); + + await Verifier.Verify(spans, settings) + .UseFileName($"{_testName}.__path={sanitisedPath}_statusCode={statusCode}"); + } + + public Task InitializeAsync() => _iisFixture.TryStartIis(this, _classicMode ? IisAppType.AspNetClassic : IisAppType.AspNetIntegrated); + + public Task DisposeAsync() => Task.CompletedTask; + + private static void AddCodeOriginScrubbers(VerifySettings settings) + { + settings.AddRegexScrubber( + new Regex(@"_dd\.code_origin\.frames\.(\d+)\.file: .*", VerifyHelper.RegOptions), + "_dd.code_origin.frames.$1.file: "); + + settings.AddRegexScrubber( + new Regex(@"_dd\.code_origin\.frames\.(\d+)\.(line|column): \d+", VerifyHelper.RegOptions), + "_dd.code_origin.frames.$1.$2: 0"); + } + } +} + +#endif + diff --git a/tracer/test/Datadog.Trace.Security.IntegrationTests/AspNetWebApi.cs b/tracer/test/Datadog.Trace.Security.IntegrationTests/AspNetWebApi.cs index 7aaca72b8f8a..739038c21c17 100644 --- a/tracer/test/Datadog.Trace.Security.IntegrationTests/AspNetWebApi.cs +++ b/tracer/test/Datadog.Trace.Security.IntegrationTests/AspNetWebApi.cs @@ -105,7 +105,10 @@ public async Task TestBlockedRequest(string test) var settings = VerifyHelper.GetSpanVerifierSettings(test); FilterConnectionHeader(settings); - await TestAppSecRequestWithVerifyAsync(_iisFixture.Agent, url, null, 5, 1, settings, userAgent: "Hello/V"); + // When AppSec is enabled, the request is blocked early and only the ASP.NET root span is created. + // When AppSec is disabled, the request completes normally and we also get the Web API span. + var spansPerRequest = SecurityEnabled ? 1 : 2; + await TestAppSecRequestWithVerifyAsync(_iisFixture.Agent, url, null, 5, spansPerRequest, settings, userAgent: "Hello/V"); } [Trait("Category", "EndToEnd")] diff --git a/tracer/test/Datadog.Trace.Tests/Debugger/EndpointDetectorTests.cs b/tracer/test/Datadog.Trace.Tests/Debugger/EndpointDetectorTests.cs index 8a28d0ba6400..eb9122f61726 100644 --- a/tracer/test/Datadog.Trace.Tests/Debugger/EndpointDetectorTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Debugger/EndpointDetectorTests.cs @@ -102,6 +102,16 @@ public void GetEndpointMethodTokens_FindsAllEndpoints() isExpectedEndpoint = true; } + // .NET Framework MVC/WebApi2 controllers (convention-based) + else if (type.Name == "NetFxMvcController" && method.Name == "Index") + { + isExpectedEndpoint = true; + } + else if (type.Name == "NetFxWebApiController" && method.Name == "Get") + { + isExpectedEndpoint = true; + } + // PageModel handlers else if (type.Name == "TestPageModel" && method.Name is "OnGet" or "OnGetAsync" or "OnPost" or "OnPostAsync" or "OnPut" or "OnPutAsync" or "OnDelete" or "OnDeleteAsync" or "OnHead" or "OnHeadAsync" or "OnPatch" or "OnPatchAsync" or "OnOptions" or "OnOptionsAsync" && @@ -191,6 +201,12 @@ public void GetEndpointMethodTokens_DoesNotDetectNonEndpoints() shouldNotBeEndpoint = true; } + // .NET Framework MVC/WebApi2 non-actions + else if (type.Name is "NetFxMvcController" or "NetFxWebApiController" && method.Name == "Helper") + { + shouldNotBeEndpoint = true; + } + // PageModel methods with NonHandler attribute else if (type.Name == "TestPageModel" && method.Name == "OnGetWithNonHandlerAttribute") { @@ -414,6 +430,24 @@ public static void MapPost(this object app, string pattern, Delegate handler) { } } +namespace System.Web.Mvc +{ + [AttributeUsage(AttributeTargets.Method)] + public class NonActionAttribute : Attribute { } + + public class ControllerBase { } + + public class Controller : ControllerBase { } +} + +namespace System.Web.Http +{ + [AttributeUsage(AttributeTargets.Method)] + public class NonActionAttribute : Attribute { } + + public class ApiController { } +} + namespace EndpointDetectorTestNamespace { // Controller with ControllerAttribute @@ -577,6 +611,28 @@ public static void UseHandlers(object app, RequestHandler handler1, RequestHandl // This empty method ensures the delegates are used } } + + // .NET Framework MVC controller + public class NetFxMvcController : System.Web.Mvc.Controller + { + public object Index() => null; + + [System.Web.Mvc.NonAction] + public object Helper() => null; + + private object PrivateMethod() => null; + + public static object StaticMethod() => null; + } + + // .NET Framework Web API 2 controller + public class NetFxWebApiController : System.Web.Http.ApiController + { + public object Get() => null; + + [System.Web.Http.NonAction] + public object Helper() => null; + } }"); } } diff --git a/tracer/test/snapshots/AspNetMvc5CodeOriginTests.Classic.__path=_Home_Index_statusCode=200.verified.txt b/tracer/test/snapshots/AspNetMvc5CodeOriginTests.Classic.__path=_Home_Index_statusCode=200.verified.txt new file mode 100644 index 000000000000..c8131e6839db --- /dev/null +++ b/tracer/test/snapshots/AspNetMvc5CodeOriginTests.Classic.__path=_Home_Index_statusCode=200.verified.txt @@ -0,0 +1,61 @@ +[ + { + TraceId: Id_1, + SpanId: Id_2, + Name: aspnet-mvc.request, + Resource: GET /home/index, + Service: sample, + Type: web, + ParentId: Id_3, + Tags: { + aspnet.action: index, + aspnet.controller: home, + aspnet.route: {controller}/{action}/{id}, + component: aspnet, + env: integration_tests, + http.method: GET, + http.request.headers.host: localhost:00000, + http.status_code: 200, + http.url: http://localhost:00000/Home/Index, + http.useragent: testhelper, + language: dotnet, + span.kind: server, + version: 1.0.0 + } + }, + { + TraceId: Id_1, + SpanId: Id_3, + Name: aspnet.request, + Resource: GET /home/index, + Service: sample, + Type: web, + Tags: { + component: aspnet, + env: integration_tests, + http.method: GET, + http.request.headers.host: localhost:00000, + http.route: {controller}/{action}/{id}, + http.status_code: 200, + http.url: http://localhost:00000/Home/Index, + http.useragent: testhelper, + language: dotnet, + runtime-id: Guid_1, + span.kind: server, + version: 1.0.0, + _dd.code_origin.frames.0.column: 0, + _dd.code_origin.frames.0.file: + _dd.code_origin.frames.0.index: 0, + _dd.code_origin.frames.0.line: 0, + _dd.code_origin.frames.0.method: Index, + _dd.code_origin.frames.0.type: Samples.AspNetMvc5.Controllers.HomeController, + _dd.code_origin.type: entry + }, + Metrics: { + process_id: 0, + _dd.top_level: 1.0, + _dd.tracer_kr: 1.0, + _sampling_priority_v1: 1.0 + } + } +] \ No newline at end of file diff --git a/tracer/test/snapshots/AspNetMvc5CodeOriginTests.Integrated.__path=_Home_Index_statusCode=200.verified.txt b/tracer/test/snapshots/AspNetMvc5CodeOriginTests.Integrated.__path=_Home_Index_statusCode=200.verified.txt new file mode 100644 index 000000000000..c8131e6839db --- /dev/null +++ b/tracer/test/snapshots/AspNetMvc5CodeOriginTests.Integrated.__path=_Home_Index_statusCode=200.verified.txt @@ -0,0 +1,61 @@ +[ + { + TraceId: Id_1, + SpanId: Id_2, + Name: aspnet-mvc.request, + Resource: GET /home/index, + Service: sample, + Type: web, + ParentId: Id_3, + Tags: { + aspnet.action: index, + aspnet.controller: home, + aspnet.route: {controller}/{action}/{id}, + component: aspnet, + env: integration_tests, + http.method: GET, + http.request.headers.host: localhost:00000, + http.status_code: 200, + http.url: http://localhost:00000/Home/Index, + http.useragent: testhelper, + language: dotnet, + span.kind: server, + version: 1.0.0 + } + }, + { + TraceId: Id_1, + SpanId: Id_3, + Name: aspnet.request, + Resource: GET /home/index, + Service: sample, + Type: web, + Tags: { + component: aspnet, + env: integration_tests, + http.method: GET, + http.request.headers.host: localhost:00000, + http.route: {controller}/{action}/{id}, + http.status_code: 200, + http.url: http://localhost:00000/Home/Index, + http.useragent: testhelper, + language: dotnet, + runtime-id: Guid_1, + span.kind: server, + version: 1.0.0, + _dd.code_origin.frames.0.column: 0, + _dd.code_origin.frames.0.file: + _dd.code_origin.frames.0.index: 0, + _dd.code_origin.frames.0.line: 0, + _dd.code_origin.frames.0.method: Index, + _dd.code_origin.frames.0.type: Samples.AspNetMvc5.Controllers.HomeController, + _dd.code_origin.type: entry + }, + Metrics: { + process_id: 0, + _dd.top_level: 1.0, + _dd.tracer_kr: 1.0, + _sampling_priority_v1: 1.0 + } + } +] \ No newline at end of file diff --git a/tracer/test/snapshots/AspNetWebApi2CodeOriginTests.Classic.__path=_api_environment_statusCode=200.verified.txt b/tracer/test/snapshots/AspNetWebApi2CodeOriginTests.Classic.__path=_api_environment_statusCode=200.verified.txt new file mode 100644 index 000000000000..940a619c1ad3 --- /dev/null +++ b/tracer/test/snapshots/AspNetWebApi2CodeOriginTests.Classic.__path=_api_environment_statusCode=200.verified.txt @@ -0,0 +1,59 @@ +[ + { + TraceId: Id_1, + SpanId: Id_2, + Name: aspnet-webapi.request, + Resource: GET /api/environment, + Service: sample, + Type: web, + ParentId: Id_3, + Tags: { + aspnet.route: api/environment, + component: aspnet, + env: integration_tests, + http.method: GET, + http.request.headers.host: localhost:00000, + http.status_code: 200, + http.url: http://localhost:00000/api/environment, + http.useragent: testhelper, + language: dotnet, + span.kind: server, + version: 1.0.0 + } + }, + { + TraceId: Id_1, + SpanId: Id_3, + Name: aspnet.request, + Resource: GET /api/environment, + Service: sample, + Type: web, + Tags: { + component: aspnet, + env: integration_tests, + http.method: GET, + http.request.headers.host: localhost:00000, + http.route: api/environment, + http.status_code: 200, + http.url: http://localhost:00000/api/environment, + http.useragent: testhelper, + language: dotnet, + runtime-id: Guid_1, + span.kind: server, + version: 1.0.0, + _dd.code_origin.frames.0.column: 0, + _dd.code_origin.frames.0.file: + _dd.code_origin.frames.0.index: 0, + _dd.code_origin.frames.0.line: 0, + _dd.code_origin.frames.0.method: Environment, + _dd.code_origin.frames.0.type: Samples.AspNetMvc5.Controllers.ApiController, + _dd.code_origin.type: entry + }, + Metrics: { + process_id: 0, + _dd.top_level: 1.0, + _dd.tracer_kr: 1.0, + _sampling_priority_v1: 1.0 + } + } +] \ No newline at end of file diff --git a/tracer/test/snapshots/AspNetWebApi2CodeOriginTests.Integrated.__path=_api_environment_statusCode=200.verified.txt b/tracer/test/snapshots/AspNetWebApi2CodeOriginTests.Integrated.__path=_api_environment_statusCode=200.verified.txt new file mode 100644 index 000000000000..940a619c1ad3 --- /dev/null +++ b/tracer/test/snapshots/AspNetWebApi2CodeOriginTests.Integrated.__path=_api_environment_statusCode=200.verified.txt @@ -0,0 +1,59 @@ +[ + { + TraceId: Id_1, + SpanId: Id_2, + Name: aspnet-webapi.request, + Resource: GET /api/environment, + Service: sample, + Type: web, + ParentId: Id_3, + Tags: { + aspnet.route: api/environment, + component: aspnet, + env: integration_tests, + http.method: GET, + http.request.headers.host: localhost:00000, + http.status_code: 200, + http.url: http://localhost:00000/api/environment, + http.useragent: testhelper, + language: dotnet, + span.kind: server, + version: 1.0.0 + } + }, + { + TraceId: Id_1, + SpanId: Id_3, + Name: aspnet.request, + Resource: GET /api/environment, + Service: sample, + Type: web, + Tags: { + component: aspnet, + env: integration_tests, + http.method: GET, + http.request.headers.host: localhost:00000, + http.route: api/environment, + http.status_code: 200, + http.url: http://localhost:00000/api/environment, + http.useragent: testhelper, + language: dotnet, + runtime-id: Guid_1, + span.kind: server, + version: 1.0.0, + _dd.code_origin.frames.0.column: 0, + _dd.code_origin.frames.0.file: + _dd.code_origin.frames.0.index: 0, + _dd.code_origin.frames.0.line: 0, + _dd.code_origin.frames.0.method: Environment, + _dd.code_origin.frames.0.type: Samples.AspNetMvc5.Controllers.ApiController, + _dd.code_origin.type: entry + }, + Metrics: { + process_id: 0, + _dd.top_level: 1.0, + _dd.tracer_kr: 1.0, + _sampling_priority_v1: 1.0 + } + } +] \ No newline at end of file