diff --git a/src/Agent/NewRelic/Agent/Core/Config/Configuration.cs b/src/Agent/NewRelic/Agent/Core/Config/Configuration.cs index 6395264aaa..4feaf8a518 100644 --- a/src/Agent/NewRelic/Agent/Core/Config/Configuration.cs +++ b/src/Agent/NewRelic/Agent/Core/Config/Configuration.cs @@ -1,6 +1,3 @@ -// Copyright 2020 New Relic, Inc. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - // ------------------------------------------------------------------------------ // // Generated by Xsd2Code. Version 3.6.0.0 diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunction.csproj b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunction.csproj index 070dd10f1a..e6de41b2ba 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunction.csproj +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunction.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionsHttpProxyingMiddlewareWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionsHttpProxyingMiddlewareWrapper.cs new file mode 100644 index 0000000000..c6e44e7dc2 --- /dev/null +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionsHttpProxyingMiddlewareWrapper.cs @@ -0,0 +1,52 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.AspNetCore.Http; +using NewRelic.Agent.Api; +using NewRelic.Agent.Extensions.Providers.Wrapper; + +public class FunctionsHttpProxyingMiddlewareWrapper : IWrapper +{ + private const string WrapperName = "FunctionsHttpProxyingMiddlewareWrapper"; + + public bool IsTransactionRequired => false; + + public CanWrapResponse CanWrap(InstrumentedMethodInfo methodInfo) + { + return new CanWrapResponse(WrapperName.Equals(methodInfo.RequestedWrapperName)); + } + + /// + /// Gets request method / path for Azure function HttpTrigger invocations + /// in apps that use the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore package + /// + public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, ITransaction transaction) + { + if (agent.Configuration.AzureFunctionModeEnabled) + { + switch (instrumentedMethodCall.MethodCall.Method.MethodName) + { + case "AddHttpContextToFunctionContext": + var httpContext = (HttpContext)instrumentedMethodCall.MethodCall.MethodArguments[1]; + + agent.CurrentTransaction.SetRequestMethod(httpContext.Request.Method); + agent.CurrentTransaction.SetUri(httpContext.Request.Path); + break; + // not needed at present for getting status code, but keep in case we need to get more from httpContext - also update instrumentation.xml + //case "TryHandleHttpResult": + // object result = instrumentedMethodCall.MethodCall.MethodArguments[0]; + // httpContext = (HttpContext)instrumentedMethodCall.MethodCall.MethodArguments[2]; + // bool isInvocationResult = (bool)instrumentedMethodCall.MethodCall.MethodArguments[3]; + + // agent.CurrentTransaction.SetHttpResponseStatusCode(httpContext.Response.StatusCode); + // break; + //case "TryHandleOutputBindingsHttpResult": + // httpContext = (HttpContext)instrumentedMethodCall.MethodCall.MethodArguments[1]; + // agent.CurrentTransaction.SetHttpResponseStatusCode(httpContext.Response.StatusCode); + // break; + } + } + + return Delegates.NoOp; + } +} diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/Instrumentation.xml b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/Instrumentation.xml index 230a0f44b2..81560a5090 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/Instrumentation.xml +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/Instrumentation.xml @@ -19,5 +19,19 @@ SPDX-License-Identifier: Apache-2.0 + + + + + + + diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InvokeFunctionAsyncWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InvokeFunctionAsyncWrapper.cs index fc9ae2dcb5..b56232ca27 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InvokeFunctionAsyncWrapper.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InvokeFunctionAsyncWrapper.cs @@ -5,6 +5,8 @@ using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using System.Threading.Tasks; using NewRelic.Agent.Api; using NewRelic.Agent.Extensions.Providers.Wrapper; @@ -78,6 +80,12 @@ public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall ins transaction.AddFaasAttribute("faas.trigger", functionDetails.Trigger); transaction.AddFaasAttribute("faas.invocation_id", functionDetails.InvocationId); + if (functionDetails.IsWebTrigger && !string.IsNullOrEmpty(functionDetails.RequestMethod)) + { + transaction.SetRequestMethod(functionDetails.RequestMethod); + transaction.SetUri(functionDetails.RequestPath); + } + var segment = transaction.StartTransactionSegment(instrumentedMethodCall.MethodCall, functionDetails.FunctionName); return Delegates.GetAsyncDelegateFor( @@ -96,6 +104,35 @@ void InvokeFunctionAsyncResponse(Task responseTask) transaction.NoticeError(responseTask.Exception); return; } + + if (functionDetails.IsWebTrigger) + { + // GetInvocationResult is a static extension method + // there are multiple GetInvocationResult methods in this type; we want the one without any generic parameters + Type type = functionContext.GetType().Assembly.GetType("Microsoft.Azure.Functions.Worker.FunctionContextBindingFeatureExtensions"); + var getInvocationResultMethod = type.GetMethods().Single(m => m.Name == "GetInvocationResult" && !m.ContainsGenericParameters); + + dynamic invocationResult = getInvocationResultMethod.Invoke(null, new[] { functionContext }); + var result = invocationResult?.Value; + + // the result always seems to be of this type regardless of whether the app + // uses the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore package or not + var resultTypeName = result?.GetType().Name; + if (resultTypeName == "GrpcHttpResponseData") + { + transaction.SetHttpResponseStatusCode((int)result.StatusCode); + } + else + { + agent.Logger.Debug($"Unexpected Azure Function invocationResult.Value type '{resultTypeName ?? "(null)"}' - unable to set http response status code."); + } + } + + } + catch (Exception ex) + { + agent.Logger.Error(ex, "Error processing Azure Function response."); + throw; } finally { @@ -108,7 +145,11 @@ void InvokeFunctionAsyncResponse(Task responseTask) internal class FunctionDetails { - private static ConcurrentDictionary _functionTriggerCache = new(); + private static MethodInfo _bindFunctionInputAsync; + private static MethodInfo _genericFunctionInputBindingFeatureGetter; + private static bool? _hasAspNetCoreExtensionsReference; + + private static readonly ConcurrentDictionary _functionTriggerCache = new(); private static Func _functionDefinitionGetter; private static Func _parametersGetter; private static Func> _propertiesGetter; @@ -179,24 +220,98 @@ public FunctionDetails(dynamic functionContext, IAgent agent) { Trigger = trigger; } + + if (IsWebTrigger) + { + ParseRequestParameters(agent, functionContext); + } } - catch(Exception ex) + catch (Exception ex) { agent.Logger.Error(ex, "Error getting Azure Function details."); throw; } } + private void ParseRequestParameters(IAgent agent, dynamic functionContext) + { + if (!_hasAspNetCoreExtensionsReference.HasValue) + { + // see if the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore assembly is in the list of loaded assemblies + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + var assembly = loadedAssemblies.FirstOrDefault(a => a.GetName().Name == "Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore"); + _hasAspNetCoreExtensionsReference = assembly != null; + + if (_hasAspNetCoreExtensionsReference.Value) + agent.Logger.Debug("Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore assembly is loaded; not parsing request parameters in InvokeFunctionAsyncWrapper."); + } + + // don't parse request parameters here if the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore assembly is loaded. + // If it is loaded, parsing occurs over in FunctionsHttpProxyingMiddlewareWrapper + if (_hasAspNetCoreExtensionsReference.Value) + { + return; + } + + object features = functionContext.Features; + + if (_genericFunctionInputBindingFeatureGetter == null) // cache the methodinfo lookups for performance + { + var get = features.GetType().GetMethod("Get"); + if (get != null) + { + _genericFunctionInputBindingFeatureGetter = get.MakeGenericMethod(features.GetType().Assembly.GetType("Microsoft.Azure.Functions.Worker.Context.Features.IFunctionInputBindingFeature")); + } + else + { + agent.Logger.Debug("Unable to find FunctionContext.Features.Get method; unable to parse request parameters."); + return; + } + + var bindFunctionInputType = features.GetType().Assembly.GetType("Microsoft.Azure.Functions.Worker.Context.Features.IFunctionInputBindingFeature"); + if (bindFunctionInputType == null) + { + agent.Logger.Debug("Unable to find IFunctionInputBindingFeature type; unable to parse request parameters."); + return; + } + _bindFunctionInputAsync = bindFunctionInputType.GetMethod("BindFunctionInputAsync"); + if (_bindFunctionInputAsync == null) + { + agent.Logger.Debug("Unable to find BindFunctionInputAsync method; unable to parse request parameters."); + return; + } + } + + if (_genericFunctionInputBindingFeatureGetter != null) + { + // Get the input binding feature and bind the input from the function context + var inputBindingFeature = _genericFunctionInputBindingFeatureGetter.Invoke(features, new object[] { }); + dynamic valueTask = _bindFunctionInputAsync.Invoke(inputBindingFeature, new object[] { functionContext }); + valueTask.AsTask().Wait(); + var inputArguments = valueTask.Result.Values; + var reqData = inputArguments[0]; + + if (reqData != null && reqData.GetType().Name == "GrpcHttpRequestData" && !string.IsNullOrEmpty(reqData.Method)) + { + RequestMethod = reqData.Method; + Uri uri = reqData.Url; + RequestPath = uri.GetComponents(UriComponents.Path, UriFormat.Unescaped); + } + } + } + public bool IsValid() { return !string.IsNullOrEmpty(FunctionName) && !string.IsNullOrEmpty(Trigger) && !string.IsNullOrEmpty(InvocationId); } - public string FunctionName { get; private set; } + public string FunctionName { get; } - public string Trigger { get; private set; } - public string InvocationId { get; private set; } + public string Trigger { get; } + public string InvocationId { get; } public bool IsWebTrigger => Trigger == "http"; + public string RequestMethod { get; private set; } + public string RequestPath { get; private set; } } } diff --git a/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionHttpTriggerTests.cs b/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionHttpTriggerTests.cs index 0ef72db77f..2fa2c392f4 100644 --- a/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionHttpTriggerTests.cs +++ b/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionHttpTriggerTests.cs @@ -70,7 +70,7 @@ public void Test() var expectedAgentAttributes = new Dictionary { - { "request.uri", "/Unknown"} + { "request.uri", "/api/httpTriggerFunctionUsingAspNetCorePipeline"} }; var transactionTraceExpectedAttributes = new Dictionary()