Skip to content

Commit

Permalink
Merge branch 'feature-work/azure-function-http-parameter-parsing' int…
Browse files Browse the repository at this point in the history
…o feature/azure-function-http-trigger-parsing
  • Loading branch information
tippmar-nr committed Sep 18, 2024
2 parents d55567f + b3a568f commit e459d20
Show file tree
Hide file tree
Showing 15 changed files with 338 additions and 30 deletions.
3 changes: 0 additions & 3 deletions src/Agent/NewRelic/Agent/Core/Config/Configuration.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// Copyright 2020 New Relic, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// ------------------------------------------------------------------------------
// <auto-generated>
// Generated by Xsd2Code. Version 3.6.0.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public class NoOpTransaction : ITransaction, ITransactionExperimental
public bool IsValid => false;
public bool IsFinished => false;
public ISegment CurrentSegment => Segment.NoOpSegment;
public bool HasHttpResponseStatusCode => false;

public DateTime StartTime => DateTime.UtcNow;

Expand Down
2 changes: 2 additions & 0 deletions src/Agent/NewRelic/Agent/Core/Transactions/Transaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ public ISegment CurrentSegment
}
}

public bool HasHttpResponseStatusCode => TransactionMetadata.HttpResponseStatusCode.HasValue;

public ITracingState TracingState { get; private set; }

public string TraceId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public interface ITransaction
/// </summary>
ISegment CurrentSegment { get; }

bool HasHttpResponseStatusCode { get; }

/// <summary>
/// End this transaction.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.0.1" />
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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));
}

/// <summary>
/// Gets request method / path for Azure function HttpTrigger invocations
/// in apps that use the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore package
/// </summary>
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;
case "TryHandleHttpResult":
if (!agent.CurrentTransaction.HasHttpResponseStatusCode) // these handlers seem to get called more than once; only set the status code one time
{
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":
if (!agent.CurrentTransaction.HasHttpResponseStatusCode) // these handlers seem to get called more than once; only set the status code one time
{
httpContext = (HttpContext)instrumentedMethodCall.MethodCall.MethodArguments[1];
agent.CurrentTransaction.SetHttpResponseStatusCode(httpContext.Response.StatusCode);
}
break;
}
}

return Delegates.NoOp;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,18 @@ SPDX-License-Identifier: Apache-2.0
<exactMethodMatcher methodName="Build" />
</match>
</tracerFactory>

<tracerFactory name="FunctionsHttpProxyingMiddlewareWrapper">
<match assemblyName="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" className="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.FunctionsHttpProxyingMiddleware">
<exactMethodMatcher methodName="AddHttpContextToFunctionContext" />
</match>
<match assemblyName="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" className="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.FunctionsHttpProxyingMiddleware">
<exactMethodMatcher methodName="TryHandleHttpResult" />
</match>
<match assemblyName="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" className="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.FunctionsHttpProxyingMiddleware">
<exactMethodMatcher methodName="TryHandleOutputBindingsHttpResult" />
</match>

</tracerFactory>
</instrumentation>
</extension>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +16,7 @@ namespace NewRelic.Providers.Wrapper.AzureFunction
{
public class InvokeFunctionAsyncWrapper : IWrapper
{
private static MethodInfo _getInvocationResultMethod;
private static bool _loggedDisabledMessage;
private const string WrapperName = "AzureFunctionInvokeAsyncWrapper";

Expand Down Expand Up @@ -78,6 +81,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<Task>(
Expand All @@ -96,6 +105,32 @@ void InvokeFunctionAsyncResponse(Task responseTask)
transaction.NoticeError(responseTask.Exception);
return;
}

// only pull response status code here if it's a web trigger and the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore assembly is not loaded.
if (functionDetails.IsWebTrigger && functionDetails.HasAspNetCoreExtensionReference != null && !functionDetails.HasAspNetCoreExtensionReference.Value)
{
if (_getInvocationResultMethod == null)
{
// 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");
_getInvocationResultMethod = type.GetMethods().Single(m => m.Name == "GetInvocationResult" && !m.ContainsGenericParameters);
}

dynamic invocationResult = _getInvocationResultMethod.Invoke(null, new[] { functionContext });
var result = invocationResult?.Value;

if (result != null && result.StatusCode != null)
{
var statusCode = result.StatusCode;
transaction.SetHttpResponseStatusCode((int)statusCode);
}
}
}
catch (Exception ex)
{
agent.Logger.Warn(ex, "Error processing Azure Function response.");
throw;
}
finally
{
Expand All @@ -108,7 +143,11 @@ void InvokeFunctionAsyncResponse(Task responseTask)

internal class FunctionDetails
{
private static ConcurrentDictionary<string, string> _functionTriggerCache = new();
private static MethodInfo _bindFunctionInputAsync;
private static MethodInfo _genericFunctionInputBindingFeatureGetter;
private static bool? _hasAspNetCoreExtensionsReference;

private static readonly ConcurrentDictionary<string, string> _functionTriggerCache = new();
private static Func<object, object> _functionDefinitionGetter;
private static Func<object, object> _parametersGetter;
private static Func<object, IReadOnlyDictionary<string, object>> _propertiesGetter;
Expand Down Expand Up @@ -179,24 +218,107 @@ public FunctionDetails(dynamic functionContext, IAgent agent)
{
Trigger = trigger;
}

if (IsWebTrigger)
{
ParseHttpTriggerParameters(agent, functionContext);
}
}
catch(Exception ex)
catch (Exception ex)
{
agent.Logger.Error(ex, "Error getting Azure Function details.");
throw;
}
}

private void ParseHttpTriggerParameters(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; InvokeFunctionAsyncWrapper will defer HttpTrigger parameter parsing to FunctionsHttpProxyingMiddlewareWrapper.");
}

// 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, []);
dynamic valueTask = _bindFunctionInputAsync.Invoke(inputBindingFeature, [functionContext]);

valueTask.AsTask().Wait(); // BindFunctionInputAsync returns a ValueTask, so we need to convert it to a Task to wait on it

object[] inputArguments = valueTask.Result.Values;

if (inputArguments is { Length: > 0 })
{
var reqData = (dynamic)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)}"; // has to start with a slash
}
}
}
}

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; }

public bool? HasAspNetCoreExtensionReference => _hasAspNetCoreExtensionsReference;
}

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
Expand Down Expand Up @@ -26,7 +26,7 @@
<None Include="local.settings.json" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.2.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="1.3.2" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="1.3.2" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.17.4" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2020 New Relic, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
Expand All @@ -19,7 +20,7 @@ public HttpTriggerFunctionUsingAspNetCorePipeline(ILogger<HttpTriggerFunctionUsi
}

[Function("HttpTriggerFunctionUsingAspNetCorePipeline")]
public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req)
public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] [FromQuery, Required] string someParam)
{
_logger.LogInformation("HttpTriggerFunctionUsingAspNetCorePipeline processed a request.");

Expand All @@ -29,7 +30,6 @@ public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "
_firstTime = false;
}


return new OkObjectResult("Welcome to Azure Functions!");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ namespace AzureFunctionApplication
/// </summary>
public class HttpTriggerFunctionUsingSimpleInvocation
{
private static bool _firstTime = true;
private readonly ILogger<HttpTriggerFunctionUsingSimpleInvocation> _logger;

public HttpTriggerFunctionUsingSimpleInvocation(ILogger<HttpTriggerFunctionUsingSimpleInvocation> logger)
Expand All @@ -25,6 +26,12 @@ public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function
{
_logger.LogInformation("HttpTriggerFunctionUsingSimpleInvocation processed a request.");

if (_firstTime)
{
await Task.Delay(250); // to ensure that the first invocation gets sampled
_firstTime = false;
}

var response = reqData.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "text/plain; charset=utf-8");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ private static async Task Main(string[] args)


var host = new HostBuilder()
// the net6 target uses the "basic" azure function configuration
// the net8 target uses the aspnetcore azure function configuration
#if NET6_0
.ConfigureFunctionsWorkerDefaults()
#else
.ConfigureFunctionsWebApplication()
#endif
.Build();

var task = host.RunAsync(cts.Token);
Expand Down
Loading

0 comments on commit e459d20

Please sign in to comment.