Skip to content

Commit 0278836

Browse files
authored
feat: Add Distributed Tracing support for Azure Functions HTTPTrigger. (#2868)
1 parent 75d4d14 commit 0278836

File tree

8 files changed

+379
-185
lines changed

8 files changed

+379
-185
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2020 New Relic, Inc. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using System.Collections.Generic;
5+
using Newtonsoft.Json;
6+
7+
namespace NewRelic.Agent.Extensions.Helpers
8+
{
9+
public static class DictionaryHelpers
10+
{
11+
/// <summary>
12+
/// Converts a JSON string to a dictionary. Will always return a dictionary, even if the JSON is invalid.
13+
/// </summary>
14+
/// <param name="json"></param>
15+
/// <returns>IReadOnlyDictionary<string, object></returns>
16+
public static IReadOnlyDictionary<string, object> FromJson(string json)
17+
{
18+
if (string.IsNullOrEmpty(json))
19+
{
20+
return new Dictionary<string, object>();
21+
}
22+
23+
try
24+
{
25+
return JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
26+
}
27+
catch
28+
{
29+
return new Dictionary<string, object>();
30+
}
31+
}
32+
}
33+
}

src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunction.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
<ItemGroup>
1616
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
17+
<PackageReference Include="Microsoft.Extensions.Primitives" Version="2.1.0" />
1718
</ItemGroup>
1819

1920
<ItemGroup>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright 2020 New Relic, Inc. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using System;
5+
using System.Collections;
6+
using System.Collections.Concurrent;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
using System.Reflection;
10+
using NewRelic.Agent.Api;
11+
using NewRelic.Agent.Extensions.Helpers;
12+
using NewRelic.Reflection;
13+
14+
namespace NewRelic.Providers.Wrapper.AzureFunction;
15+
16+
internal class FunctionDetails
17+
{
18+
private static MethodInfo _bindFunctionInputAsync;
19+
private static MethodInfo _genericFunctionInputBindingFeatureGetter;
20+
private static bool? _hasAspNetCoreExtensionsReference;
21+
22+
private static readonly ConcurrentDictionary<string, string> _functionTriggerCache = new();
23+
private static Func<object, object> _functionDefinitionGetter;
24+
private static Func<object, object> _parametersGetter;
25+
private static Func<object, IReadOnlyDictionary<string, object>> _propertiesGetter;
26+
27+
private const string AspNetCoreExtensionsAssemblyName = "Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore";
28+
private const string IFunctionInputBindingFeatureTypeName = "Microsoft.Azure.Functions.Worker.Context.Features.IFunctionInputBindingFeature";
29+
30+
public FunctionDetails(dynamic functionContext, IAgent agent)
31+
{
32+
try
33+
{
34+
FunctionName = functionContext.FunctionDefinition.Name;
35+
InvocationId = functionContext.InvocationId;
36+
37+
// cache the trigger by function name
38+
if (!_functionTriggerCache.TryGetValue(FunctionName, out string trigger))
39+
{
40+
// functionContext.FunctionDefinition.Parameters is an ImmutableArray<FunctionParameter>
41+
var funcAsObj = (object)functionContext;
42+
_functionDefinitionGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<object>(funcAsObj.GetType(), "FunctionDefinition");
43+
var functionDefinition = _functionDefinitionGetter(funcAsObj);
44+
45+
_parametersGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<object>(functionDefinition.GetType(), "Parameters");
46+
var parameters = _parametersGetter(functionDefinition) as IEnumerable;
47+
48+
// Trigger is normally the first parameter, but we'll check all parameters to be sure.
49+
var foundTrigger = false;
50+
foreach (var parameter in parameters)
51+
{
52+
// Properties is an IReadOnlyDictionary<string, object>
53+
_propertiesGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<IReadOnlyDictionary<string, object>>(parameter.GetType(), "Properties");
54+
var properties = _propertiesGetter(parameter);
55+
if (properties == null || properties.Count == 0)
56+
{
57+
continue;
58+
}
59+
60+
if (!properties.TryGetValue("bindingAttribute", out var triggerAttribute))
61+
{
62+
foreach (var propVal in properties.Values)
63+
{
64+
if (propVal.GetType().Name.Contains("Trigger"))
65+
{
66+
triggerAttribute = propVal;
67+
break;
68+
}
69+
}
70+
71+
if (triggerAttribute == null)
72+
{
73+
continue;
74+
}
75+
}
76+
77+
var triggerTypeName = triggerAttribute.GetType().Name;
78+
Trigger = triggerTypeName.ResolveTriggerType();
79+
foundTrigger = true;
80+
break;
81+
}
82+
83+
// shouldn't happen, as all functions are required to have a trigger
84+
if (!foundTrigger)
85+
{
86+
agent.Logger.Debug($"Function {FunctionName} does not have a trigger, defaulting to 'other'");
87+
Trigger = "other";
88+
}
89+
90+
_functionTriggerCache[FunctionName] = Trigger;
91+
}
92+
else
93+
{
94+
Trigger = trigger;
95+
}
96+
97+
if (IsWebTrigger)
98+
{
99+
ParseHttpTriggerParameters(agent, functionContext);
100+
}
101+
}
102+
catch (Exception ex)
103+
{
104+
agent.Logger.Error(ex, "Error getting Azure Function details.");
105+
throw;
106+
}
107+
}
108+
109+
private void ParseHttpTriggerParameters(IAgent agent, dynamic functionContext)
110+
{
111+
if (!_hasAspNetCoreExtensionsReference.HasValue)
112+
{
113+
// see if the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore assembly is in the list of loaded assemblies
114+
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
115+
var assembly = loadedAssemblies.FirstOrDefault(a => a.GetName().Name == AspNetCoreExtensionsAssemblyName);
116+
117+
_hasAspNetCoreExtensionsReference = assembly != null;
118+
119+
if (_hasAspNetCoreExtensionsReference.Value)
120+
agent.Logger.Debug($"{AspNetCoreExtensionsAssemblyName} assembly is loaded; InvokeFunctionAsyncWrapper will defer HttpTrigger parameter parsing to FunctionsHttpProxyingMiddlewareWrapper.");
121+
}
122+
123+
// don't parse request parameters here if the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore assembly is loaded.
124+
// If it is loaded, parsing occurs over in FunctionsHttpProxyingMiddlewareWrapper
125+
if (_hasAspNetCoreExtensionsReference.Value)
126+
{
127+
return;
128+
}
129+
130+
object features = functionContext.Features;
131+
132+
if (_genericFunctionInputBindingFeatureGetter == null) // cache the methodinfo lookups for performance
133+
{
134+
var get = features.GetType().GetMethod("Get");
135+
if (get != null)
136+
{
137+
_genericFunctionInputBindingFeatureGetter = get.MakeGenericMethod(features.GetType().Assembly.GetType(IFunctionInputBindingFeatureTypeName));
138+
}
139+
else
140+
{
141+
agent.Logger.Debug("Unable to find FunctionContext.Features.Get method; unable to parse request parameters.");
142+
return;
143+
}
144+
145+
var bindFunctionInputType = features.GetType().Assembly.GetType(IFunctionInputBindingFeatureTypeName);
146+
if (bindFunctionInputType == null)
147+
{
148+
agent.Logger.Debug("Unable to find IFunctionInputBindingFeature type; unable to parse request parameters.");
149+
return;
150+
}
151+
_bindFunctionInputAsync = bindFunctionInputType.GetMethod("BindFunctionInputAsync");
152+
if (_bindFunctionInputAsync == null)
153+
{
154+
agent.Logger.Debug("Unable to find BindFunctionInputAsync method; unable to parse request parameters.");
155+
return;
156+
}
157+
}
158+
159+
if (_genericFunctionInputBindingFeatureGetter != null)
160+
{
161+
// Get the input binding feature and bind the input from the function context
162+
var inputBindingFeature = _genericFunctionInputBindingFeatureGetter.Invoke(features, []);
163+
dynamic valueTask = _bindFunctionInputAsync.Invoke(inputBindingFeature, [functionContext]);
164+
165+
valueTask.AsTask().Wait(); // BindFunctionInputAsync returns a ValueTask, so we need to convert it to a Task to wait on it
166+
167+
object[] inputArguments = valueTask.Result.Values;
168+
169+
if (inputArguments is { Length: > 0 })
170+
{
171+
var reqData = (dynamic)inputArguments[0];
172+
173+
if (reqData != null && reqData.GetType().Name == "GrpcHttpRequestData" && !string.IsNullOrEmpty(reqData.Method))
174+
{
175+
RequestMethod = reqData.Method;
176+
Uri uri = reqData.Url;
177+
RequestPath = $"/{uri.GetComponents(UriComponents.Path, UriFormat.Unescaped)}"; // has to start with a slash
178+
}
179+
}
180+
}
181+
182+
if (functionContext?.BindingContext?.BindingData is IReadOnlyDictionary<string, object> bindingData && bindingData.ContainsKey("Headers"))
183+
{
184+
// The headers are stored as a JSON blob.
185+
var headersJson = bindingData["Headers"].ToString();
186+
Headers = DictionaryHelpers.FromJson(headersJson);
187+
}
188+
}
189+
190+
public bool IsValid()
191+
{
192+
return !string.IsNullOrEmpty(FunctionName) && !string.IsNullOrEmpty(Trigger) && !string.IsNullOrEmpty(InvocationId);
193+
}
194+
195+
public string FunctionName { get; }
196+
197+
public string Trigger { get; }
198+
public string InvocationId { get; }
199+
public bool IsWebTrigger => Trigger == "http";
200+
public string RequestMethod { get; private set; }
201+
public string RequestPath { get; private set; }
202+
public IReadOnlyDictionary<string, object> Headers { get; private set; }
203+
public bool? HasAspNetCoreExtensionReference => _hasAspNetCoreExtensionsReference;
204+
}

src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionsHttpProxyingMiddlewareWrapper.cs

+14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright 2020 New Relic, Inc. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
using System.Collections.Generic;
5+
using Microsoft.Extensions.Primitives;
46
using NewRelic.Agent.Api;
57
using NewRelic.Agent.Extensions.Providers.Wrapper;
68

@@ -33,6 +35,10 @@ public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall ins
3335

3436
agent.CurrentTransaction.SetRequestMethod(httpContext.Request.Method);
3537
agent.CurrentTransaction.SetUri(httpContext.Request.Path);
38+
39+
// Only need to accept DT headers from incoming request.
40+
var headers = httpContext.Request.Headers as IDictionary<string, StringValues>;
41+
transaction.AcceptDistributedTraceHeaders(headers, GetHeaderValue, TransportType.HTTP);
3642
break;
3743
case "TryHandleHttpResult":
3844
if (!agent.CurrentTransaction.HasHttpResponseStatusCode) // these handlers seem to get called more than once; only set the status code one time
@@ -52,5 +58,13 @@ public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall ins
5258
}
5359

5460
return Delegates.NoOp;
61+
62+
static IEnumerable<string> GetHeaderValue(IDictionary<string, StringValues> headers, string key)
63+
{
64+
if (!headers.ContainsKey(key))
65+
return [];
66+
67+
return headers[key].ToArray();
68+
}
5569
}
5670
}

0 commit comments

Comments
 (0)