diff --git a/src/Agent/NewRelic/Agent/Core/AgentHealth/AgentHealthReporter.cs b/src/Agent/NewRelic/Agent/Core/AgentHealth/AgentHealthReporter.cs index dc23101004..d3c833bb7f 100644 --- a/src/Agent/NewRelic/Agent/Core/AgentHealth/AgentHealthReporter.cs +++ b/src/Agent/NewRelic/Agent/Core/AgentHealth/AgentHealthReporter.cs @@ -812,7 +812,7 @@ private void CollectOneTimeMetrics() ReportIfGCSamplerV2IsEnabled(); ReportIfAwsAccountIdProvided(); ReportIfAgentControlHealthEnabled(); - ReportIfAzureFunctionModeIsEnabled(); + ReportIfAzureFunctionModeIsDetected(); } public void CollectMetrics() @@ -985,11 +985,11 @@ private void ReportIfAwsAccountIdProvided() } } - private void ReportIfAzureFunctionModeIsEnabled() + private void ReportIfAzureFunctionModeIsDetected() { - if (_configuration.AzureFunctionModeEnabled && _configuration.AzureFunctionModeDetected) + if (_configuration.AzureFunctionModeDetected) { - ReportSupportabilityCountMetric(MetricNames.SupportabilityAzureFunctionModeEnabled); + ReportSupportabilityCountMetric(MetricNames.SupportabilityAzureFunctionMode(_configuration.AzureFunctionModeEnabled)); } } diff --git a/src/Agent/NewRelic/Agent/Core/Metrics/MetricNames.cs b/src/Agent/NewRelic/Agent/Core/Metrics/MetricNames.cs index a2888d9d40..b570575bd6 100644 --- a/src/Agent/NewRelic/Agent/Core/Metrics/MetricNames.cs +++ b/src/Agent/NewRelic/Agent/Core/Metrics/MetricNames.cs @@ -839,7 +839,11 @@ public static string GetSupportabilityInstallType(string installType) public const string SupportabilityIgnoredInstrumentation = SupportabilityDotnetPs + "IgnoredInstrumentation"; public const string SupportabilityGCSamplerV2Enabled = SupportabilityDotnetPs + "GCSamplerV2/Enabled"; public const string SupportabilityAwsAccountIdProvided = SupportabilityDotnetPs + "AwsAccountId/Config"; - public const string SupportabilityAzureFunctionModeEnabled = SupportabilityDotnetPs + "AzureFunctionMode/Enabled"; + + public static string SupportabilityAzureFunctionMode(bool enabled) + { + return SupportabilityDotnetPs + "AzureFunctionMode" + PathSeparator + (enabled ? Enabled : Disabled); + } #endregion Supportability diff --git a/src/Agent/NewRelic/Agent/Core/Transactions/NoOpTransaction.cs b/src/Agent/NewRelic/Agent/Core/Transactions/NoOpTransaction.cs index 91c3723e52..42568992e5 100644 --- a/src/Agent/NewRelic/Agent/Core/Transactions/NoOpTransaction.cs +++ b/src/Agent/NewRelic/Agent/Core/Transactions/NoOpTransaction.cs @@ -332,6 +332,11 @@ public void AddFaasAttribute(string name, object value) return; } + public object GetFaasAttribute(string name) + { + return null; + } + public void AddCloudSdkAttribute(string name, object value) { return; diff --git a/src/Agent/NewRelic/Agent/Core/Transactions/Transaction.cs b/src/Agent/NewRelic/Agent/Core/Transactions/Transaction.cs index 46f82f827c..e80452b03c 100644 --- a/src/Agent/NewRelic/Agent/Core/Transactions/Transaction.cs +++ b/src/Agent/NewRelic/Agent/Core/Transactions/Transaction.cs @@ -1393,5 +1393,11 @@ public void AddFaasAttribute(string name, object value) var faasAttrib = _attribDefs.GetFaasAttribute(name); TransactionMetadata.UserAndRequestAttributes.TrySetValue(faasAttrib, value); } + + public object GetFaasAttribute(string name) + { + var faasAttrib = _attribDefs.GetFaasAttribute(name); + return TransactionMetadata.UserAndRequestAttributes.GetAttributeValues(AttributeClassification.Intrinsics).FirstOrDefault(v => v.AttributeDefinition == faasAttrib)?.Value; + } } } diff --git a/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Api/ITransaction.cs b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Api/ITransaction.cs index c8fd50cfaf..ef241d38fb 100644 --- a/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Api/ITransaction.cs +++ b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Api/ITransaction.cs @@ -315,5 +315,6 @@ ISegment StartMessageBrokerSegment(MethodCall methodCall, MessageBrokerDestinati void AddLambdaAttribute(string name, object value); void AddFaasAttribute(string name, object value); + object GetFaasAttribute(string name); } } diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionInProcessExecuteWithWatchersAsyncWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionInProcessExecuteWithWatchersAsyncWrapper.cs new file mode 100644 index 0000000000..a4062d3ca9 --- /dev/null +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionInProcessExecuteWithWatchersAsyncWrapper.cs @@ -0,0 +1,141 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using NewRelic.Agent.Api; +using NewRelic.Agent.Extensions.Providers.Wrapper; +using NewRelic.Reflection; + +namespace NewRelic.Providers.Wrapper.AzureFunction; + +public class AzureFunctionInProcessExecuteWithWatchersAsyncWrapper : IWrapper +{ + private static ConcurrentDictionary _functionDetailsCache = new(); + private static Func _fullNameGetter; + private static Func _functionDescriptorGetter; + private static Func _idGetter; + + private static bool _loggedDisabledMessage; + + private static bool _coldStart = true; + private static bool IsColdStart => _coldStart && !(_coldStart = false); + + + public bool IsTransactionRequired => false; + + public CanWrapResponse CanWrap(InstrumentedMethodInfo instrumentedMethodInfo) + { + return new CanWrapResponse(nameof(AzureFunctionInProcessExecuteWithWatchersAsyncWrapper).Equals(instrumentedMethodInfo.RequestedWrapperName)); + } + + public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, ITransaction transaction) + { + if (!agent.Configuration.AzureFunctionModeEnabled) // bail early if azure function mode isn't enabled + { + if (!_loggedDisabledMessage) + { + agent.Logger.Info("Azure Function mode is not enabled; Azure Functions will not be instrumented."); + _loggedDisabledMessage = true; + } + + return Delegates.NoOp; + } + + object functionInstance = instrumentedMethodCall.MethodCall.MethodArguments[0]; + + _functionDescriptorGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor(functionInstance.GetType(), "FunctionDescriptor"); + var functionDescriptor = _functionDescriptorGetter(functionInstance); + + _fullNameGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor(functionDescriptor.GetType(), "FullName"); + string functionClassAndMethodName = _fullNameGetter(functionDescriptor); + + // cache the function details by function name so we only have to reflect on the function once + var inProcessFunctionDetails = _functionDetailsCache.GetOrAdd(functionClassAndMethodName, _ => GetInProcessFunctionDetails(functionClassAndMethodName)); + + _idGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor(functionInstance.GetType(), "Id"); + string invocationId = _idGetter(functionInstance).ToString(); + + agent.Logger.Finest("Instrumenting in-process Azure Function: {FunctionName} / invocation ID {invocationId} / Trigger {Trigger}.", inProcessFunctionDetails.FunctionName, invocationId, inProcessFunctionDetails.Trigger); + + agent.RecordSupportabilityMetric($"DotNet/AzureFunction/Worker/InProcess"); + agent.RecordSupportabilityMetric($"DotNet/AzureFunction/Trigger/{inProcessFunctionDetails.TriggerTypeName ?? "unknown"}"); + + transaction = agent.CreateTransaction( + isWeb: inProcessFunctionDetails.IsWebTrigger, + category: "AzureFunction", + transactionDisplayName: inProcessFunctionDetails.FunctionName, + doNotTrackAsUnitOfWork: true); + + if (instrumentedMethodCall.IsAsync) + { + transaction.AttachToAsync(); + transaction.DetachFromPrimary(); //Remove from thread-local type storage + } + + if (IsColdStart) // only report this attribute if it's a cold start + { + transaction.AddFaasAttribute("faas.coldStart", true); + } + + transaction.AddFaasAttribute("cloud.resource_id", agent.Configuration.AzureFunctionResourceIdWithFunctionName(inProcessFunctionDetails.FunctionName)); + transaction.AddFaasAttribute("faas.name", $"{agent.Configuration.AzureFunctionAppName}/{inProcessFunctionDetails.FunctionName}"); + transaction.AddFaasAttribute("faas.trigger", inProcessFunctionDetails.Trigger); + transaction.AddFaasAttribute("faas.invocation_id", invocationId); + + var segment = transaction.StartTransactionSegment(instrumentedMethodCall.MethodCall, "Azure In-Proc Pipeline"); + + return Delegates.GetAsyncDelegateFor( + agent, + segment, + false, + InvokeFunctionAsyncResponse, + TaskContinuationOptions.ExecuteSynchronously); + + void InvokeFunctionAsyncResponse(Task responseTask) + { + try + { + if (responseTask.IsFaulted) + { + transaction.NoticeError(responseTask.Exception); + } + } + finally + { + segment.End(); + transaction.End(); + } + } + } + + private InProcessFunctionDetails GetInProcessFunctionDetails(string functionClassAndMethodName) + { + string functionClassName = functionClassAndMethodName.Substring(0, functionClassAndMethodName.LastIndexOf('.')); + string functionMethodName = functionClassAndMethodName.Substring(functionClassAndMethodName.LastIndexOf('.') + 1); + + // get the type for functionClassName from any loaded assembly, since it's not in the current assembly + Type functionClassType = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()).FirstOrDefault(t => t.FullName == functionClassName); + + MethodInfo functionMethod = functionClassType?.GetMethod(functionMethodName); + var functionNameAttribute = functionMethod?.GetCustomAttributes().FirstOrDefault(a => a.GetType().Name == "FunctionNameAttribute"); + string functionName = functionNameAttribute?.GetType().GetProperty("Name")?.GetValue(functionNameAttribute) as string; + + var triggerAttributeParameter = functionMethod?.GetParameters().FirstOrDefault(p => p.GetCustomAttributes().Any(a => a.GetType().Name.Contains("TriggerAttribute"))); + var triggerAttribute = triggerAttributeParameter?.GetCustomAttributes().FirstOrDefault(); + string triggerAttributeName = triggerAttribute?.GetType().Name; + string triggerType = triggerAttributeName?.ResolveTriggerType(); + + var inProcessFunctionDetails = new InProcessFunctionDetails + { + Trigger = triggerType, + TriggerTypeName = triggerAttributeName?.Replace("TriggerAttribute", string.Empty), + FunctionName = functionName, + }; + + return inProcessFunctionDetails; + } +} diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionInProcessInvokeAsyncWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionInProcessInvokeAsyncWrapper.cs new file mode 100644 index 0000000000..cf149f3913 --- /dev/null +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionInProcessInvokeAsyncWrapper.cs @@ -0,0 +1,252 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using NewRelic.Agent.Api; +using NewRelic.Agent.Extensions.Providers.Wrapper; +using NewRelic.Reflection; + +namespace NewRelic.Providers.Wrapper.AzureFunction; + +public class AzureFunctionInProcessInvokeAsyncWrapper : IWrapper +{ + private static readonly ConcurrentDictionary> _getResultFromGenericTask = new(); + + public bool IsTransactionRequired => true; + + public CanWrapResponse CanWrap(InstrumentedMethodInfo methodInfo) + { + return new CanWrapResponse(nameof(AzureFunctionInProcessInvokeAsyncWrapper).Equals(methodInfo.RequestedWrapperName)); + } + + public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, ITransaction transaction) + { + if (!agent.Configuration.AzureFunctionModeEnabled) // bail early if azure function mode isn't enabled + { + return Delegates.NoOp; + } + + var trigger = transaction.GetFaasAttribute("faas.trigger") as string; + var name = transaction.GetFaasAttribute("faas.name") as string; + var functionName = name?.Substring(name.LastIndexOf('/') + 1); + + object[] args = (object[])instrumentedMethodCall.MethodCall.MethodArguments[1]; + + bool handledHttpArg = false; + if (trigger == "http") + { + // iterate each argument to find one with a type we can work with + foreach (object arg in args) + { + var argType = arg?.GetType().FullName; + handledHttpArg = TryHandleHttpTrigger(arg, argType, transaction, agent); + if (handledHttpArg) + break; + } + + if (!handledHttpArg) + { + var argTypeNames = string.Join(", ", args.Select(a => a?.GetType().FullName)); + agent.Logger.Debug($"Unable to extract HttpTrigger attributes from request argument(s): {argTypeNames}"); + } + } + else if (trigger == "pubsub") + { + foreach (object arg in args) + { + var argType = arg?.GetType().FullName; + if (TryExtractServiceBusDTHeaders(arg, argType, transaction)) + break; + } + } + + var segment = transaction.StartTransactionSegment(instrumentedMethodCall.MethodCall, functionName); + + return Delegates.GetAsyncDelegateFor( + agent, + segment, + false, + InvokeFunctionAsyncResponse, + TaskContinuationOptions.ExecuteSynchronously); + + void InvokeFunctionAsyncResponse(Task responseTask) + { + try + { + if (responseTask.IsFaulted) + { + transaction.NoticeError(responseTask.Exception); + return; + } + + var result = GetTaskResult(responseTask); + if (result == null) + { + return; + } + + var resultType = result.GetType(); + agent.Logger.Debug($"Azure Function response type: {resultType.FullName}"); + + // insert DT headers if the response object is a ServiceBusMessage + if (resultType.FullName == "Azure.Messaging.ServiceBus.ServiceBusMessage") + { + TryInsertServiceBusDTHeaders(transaction, result); + return; + } + + // if the trigger is HTTP, try to set the StatusCode + if (trigger == "http" && handledHttpArg) + { + TrySetHttpResponseStatusCode(result, resultType, transaction, agent); + } + } + finally + { + segment.End(); + } + } + } + + private bool TrySetHttpResponseStatusCode(dynamic result, Type resultType, ITransaction transaction, IAgent agent) + { + // make sure there's a StatusCode property on the result object + var statusCodeProperty = resultType.GetProperty("StatusCode"); + if (statusCodeProperty != null) + { + var statusCode = statusCodeProperty.GetValue(result); + if (statusCode != null) + { + transaction.SetHttpResponseStatusCode((int)statusCode); + return true; + } + } + + agent.Logger.Debug($"Could not find StatusCode property on response object type {result?.GetType().FullName}"); + return false; + } + + private bool TryHandleHttpTrigger(dynamic arg, string argTypeName, ITransaction transaction, IAgent agent) + { + if (arg is System.Net.Http.HttpRequestMessage httpTriggerArg) + { + IEnumerable>> headers = httpTriggerArg.Headers; // IEnumerable>> + + Uri requestUri = httpTriggerArg.RequestUri; + string requestPath = requestUri.AbsolutePath; + transaction.SetUri(requestPath); + + string requestMethod = httpTriggerArg.Method.Method; + transaction.SetRequestMethod(requestMethod); + + if (headers != null) + { + transaction.AcceptDistributedTraceHeaders(headers, GetHeaderValueFromIEnumerable, TransportType.HTTP); + } + + return true; + } + + if (argTypeName is "Microsoft.AspNetCore.Http.DefaultHttpRequest" or "Microsoft.AspNetCore.Http.HttpRequest") + { + IDictionary headers = arg.Headers; + + var requestMethod = arg.Method; + var requestPath = arg.Path.Value; + + transaction.SetRequestMethod(requestMethod); + transaction.SetUri(requestPath); + + if (headers?.Count != 0) + { + transaction.AcceptDistributedTraceHeaders(headers, GetHeaderValueFromIDictionary, TransportType.HTTP); + } + + return true; + } + + return false; + } + + /// + /// Extract ServiceBus DT headers from the ApplicationProperties collection, if available + /// + private bool TryExtractServiceBusDTHeaders(dynamic arg, string argTypeName, ITransaction transaction) + { + if (argTypeName == "Azure.Messaging.ServiceBus.ServiceBusReceivedMessage") + { + if (arg.ApplicationProperties is ReadOnlyDictionary applicationProperties) + { + transaction.AcceptDistributedTraceHeaders(applicationProperties, GetServiceBusDTHeaders, TransportType.Queue); + return true; + } + } + + return false; + + IEnumerable GetServiceBusDTHeaders(ReadOnlyDictionary applicationProperties, string key) + { + var headerValues = new List(); + foreach (var item in applicationProperties) + { + if (item.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + headerValues.Add(item.Value as string); + } + } + + return headerValues; + } + } + + /// + /// Insert ServiceBus DT headers into the ApplicationProperties collection + /// + private bool TryInsertServiceBusDTHeaders(ITransaction transaction, object result) + { + // inject distributed tracing headers into the response message + dynamic serviceBusMessage = result; + if (serviceBusMessage.ApplicationProperties is IDictionary applicationProperties) + { + transaction.InsertDistributedTraceHeaders(applicationProperties, SetServiceBusDTHeaders); + return true; + } + + return false; + + void SetServiceBusDTHeaders(IDictionary applicationProperties, string key, string value) + { + applicationProperties.Add(key, value); + } + + } + + private IEnumerable GetHeaderValueFromIEnumerable(IEnumerable>> headers, string key) + { + var val = headers.FirstOrDefault(kvp => kvp.Key == key).Value; + return val; + } + + private IEnumerable GetHeaderValueFromIDictionary(IDictionary headers, string key) + { + var val = headers.FirstOrDefault(kvp => kvp.Key == key).Value; + return val; + } + + + private static object GetTaskResult(object task) + { + if (((Task)task).IsFaulted) + { + return null; + } + + var getResponse = _getResultFromGenericTask.GetOrAdd(task.GetType(), t => VisibilityBypasser.Instance.GeneratePropertyAccessor(t, "Result")); + return getResponse(task); + } +} diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InvokeFunctionAsyncWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionIsolatedInvokeFunctionAsyncWrapper.cs similarity index 73% rename from src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InvokeFunctionAsyncWrapper.cs rename to src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionIsolatedInvokeFunctionAsyncWrapper.cs index 5fb0105215..ce7c4fe632 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InvokeFunctionAsyncWrapper.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/AzureFunctionIsolatedInvokeFunctionAsyncWrapper.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -11,11 +12,10 @@ namespace NewRelic.Providers.Wrapper.AzureFunction; -public class InvokeFunctionAsyncWrapper : IWrapper +public class AzureFunctionIsolatedInvokeAsyncWrapper : IWrapper { private static MethodInfo _getInvocationResultMethod; private static bool _loggedDisabledMessage; - private const string WrapperName = "AzureFunctionInvokeAsyncWrapper"; private static bool _coldStart = true; private static bool IsColdStart => _coldStart && !(_coldStart = false); @@ -26,7 +26,7 @@ public class InvokeFunctionAsyncWrapper : IWrapper public CanWrapResponse CanWrap(InstrumentedMethodInfo methodInfo) { - return new CanWrapResponse(WrapperName.Equals(methodInfo.RequestedWrapperName)); + return new CanWrapResponse(nameof(AzureFunctionIsolatedInvokeAsyncWrapper).Equals(methodInfo.RequestedWrapperName)); } public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, @@ -47,17 +47,20 @@ public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall ins if (functionContext == null) { - agent.Logger.Debug($"{WrapperName}: FunctionContext is null, can't instrument this invocation."); + agent.Logger.Debug($"{nameof(AzureFunctionIsolatedInvokeAsyncWrapper)}: FunctionContext is null, can't instrument this invocation."); throw new ArgumentNullException("functionContext"); } - var functionDetails = new FunctionDetails(functionContext, agent); + var functionDetails = new IsolatedFunctionDetails(functionContext, agent); if (!functionDetails.IsValid()) { - agent.Logger.Debug($"{WrapperName}: FunctionDetails are invalid, can't instrument this invocation."); + agent.Logger.Debug($"{nameof(AzureFunctionIsolatedInvokeAsyncWrapper)}: FunctionDetails are invalid, can't instrument this invocation."); throw new Exception("FunctionDetails are missing some require information."); } + agent.RecordSupportabilityMetric($"DotNet/AzureFunction/Worker/Isolated"); + agent.RecordSupportabilityMetric($"DotNet/AzureFunction/Trigger/{functionDetails.TriggerTypeName ?? "unknown"}"); + transaction = agent.CreateTransaction( isWeb: functionDetails.IsWebTrigger, category: "AzureFunction", @@ -90,6 +93,14 @@ public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall ins transaction.AcceptDistributedTraceHeaders(functionDetails.Headers, GetHeaderValue, TransportType.HTTP); } } + // save for later + //else if (functionDetails.TriggerTypeName == "ServiceBus") + //{ + // if (functionDetails.Headers?.Count != 0) + // { + // transaction.AcceptDistributedTraceHeaders(functionDetails.Headers, GetHeaderValue, TransportType.Queue); + // } + //} var segment = transaction.StartTransactionSegment(instrumentedMethodCall.MethodCall, functionDetails.FunctionName); @@ -110,21 +121,20 @@ void InvokeFunctionAsyncResponse(Task responseTask) return; } + 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(FunctionContextBindingFeatureExtensionsTypeName); + _getInvocationResultMethod = type.GetMethods().Single(m => m.Name == "GetInvocationResult" && !m.ContainsGenericParameters); + } + dynamic invocationResult = _getInvocationResultMethod.Invoke(null, new[] { functionContext }); + var result = invocationResult?.Value; + // 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(FunctionContextBindingFeatureExtensionsTypeName); - _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) + if (result != null && result.StatusCode != null) { var statusCode = result.StatusCode; transaction.SetHttpResponseStatusCode((int)statusCode); diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionsHttpProxyingMiddlewareWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionsHttpProxyingMiddlewareWrapper.cs index 2922e0ce83..ce7c815879 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionsHttpProxyingMiddlewareWrapper.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionsHttpProxyingMiddlewareWrapper.cs @@ -10,13 +10,11 @@ namespace NewRelic.Providers.Wrapper.AzureFunction; 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)); + return new CanWrapResponse(nameof(FunctionsHttpProxyingMiddlewareWrapper).Equals(methodInfo.RequestedWrapperName)); } /// diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InProcessFunctionDetails.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InProcessFunctionDetails.cs new file mode 100644 index 0000000000..de01d1a3ba --- /dev/null +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/InProcessFunctionDetails.cs @@ -0,0 +1,13 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace NewRelic.Providers.Wrapper.AzureFunction; + +public class InProcessFunctionDetails +{ + public string Trigger { get; set; } + public string TriggerTypeName { get; set; } + public bool IsWebTrigger => Trigger == "http"; + public string FunctionName { get; set; } +} + 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 821b5cc8d2..c6dab4bf6d 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/Instrumentation.xml +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/Instrumentation.xml @@ -7,18 +7,32 @@ SPDX-License-Identifier: Apache-2.0 - + - - + + + + + + + + + + + + + + - + + + @@ -30,7 +44,6 @@ SPDX-License-Identifier: Apache-2.0 - diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionDetails.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/IsolatedFunctionDetails.cs similarity index 84% rename from src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionDetails.cs rename to src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/IsolatedFunctionDetails.cs index d4d3eabb24..b75573942b 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/FunctionDetails.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/IsolatedFunctionDetails.cs @@ -13,7 +13,7 @@ namespace NewRelic.Providers.Wrapper.AzureFunction; -internal class FunctionDetails +internal class IsolatedFunctionDetails { private static MethodInfo _bindFunctionInputAsync; private static MethodInfo _genericFunctionInputBindingFeatureGetter; @@ -27,7 +27,7 @@ internal class FunctionDetails private const string AspNetCoreExtensionsAssemblyName = "Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore"; private const string IFunctionInputBindingFeatureTypeName = "Microsoft.Azure.Functions.Worker.Context.Features.IFunctionInputBindingFeature"; - public FunctionDetails(dynamic functionContext, IAgent agent) + public IsolatedFunctionDetails(dynamic functionContext, IAgent agent) { try { @@ -76,6 +76,10 @@ public FunctionDetails(dynamic functionContext, IAgent agent) var triggerTypeName = triggerAttribute.GetType().Name; Trigger = triggerTypeName.ResolveTriggerType(); + + // strip out the `TriggerAttribute` suffix + TriggerTypeName = triggerTypeName.Replace("TriggerAttribute", string.Empty); + foundTrigger = true; break; } @@ -98,6 +102,11 @@ public FunctionDetails(dynamic functionContext, IAgent agent) { ParseHttpTriggerParameters(agent, functionContext); } + // save for later + //else if (TriggerTypeName == "ServiceBus") + //{ + // ParseServiceBusParameters(agent, functionContext); + //} } catch (Exception ex) { @@ -106,6 +115,20 @@ public FunctionDetails(dynamic functionContext, IAgent agent) } } + // save for later + //private void ParseServiceBusParameters(IAgent agent, dynamic functionContext) + //{ + // if (functionContext?.BindingContext?.BindingData is IReadOnlyDictionary bindingData) + // { + // if (bindingData.TryGetValue("ApplicationProperties", out var value)) + // { + // // The headers are stored as a JSON blob. + // var headersJson = value.ToString(); + // Headers = DictionaryHelpers.FromJson(headersJson); + // } + // } + //} + private void ParseHttpTriggerParameters(IAgent agent, dynamic functionContext) { if (!_hasAspNetCoreExtensionsReference.HasValue) @@ -179,11 +202,14 @@ private void ParseHttpTriggerParameters(IAgent agent, dynamic functionContext) } } - if (functionContext?.BindingContext?.BindingData is IReadOnlyDictionary bindingData && bindingData.ContainsKey("Headers")) + if (functionContext?.BindingContext?.BindingData is IReadOnlyDictionary bindingData) { - // The headers are stored as a JSON blob. - var headersJson = bindingData["Headers"].ToString(); - Headers = DictionaryHelpers.FromJson(headersJson); + if (bindingData.ContainsKey("Headers")) + { + // The headers are stored as a JSON blob. + var headersJson = bindingData["Headers"].ToString(); + Headers = DictionaryHelpers.FromJson(headersJson); + } } } @@ -194,7 +220,15 @@ public bool IsValid() public string FunctionName { get; } + /// + /// The otel semantic convention trigger type. (pubsub, http, timer, etc.) + /// public string Trigger { get; } + + /// + /// The actual trigger type (like Http, Timer, etc.) + /// + public string TriggerTypeName { get; } public string InvocationId { get; } public bool IsWebTrigger => Trigger == "http"; public string RequestMethod { get; private set; } diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/TriggerTypeExtensions.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/TriggerTypeExtensions.cs index a3f039ace9..a05648f884 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/TriggerTypeExtensions.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AzureFunction/TriggerTypeExtensions.cs @@ -15,6 +15,8 @@ public static string ResolveTriggerType(this string triggerTypeName) // The return values are based on https://opentelemetry.io/docs/specs/semconv/attributes-registry/faas/ (scroll to the bottom) // 08/27/2024 - All trigger types added from https://learn.microsoft.com/en-us/azure/azure-functions/functions-triggers-bindings?tabs=isolated-process%2Cpython-v2&pivots=programming-language-csharp + // 02/28/2025 - Updates here also need to be reflected in Angler's supportability metrics list + string resolvedTriggerType; switch (trigger) @@ -50,6 +52,9 @@ public static string ResolveTriggerType(this string triggerTypeName) break; case "DaprServiceInvocation": // RPC call to another Dapr service - no group so other. + case "Activity": // Durable Functions - no group so other. + case "Entity": // Durable Functions - no group so other. + case "Orchestration": // Durable Functions - no group so other. resolvedTriggerType = "other"; break; default: diff --git a/src/Agent/NewRelic/Home/Home.csproj b/src/Agent/NewRelic/Home/Home.csproj index 30c4ef7982..0335434869 100644 --- a/src/Agent/NewRelic/Home/Home.csproj +++ b/src/Agent/NewRelic/Home/Home.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Agent/NewRelic/Profiler/Configuration/Configuration.h b/src/Agent/NewRelic/Profiler/Configuration/Configuration.h index 8a655d459e..32f4a5cca3 100644 --- a/src/Agent/NewRelic/Profiler/Configuration/Configuration.h +++ b/src/Agent/NewRelic/Profiler/Configuration/Configuration.h @@ -644,11 +644,20 @@ namespace NewRelic { namespace Profiler { namespace Configuration { bool IsAzureFunction() const { - // Azure Functions sets the FUNCTIONS_WORKER_RUNTIME environment variable to "dotnet-isolated" when running in the .NET worker. + // Azure Functions sets the FUNCTIONS_WORKER_RUNTIME environment variable when running in Azure Functions auto functionsWorkerRuntime = _systemCalls->TryGetEnvironmentVariable(_X("FUNCTIONS_WORKER_RUNTIME")); return functionsWorkerRuntime != nullptr && functionsWorkerRuntime->length() > 0; } + bool IsAzureFunctionInProcess() const + { + auto functionsWorkerRuntime = _systemCalls->TryGetEnvironmentVariable(_X("FUNCTIONS_WORKER_RUNTIME")); + + // "dotnet" when running in in-process mode + // "dotnet-isolated" when running in isolated mode + return functionsWorkerRuntime != nullptr && Strings::AreEqualCaseInsensitive(*functionsWorkerRuntime, _X("dotnet")); + } + /// /// Returns 0 if the process should not be instrumented, 1 if it should be instrumented, and -1 if it is indeterminate. /// @@ -656,6 +665,18 @@ namespace NewRelic { namespace Profiler { namespace Configuration { { LogInfo(_X("Azure function detected. Determining whether to instrument ") + commandLine); + if (IsAzureFunctionInProcess()) + { + // appPoolName starts with "~" for Azure Functions background tasks (kudu / scm) + if (appPoolId.find(_X("~")) == 0) { + LogInfo(_X("This application pool (") + appPoolId + _X(") has been identified as an in-process Azure Functions built-in background application and will be ignored.")); + return 0; + } + + LogInfo(L"Function is running in-process. This process will be instrumented."); + return 1; + } + bool isAzureWebJobsScriptWebHost = appPoolId.length() > 0 && NewRelic::Profiler::Strings::ContainsCaseInsensitive(commandLine, appPoolId); if (isAzureWebJobsScriptWebHost) { diff --git a/src/Agent/NewRelic/Profiler/ConfigurationTest/ConfigurationTest.cpp b/src/Agent/NewRelic/Profiler/ConfigurationTest/ConfigurationTest.cpp index a6648b7582..7bd920a246 100644 --- a/src/Agent/NewRelic/Profiler/ConfigurationTest/ConfigurationTest.cpp +++ b/src/Agent/NewRelic/Profiler/ConfigurationTest/ConfigurationTest.cpp @@ -73,10 +73,7 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsFalse(configuration.ShouldInstrument(L"foo.exe", L"", L"", L"", false)); } - // tests to verify that "legacy" behavior (before azure function support) is retained. - // If NEW_RELIC_AZURE_FUNCTION_MODE_ENABLED environment variable is not set or is set to false, - // we should behave as if no azure function support has been added. - TEST_METHOD(azure_function_should_behave_as_legacy_if_azure_function_mode_not_specified) + TEST_METHOD(azure_function_should_instrument_functions_net_host_if_azure_function_mode_not_specified) { std::wstring configurationXml(L"\ \ @@ -93,10 +90,7 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsTrue(configuration.ShouldInstrument(L"functionsnethost.exe", L"", L"", L"blah blah blah FooBarBaz blah blah blah", true)); } - // tests to verify that "legacy" behavior (before azure function support) is retained. - // If NEW_RELIC_AZURE_FUNCTION_MODE_ENABLED environment variable is not set or is set to false, - // we should behave as if no azure function support has been added. - TEST_METHOD(azure_function_should_behave_as_legacy_if_azure_function_mode_disabled) + TEST_METHOD(isolated_azure_function_should_instrument_functions_net_host_if_azure_function_mode_disabled) { std::wstring configurationXml(L"\ \ @@ -113,8 +107,25 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsTrue(configuration.ShouldInstrument(L"functionsnethost.exe", L"", L"", L"blah blah blah FooBarBaz blah blah blah", true)); } + TEST_METHOD(in_proc_azure_function_should_instrument_functions_net_host_if_azure_function_mode_disabled) + { + std::wstring configurationXml(L"\ + \ + \ + \ + \ + "); + + auto systemCalls = std::make_shared(); + systemCalls->environmentVariables[L"FUNCTIONS_WORKER_RUNTIME"] = L"dotnet"; + systemCalls->environmentVariables[L"NEW_RELIC_AZURE_FUNCTION_MODE_ENABLED"] = L"0"; + + Configuration configuration(configurationXml, _missingConfig, L"", systemCalls); + + Assert::IsTrue(configuration.ShouldInstrument(L"w3wp.exe", L"", L"someapppoolname", L"blah blah blah FooBarBaz blah blah blah", true)); + } - TEST_METHOD(should_not_instrument_azure_function_app_pool_id_in_commandline) + TEST_METHOD(should_not_instrument_isolated_azure_function_app_pool_id_in_commandline) { std::wstring configurationXml(L"\ \ @@ -132,7 +143,7 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsFalse(configuration.ShouldInstrument(L"w3wp.exe", L"", L"FooBarBaz", L"blah blah blah FooBarBaz blah blah blah", true)); } - TEST_METHOD(should_instrument_azure_function_fallback_to_app_pool_checking) + TEST_METHOD(should_instrument_isolated_azure_function_fallback_to_app_pool_checking) { std::wstring configurationXml(L"\ \ @@ -150,7 +161,7 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsTrue(configuration.ShouldInstrument(L"w3wp.exe", L"", L"foo", L"", true)); } - TEST_METHOD(should_not_instrument_azure_function_func_exe_process_path) + TEST_METHOD(should_not_instrument_isolated_azure_function_func_exe_process_path) { std::wstring configurationXml(L"\ \ @@ -168,7 +179,7 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsFalse(configuration.ShouldInstrument(L"func.exe", L"", L"", L"blah blah blah FooBarBaz blah blah blah", true)); } - TEST_METHOD(should_instrument_azure_function_functionsnethost_exe_process_path) + TEST_METHOD(should_instrument_isolated_azure_function_functionsnethost_exe_process_path) { std::wstring configurationXml(L"\ \ @@ -186,7 +197,7 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsTrue(configuration.ShouldInstrument(L"functionsnethost.exe", L"", L"", L"blah blah blah FooBarBaz blah blah blah", true)); } - TEST_METHOD(should_instrument_azure_function_functions_worker_id_in_command_line) + TEST_METHOD(should_instrument_isolated_azure_function_functions_worker_id_in_command_line) { std::wstring configurationXml(L"\ \ @@ -204,7 +215,7 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsTrue(configuration.ShouldInstrument(L"SomeFW481FunctionApp.exe", L"", L"", L"blah blah blah --functions-worker-id FooBarBaz blah blah blah", false)); } - TEST_METHOD(should_instrument_azure_function_worker_id_in_command_line) + TEST_METHOD(should_instrument_isolated_azure_function_worker_id_in_command_line) { std::wstring configurationXml(L"\ \ @@ -222,6 +233,42 @@ namespace NewRelic { namespace Profiler { namespace Configuration { namespace Te Assert::IsTrue(configuration.ShouldInstrument(L"SomeFW481FunctionApp.exe", L"", L"", L"blah blah blah --worker-id FooBarBaz blah blah blah", false)); } + TEST_METHOD(should_instrument_in_process_azure_function) + { + std::wstring configurationXml(L"\ + \ + \ + \ + \ +"); + + auto systemCalls = std::make_shared(); + systemCalls->environmentVariables[L"FUNCTIONS_WORKER_RUNTIME"] = L"dotnet"; + systemCalls->environmentVariables[L"NEW_RELIC_AZURE_FUNCTION_MODE_ENABLED"] = L"true"; + + Configuration configuration(configurationXml, _missingConfig, L"", systemCalls); + + Assert::IsTrue(configuration.ShouldInstrument(L"SomeFW481FunctionApp.exe", L"", L"someapppoolname", L"blah blah blah FooBarBaz blah blah blah", false)); + } + + TEST_METHOD(should_not_instrument_in_process_azure_function_kudu_app_pool) + { + std::wstring configurationXml(L"\ + \ + \ + \ + \ +"); + + auto systemCalls = std::make_shared(); + systemCalls->environmentVariables[L"FUNCTIONS_WORKER_RUNTIME"] = L"dotnet"; + systemCalls->environmentVariables[L"NEW_RELIC_AZURE_FUNCTION_MODE_ENABLED"] = L"true"; + + Configuration configuration(configurationXml, _missingConfig, L"", systemCalls); + + Assert::IsFalse(configuration.ShouldInstrument(L"SomeFW481FunctionApp.exe", L"", L"~somekuduapppool", L"blah blah blah FooBarBaz blah blah blah", false)); + } + TEST_METHOD(instrument_process) { ProcessesPtr processes(new Processes()); diff --git a/tests/Agent/IntegrationTests/ApplicationHelperLibraries/ApplicationLifecycle/AppLifecycleManager.cs b/tests/Agent/IntegrationTests/ApplicationHelperLibraries/ApplicationLifecycle/AppLifecycleManager.cs index 94390f7daa..deca892482 100644 --- a/tests/Agent/IntegrationTests/ApplicationHelperLibraries/ApplicationLifecycle/AppLifecycleManager.cs +++ b/tests/Agent/IntegrationTests/ApplicationHelperLibraries/ApplicationLifecycle/AppLifecycleManager.cs @@ -25,7 +25,15 @@ public class AppLifecycleManager private const string DefaultPort = "5001"; - private const int MinutesToWait = 5; + private static int MinutesToWait + { + get + { + // look for TEST_MINUTES_TO_WAIT env var, default to 5 minutes if not found + var minutes = Environment.GetEnvironmentVariable("TEST_MINUTES_TO_WAIT"); + return string.IsNullOrEmpty(minutes) ? 5 : int.Parse(minutes); + } + } private static string _applicationName; @@ -100,6 +108,7 @@ public static void WaitForTestCompletion(string port) { using (var eventWaitHandle = new EventWaitHandle(false, EventResetMode.AutoReset, ShutdownChannelPrefix + port)) { + Log($"Waiting for shutdown event handle: {ShutdownChannelPrefix + port}"); if (!eventWaitHandle.WaitOne(TimeSpan.FromMinutes(MinutesToWait))) Log("Timed out waiting for shutdown event handle to be signaled."); } @@ -124,9 +133,10 @@ public static void CreatePidFile() file.WriteLine(pid); } + Log("PID File created: " + pidFilePath); } - private static void Log(string message) + public static void Log(string message) { Console.WriteLine($"[{ApplicationName}] {message}"); } diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/AzureFunctionApplication.csproj b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/AzureFunctionApplication.csproj index 695a1067e8..47f02687b2 100644 --- a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/AzureFunctionApplication.csproj +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/AzureFunctionApplication.csproj @@ -3,7 +3,6 @@ net481;net8.0;net9.0 v4 Exe - enable @@ -22,11 +21,12 @@ - + - + + @@ -39,11 +39,5 @@ Never - - - - - - \ No newline at end of file diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/HttpTriggerFunctionUsingAspNetCorePipeline.cs b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/HttpTriggerFunctionUsingAspNetCorePipeline.cs index 7f6ee43d1f..ffab71de29 100644 --- a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/HttpTriggerFunctionUsingAspNetCorePipeline.cs +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/HttpTriggerFunctionUsingAspNetCorePipeline.cs @@ -3,6 +3,7 @@ #if NET9_0 using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/HttpTriggerFunctionUsingSimpleInvocation.cs b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/HttpTriggerFunctionUsingSimpleInvocation.cs index 108ce91c41..3956d3f67d 100644 --- a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/HttpTriggerFunctionUsingSimpleInvocation.cs +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/HttpTriggerFunctionUsingSimpleInvocation.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Net; +using System.Threading.Tasks; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/Program.cs b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/Program.cs index 46cfbad660..bd0c9afca8 100644 --- a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/Program.cs +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/Program.cs @@ -1,24 +1,13 @@ // Copyright 2020 New Relic, Inc. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -using ApplicationLifecycle; +using System.Threading.Tasks; using Microsoft.Extensions.Hosting; internal class Program { private static async Task Main(string[] args) { - var cts = new CancellationTokenSource(); - - _ = AppLifecycleManager.GetPortFromArgs(args); - - // --port arg to this app is different from the --port arg sent in the `func` invocation - // so we have to pull it from a custom environment variable - var port = Environment.GetEnvironmentVariable("AZURE_FUNCTION_APP_EVENT_HANDLE_PORT"); - if (string.IsNullOrEmpty(port)) - throw new Exception("AZURE_FUNCTION_APP_EVENT_HANDLE_PORT environment variable not set"); - - var host = new HostBuilder() // the net481 and net8 target uses the "basic" azure function configuration // the net9 target uses the aspnetcore azure function configuration @@ -29,12 +18,6 @@ private static async Task Main(string[] args) #endif .Build(); - var task = host.RunAsync(cts.Token); - - AppLifecycleManager.CreatePidFile(); - AppLifecycleManager.WaitForTestCompletion(port); - - cts.Cancel(); - await task; + await host.RunAsync(); } } diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/ServiceBusTriggerFunction.cs b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/ServiceBusTriggerFunction.cs new file mode 100644 index 0000000000..57ca1eacd2 --- /dev/null +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionApplication/ServiceBusTriggerFunction.cs @@ -0,0 +1,37 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace AzureFunctionApplication; + +public class ServiceBusTriggerFunction +{ + [Function("ServiceBusTriggerFunction")] + public void Run([ServiceBusTrigger("func-test-queue")] ServiceBusReceivedMessage message, ILogger log) + { + var jsonMessage = JsonSerializer.Serialize(message, new JsonSerializerOptions() { WriteIndented = true }); + + log.LogInformation($"C# ServiceBus queue trigger function processed message: {jsonMessage}"); + } + + /// + /// Takes input from an HTTP trigger and sends a Service Bus message, which should then trigger ServiceBusTriggerFunction automagically + /// + [Function("HttpTrigger_SendServiceBusMessage")] + [ServiceBusOutput("func-test-queue")] + public async Task ServiceBusOutput([HttpTrigger(AuthorizationLevel.Admin, "post", Route = null)] HttpRequestMessage requestMessage, ILogger log) + { + var input = await requestMessage!.Content!.ReadAsStringAsync(); + + var serviceBusMessage = new ServiceBusMessage(input); + + log.LogInformation($"C# function processed: {input} and sent a ServiceBus message "); + return serviceBusMessage; + } +} diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/AzureFunctionInProcApplication.csproj b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/AzureFunctionInProcApplication.csproj new file mode 100644 index 0000000000..0ca818c4f9 --- /dev/null +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/AzureFunctionInProcApplication.csproj @@ -0,0 +1,22 @@ + + + net8.0 + v4 + x64 + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + \ No newline at end of file diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/HttpTriggerFunction.cs b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/HttpTriggerFunction.cs new file mode 100644 index 0000000000..dcaa8a7dc5 --- /dev/null +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/HttpTriggerFunction.cs @@ -0,0 +1,31 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Extensions.Logging; + +namespace AzureFunctionInProcApplication +{ + public static class HttpTriggerFunction + { + private static bool _firstTime = true; + + [FunctionName("HttpTriggerFunction")] + public static async Task Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req, ILogger logger) + { + logger.LogInformation("HttpTriggerFunction processed a request."); + + if (_firstTime) + { + await Task.Delay(250); // to ensure that the first invocation gets sampled + _firstTime = false; + } + + return new OkObjectResult("Welcome to Azure in-proc Functions!"); + } + } +} diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/ServiceBusTriggerFunction.cs b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/ServiceBusTriggerFunction.cs new file mode 100644 index 0000000000..16998c7c36 --- /dev/null +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/ServiceBusTriggerFunction.cs @@ -0,0 +1,39 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Net.Http; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AzureFunctionInProcApplication +{ + public class ServiceBusTriggerFunction + { + [FunctionName("ServiceBusTriggerFunction")] + public void Run([ServiceBusTrigger("func-test-queue")] ServiceBusReceivedMessage message, ILogger log) + { + var jsonMessage = JsonConvert.SerializeObject(message, Formatting.Indented); + + log.LogInformation($"C# ServiceBus queue trigger function processed message: {jsonMessage}"); + } + + /// + /// Takes input from an HTTP trigger and sends a Service Bus message, which should then trigger ServiceBusTriggerFunction automagically + /// + [FunctionName("HttpTrigger_SendServiceBusMessage")] + [return: ServiceBus("func-test-queue")] + public async Task ServiceBusOutput([HttpTrigger(AuthorizationLevel.Admin, "post", Route = null)] HttpRequestMessage requestMessage, ILogger log) + { + var input = await requestMessage!.Content!.ReadAsStringAsync(); + + var serviceBusMessage = new ServiceBusMessage(input); + + log.LogInformation($"C# function processed: {input} and sent a ServiceBus message "); + return serviceBusMessage; + } + } +} diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/host.json b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/host.json new file mode 100644 index 0000000000..47661c0098 --- /dev/null +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/host.json @@ -0,0 +1,9 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Error", + "AzureFunctionInProcApplication": "Information" + } + } +} diff --git a/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/local.settings.json b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/local.settings.json new file mode 100644 index 0000000000..e8a556b2e2 --- /dev/null +++ b/tests/Agent/IntegrationTests/Applications/AzureFunctionInProcApplication/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_INPROC_NET8_ENABLED": "1" + } +} diff --git a/tests/Agent/IntegrationTests/IntegrationTestHelpers/ITestLogger.cs b/tests/Agent/IntegrationTests/IntegrationTestHelpers/ITestLogger.cs index 118761ecd2..e6f6a5dbe2 100644 --- a/tests/Agent/IntegrationTests/IntegrationTestHelpers/ITestLogger.cs +++ b/tests/Agent/IntegrationTests/IntegrationTestHelpers/ITestLogger.cs @@ -8,5 +8,6 @@ public interface ITestLogger { void WriteLine(string message); void WriteLine(string format, params object[] args); + void WriteFormattedOutput(string formattedOutput); } } diff --git a/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/AzureFuncTool.cs b/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/AzureFuncTool.cs index e77f2e2ea1..3e0c679367 100644 --- a/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/AzureFuncTool.cs +++ b/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/AzureFuncTool.cs @@ -5,17 +5,30 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using Xunit; namespace NewRelic.Agent.IntegrationTestHelpers.RemoteServiceFixtures { public class AzureFuncTool : RemoteService { private readonly bool _enableAzureFunctionMode; + private readonly bool _inProc; - public AzureFuncTool(string applicationDirectoryName, string targetFramework, ApplicationType applicationType, bool createsPidFile = true, bool isCoreApp = false, bool publishApp = false, bool enableAzureFunctionMode = true) - : base(applicationDirectoryName, "AzureFunctionApplication.exe", targetFramework, applicationType, createsPidFile, isCoreApp, publishApp) + private StringBuilder _stdOutStringBuilder = new(); + private StringBuilder _stdErrStringBuilder = new(); + private bool _outputCapturedAfterShutdown; + + public AzureFuncTool(string applicationDirectoryName, string executableName, string targetFramework, ApplicationType applicationType, bool createsPidFile = true, bool isCoreApp = false, bool publishApp = false, bool enableAzureFunctionMode = true, bool inProc = false) + : base(applicationDirectoryName, executableName, targetFramework, applicationType, createsPidFile, isCoreApp, publishApp) { _enableAzureFunctionMode = enableAzureFunctionMode; + _inProc = inProc; + + CaptureStandardOutput = false; // we implement our own output capture here, so don't use the base class implementation } public override void CopyToRemote() @@ -29,7 +42,7 @@ public override void CopyToRemote() File.Copy(localSettingsPath, deployPath, true); } - public override void Start(string commandLineArguments, Dictionary environmentVariables, bool captureStandardOutput = false, bool doProfile = true) + public override void Start(string commandLineArguments, Dictionary environmentVariables, bool _ = false, bool doProfile = true) { var arguments = UsesSpecificPort ? $"{commandLineArguments} --port {Port}" @@ -48,12 +61,12 @@ public override void Start(string commandLineArguments, Dictionary _stdOutStringBuilder.AppendFormat($"[{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}] {{0}}{Environment.NewLine}", args.Data); + RemoteProcess.ErrorDataReceived += (sender, args) => _stdErrStringBuilder.AppendFormat($"[{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}] {{0}}{Environment.NewLine}", args.Data); + RemoteProcess.Start(); + RemoteProcess.BeginOutputReadLine(); + RemoteProcess.BeginErrorReadLine(); if (RemoteProcess == null) { throw new Exception("Process failed to start."); } - CapturedOutput = new ProcessOutput(TestLogger, RemoteProcess, captureStandardOutput); - if (RemoteProcess.HasExited && RemoteProcess.ExitCode != 0) { - if (captureStandardOutput) + CaptureOutput("[RemoteService]: Start"); + throw new Exception("App server shutdown unexpectedly."); + } + + WaitForAppServerToStartListening(RemoteProcess, true); + } + + protected override void WaitForAppServerToStartListening(Process process, bool _) + { + // When this URL returns a 200 and has "state":"Running" in the body, the app is ready + var url = $"http://{DestinationServerName}:{Port}/admin/host/status"; + + TestLogger?.WriteLine("Waiting for Azure function host to become ready..."); + var expectedBodyContent = "\"state\":\"Running\""; + var status = WaitForUrlToRespond(url, 200, expectedBodyContent, 20, 1000); + if (status) + return; + + if (!process.HasExited) + { + try { - CapturedOutput.WriteProcessOutputToLog("[RemoteService]: Start"); + //We need to attempt to clean up the process that did not successfully start. + process.Kill(); + } + catch (Exception) + { + TestLogger?.WriteLine("[RemoteService]: WaitForAppServerToStartListening could not kill hung remote process."); } - throw new Exception("App server shutdown unexpectedly."); } - WaitForAppServerToStartListening(RemoteProcess, captureStandardOutput); + CaptureOutput("[RemoteService]: WaitForAppServerToStartListening"); + Assert.Fail("Timed out waiting for Azure function host to become ready."); + } - public override void Shutdown(bool force = false) + private bool WaitForUrlToRespond(string url, int expectedStatusCode, string expectedBodyContent, int maxAttempts, int delayBetweenAttemptsMs) { - base.Shutdown(); + for (var i = 0; i < maxAttempts; i++) + { + try + { + using var client = new HttpClient(); + var response = client.GetAsync(url).Result; + if (response.StatusCode == (HttpStatusCode)expectedStatusCode) + { + // check the body for "state" : "Running" + var body = response.Content.ReadAsStringAsync().Result; + if (body.Contains(expectedBodyContent)) + { + TestLogger?.WriteLine("Azure function host is ready."); + return true; + } + } + } + catch + { + // ignored + } + Thread.Sleep(delayBetweenAttemptsMs); + } + return false; + } - // the actual azure function is shutdown at this point, but the func tool is still running. We can kill it here safely. + public override void Shutdown(bool _) + { try { if (RemoteProcess is { HasExited: false }) @@ -172,6 +241,27 @@ public override void Shutdown(bool force = false) { // ignored } + + if (!_outputCapturedAfterShutdown) // this method gets called multiple times; only capture the output once + { + CaptureOutput("Azure Func Tool"); + _outputCapturedAfterShutdown = true; + } + } + + private void CaptureOutput(string processDescription) + { + TestLogger?.WriteLine(""); + TestLogger?.WriteLine($"====== {processDescription} standard output log ====="); + var stdOutLog = _stdOutStringBuilder.ToString(); + TestLogger?.WriteFormattedOutput(stdOutLog); + TestLogger?.WriteLine($"====== {processDescription} end of standard output log ====="); + + TestLogger?.WriteLine(""); + TestLogger?.WriteLine($"====== {processDescription} standard error log ======="); + TestLogger?.WriteFormattedOutput(_stdErrStringBuilder.ToString()); + TestLogger?.WriteLine($"====== {processDescription} end of standard error log ====="); + TestLogger?.WriteLine(""); } } } diff --git a/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplication.cs b/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplication.cs index 0fc2db4e59..889a368a5c 100644 --- a/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplication.cs +++ b/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplication.cs @@ -275,7 +275,10 @@ protected RemoteApplication(ApplicationType applicationType, bool isCoreApp = fa public void WaitForExit() { - RemoteProcess.WaitForExit(); + if (!RemoteProcess.HasExited) + { + RemoteProcess.WaitForExit(); + } } public bool WaitForExit(int milliseconds) diff --git a/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplicationFixture.cs b/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplicationFixture.cs index bfb7db67fe..bc81cd5898 100644 --- a/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplicationFixture.cs +++ b/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplicationFixture.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading; @@ -566,11 +567,18 @@ protected void GetAndAssertSuccessStatus(string address, bool expectedSuccessSta } } - protected void PostJson(string address, string payload) + protected void PostJson(string address, string payload, List> headers = null) { var content = new StringContent(payload); content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + if (headers != null && headers.Any()) + { + foreach(var header in headers) + { + content.Headers.Add(header.Key, header.Value); + } + } var result = _httpClient.PostAsync(address, content).GetAwaiter().GetResult(); diff --git a/tests/Agent/IntegrationTests/IntegrationTestHelpers/XUnitTestLogger.cs b/tests/Agent/IntegrationTests/IntegrationTestHelpers/XUnitTestLogger.cs index 8401c740d0..d7f67ac497 100644 --- a/tests/Agent/IntegrationTests/IntegrationTestHelpers/XUnitTestLogger.cs +++ b/tests/Agent/IntegrationTests/IntegrationTestHelpers/XUnitTestLogger.cs @@ -30,5 +30,10 @@ public void WriteLine(string format, params object[] args) { _xunitOutput?.WriteLine($"[{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}] {format}", args); } + + public void WriteFormattedOutput(string formattedOutput) + { + _xunitOutput?.WriteLine(formattedOutput); + } } } diff --git a/tests/Agent/IntegrationTests/IntegrationTests.sln b/tests/Agent/IntegrationTests/IntegrationTests.sln index e53b641ddb..5c381dd730 100644 --- a/tests/Agent/IntegrationTests/IntegrationTests.sln +++ b/tests/Agent/IntegrationTests/IntegrationTests.sln @@ -6,6 +6,7 @@ MinimumVisualStudioVersion = 15.0.26228.04 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTests", "IntegrationTests\IntegrationTests.csproj", "{EE939277-76B4-4099-B3D6-300C8D70F3C1}" ProjectSection(ProjectDependencies) = postProject {01291327-B0E8-4EAB-929B-D6B796BF1D7C} = {01291327-B0E8-4EAB-929B-D6B796BF1D7C} + {05078BA6-DADF-4DDE-3339-62C1D9924642} = {05078BA6-DADF-4DDE-3339-62C1D9924642} {07FC6D0B-0638-4B73-80D1-05E17EF89183} = {07FC6D0B-0638-4B73-80D1-05E17EF89183} {0BD4D452-1466-43E0-BFE9-8B15B53BB11E} = {0BD4D452-1466-43E0-BFE9-8B15B53BB11E} {1B7BED7D-156F-4789-ABAA-AC5D982F57DE} = {1B7BED7D-156F-4789-ABAA-AC5D982F57DE} @@ -192,6 +193,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCoreWebApiLambdaAppli EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureFunctionApplication", "Applications\AzureFunctionApplication\AzureFunctionApplication.csproj", "{79BB2AEE-39BF-4855-8270-71BFD35BC397}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureFunctionInProcApplication", "Applications\AzureFunctionInProcApplication\AzureFunctionInProcApplication.csproj", "{05078BA6-DADF-4DDE-3339-62C1D9924642}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -438,6 +441,10 @@ Global {79BB2AEE-39BF-4855-8270-71BFD35BC397}.Debug|Any CPU.Build.0 = Debug|Any CPU {79BB2AEE-39BF-4855-8270-71BFD35BC397}.Release|Any CPU.ActiveCfg = Release|Any CPU {79BB2AEE-39BF-4855-8270-71BFD35BC397}.Release|Any CPU.Build.0 = Release|Any CPU + {05078BA6-DADF-4DDE-3339-62C1D9924642}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05078BA6-DADF-4DDE-3339-62C1D9924642}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05078BA6-DADF-4DDE-3339-62C1D9924642}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05078BA6-DADF-4DDE-3339-62C1D9924642}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -496,6 +503,7 @@ Global {78C0CA57-4E16-4E3E-B0CF-E8C9259BA1F2} = {30CF078E-E531-441E-83AB-24AB9B1C179F} {D7E78459-8139-4CB4-B830-E8914454CD60} = {F0F6F2CE-8AE8-49E1-8EE9-A44B451EFC29} {79BB2AEE-39BF-4855-8270-71BFD35BC397} = {F0F6F2CE-8AE8-49E1-8EE9-A44B451EFC29} + {05078BA6-DADF-4DDE-3339-62C1D9924642} = {F0F6F2CE-8AE8-49E1-8EE9-A44B451EFC29} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3830ABDF-4AEA-4D91-83A2-13F091D1DF5F} diff --git a/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionHttpTriggerTests.cs b/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionHttpTriggerTests.cs index 26f20b37f3..aca39d7b6f 100644 --- a/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionHttpTriggerTests.cs +++ b/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionHttpTriggerTests.cs @@ -14,7 +14,8 @@ namespace NewRelic.Agent.IntegrationTests.AzureFunction; public enum AzureFunctionHttpTriggerTestMode { AspNetCorePipeline, - SimpleInvocation + SimpleInvocation, + InProcess } public abstract class AzureFunctionHttpTriggerTestsBase : NewRelicIntegrationTest @@ -64,17 +65,25 @@ protected AzureFunctionHttpTriggerTestsBase(TFixture fixture, ITestOutputHelper }, exerciseApplication: () => { - if (IsPipelineTest) + switch (_testMode) { - _fixture.Get("api/httpTriggerFunctionUsingAspNetCorePipeline?someParameter=foo"); - _fixture.Get("api/httpTriggerFunctionUsingAspNetCorePipeline?someParameter=bar"); // make a second call to verify coldStart is not sent - _fixture.Get("api/httpTriggerFunctionUsingSimpleInvocation"); // invoke an http trigger function that does not use the aspnet core pipeline, even in pipeline test mode - } - else - { - _fixture.Get("api/httpTriggerFunctionUsingSimpleInvocation"); - _fixture.Get("api/httpTriggerFunctionUsingSimpleInvocation"); // make a second call to verify coldStart is not sent + case AzureFunctionHttpTriggerTestMode.AspNetCorePipeline: + _fixture.Get("api/httpTriggerFunctionUsingAspNetCorePipeline?someParameter=foo"); + _fixture.Get("api/httpTriggerFunctionUsingAspNetCorePipeline?someParameter=bar"); // make a second call to verify coldStart is not sent + _fixture.Get("api/httpTriggerFunctionUsingSimpleInvocation"); // invoke an http trigger function that does not use the aspnet core pipeline, even in pipeline test mode + break; + case AzureFunctionHttpTriggerTestMode.SimpleInvocation: + _fixture.Get("api/httpTriggerFunctionUsingSimpleInvocation"); + _fixture.Get("api/httpTriggerFunctionUsingSimpleInvocation"); // make a second call to verify coldStart is not sent + break; + case AzureFunctionHttpTriggerTestMode.InProcess: + _fixture.Get("api/httpTriggerFunction"); + _fixture.Get("api/httpTriggerFunction"); // make a second call to verify coldStart is not sent + break; + default: + throw new ArgumentOutOfRangeException(); } + _fixture.AgentLog.WaitForLogLines(AgentLogBase.TransactionSampleLogLineRegex, TimeSpan.FromMinutes(2)); _fixture.AgentLog.WaitForLogLines(AgentLogBase.MetricDataLogLineRegex, TimeSpan.FromMinutes(2)); } @@ -86,7 +95,7 @@ protected AzureFunctionHttpTriggerTestsBase(TFixture fixture, ITestOutputHelper [SkippableFact()] public void Test_SimpleInvocationMode() { - Skip.IfNot(IsSimpleInvocationTest, "This test is for the Simple Invocation mode only."); + Skip.IfNot(_testMode == AzureFunctionHttpTriggerTestMode.SimpleInvocation, "This test is for the Simple Invocation mode only."); var firstTransactionExpectedTransactionEventIntrinsicAttributes = new List { @@ -97,25 +106,12 @@ public void Test_SimpleInvocationMode() "cloud.resource_id" }; - var secondTransactionUnexpectedTransactionEventIntrinsicAttributes = new List - { - "faas.coldStart" - }; + var secondTransactionUnexpectedTransactionEventIntrinsicAttributes = new List { "faas.coldStart" }; - var expectedAgentAttributes = new Dictionary - { - { "request.uri", "/api/httpTriggerFunctionUsingSimpleInvocation"}, - { "request.method", "GET" }, - { "http.statusCode", 200 } - }; + var expectedAgentAttributes = new Dictionary { { "request.uri", "/api/httpTriggerFunctionUsingSimpleInvocation" }, { "request.method", "GET" }, { "http.statusCode", 200 } }; var simpleTransactionName = "WebTransaction/AzureFunction/HttpTriggerFunctionUsingSimpleInvocation"; - var simpleExpectedMetrics = new List() - { - new() {metricName = "DotNet/HttpTriggerFunctionUsingSimpleInvocation", CallCountAllHarvests = 2}, - new() {metricName = "DotNet/HttpTriggerFunctionUsingSimpleInvocation", metricScope = simpleTransactionName, CallCountAllHarvests = 2}, - new() {metricName = simpleTransactionName, CallCountAllHarvests = 2}, - }; + var simpleExpectedMetrics = new List() { new() { metricName = "DotNet/HttpTriggerFunctionUsingSimpleInvocation", CallCountAllHarvests = 2 }, new() { metricName = "DotNet/HttpTriggerFunctionUsingSimpleInvocation", metricScope = simpleTransactionName, CallCountAllHarvests = 2 }, new() { metricName = simpleTransactionName, CallCountAllHarvests = 2 }, }; var transactionSample = _fixture.AgentLog.TryGetTransactionSample(simpleTransactionName); @@ -131,6 +127,14 @@ public void Test_SimpleInvocationMode() if (_fixture.AzureFunctionModeEnabled) { + var supportabilityMetrics = new List() + { + new() { metricName = "Supportability/Dotnet/AzureFunctionMode/enabled" }, + new() { metricName = "Supportability/DotNet/AzureFunction/Worker/Isolated"}, + new() { metricName = "Supportability/DotNet/AzureFunction/Trigger/Http"} + }; + Assertions.MetricsExist(supportabilityMetrics, metrics); + Assertions.MetricsExist(simpleExpectedMetrics, metrics); Assert.NotNull(transactionSample); @@ -183,6 +187,12 @@ public void Test_SimpleInvocationMode() } else { + var supportabilityMetrics = new List() + { + new() { metricName = "Supportability/Dotnet/AzureFunctionMode/disabled" } + }; + Assertions.MetricsExist(supportabilityMetrics, metrics); + Assertions.MetricsDoNotExist(simpleExpectedMetrics, metrics); Assert.Null(transactionSample); @@ -200,7 +210,7 @@ public void Test_SimpleInvocationMode() [SkippableFact] public void Test_PipelineMode() { - Skip.IfNot(IsPipelineTest, "This test is for the Pipeline mode only."); + Skip.IfNot(_testMode == AzureFunctionHttpTriggerTestMode.AspNetCorePipeline, "This test is for the Pipeline mode only."); var firstTransactionExpectedTransactionEventIntrinsicAttributes = new List { @@ -211,41 +221,17 @@ public void Test_PipelineMode() "cloud.resource_id" }; - var secondTransactionUnexpectedTransactionEventIntrinsicAttributes = new List - { - "faas.coldStart" - }; + var secondTransactionUnexpectedTransactionEventIntrinsicAttributes = new List { "faas.coldStart" }; - var simpleTransactionExpectedTransactionEventIntrinsicAttributes = new List - { - "faas.invocation_id", - "faas.name", - "faas.trigger", - "cloud.resource_id" - }; + var simpleTransactionExpectedTransactionEventIntrinsicAttributes = new List { "faas.invocation_id", "faas.name", "faas.trigger", "cloud.resource_id" }; - var expectedAgentAttributes = new Dictionary - { - { "request.uri", "/api/httpTriggerFunctionUsingAspNetCorePipeline"}, - { "request.method", "GET" }, - { "http.statusCode", 200 } - }; + var expectedAgentAttributes = new Dictionary { { "request.uri", "/api/httpTriggerFunctionUsingAspNetCorePipeline" }, { "request.method", "GET" }, { "http.statusCode", 200 } }; var pipelineTransactionName = "WebTransaction/AzureFunction/HttpTriggerFunctionUsingAspNetCorePipeline"; - var pipelineExpectedMetrics = new List() - { - new() {metricName = "DotNet/HttpTriggerFunctionUsingAspNetCorePipeline", CallCountAllHarvests = 2}, - new() {metricName = "DotNet/HttpTriggerFunctionUsingAspNetCorePipeline", metricScope = pipelineTransactionName, CallCountAllHarvests = 2}, - new() {metricName = pipelineTransactionName, CallCountAllHarvests = 2}, - }; + var pipelineExpectedMetrics = new List() { new() { metricName = "DotNet/HttpTriggerFunctionUsingAspNetCorePipeline", CallCountAllHarvests = 2 }, new() { metricName = "DotNet/HttpTriggerFunctionUsingAspNetCorePipeline", metricScope = pipelineTransactionName, CallCountAllHarvests = 2 }, new() { metricName = pipelineTransactionName, CallCountAllHarvests = 2 }, }; var simpleTransactionName = "WebTransaction/AzureFunction/HttpTriggerFunctionUsingSimpleInvocation"; - var simpleExpectedMetrics = new List() - { - new() {metricName = "DotNet/HttpTriggerFunctionUsingSimpleInvocation", callCount = 1}, - new() {metricName = "DotNet/HttpTriggerFunctionUsingSimpleInvocation", metricScope = simpleTransactionName, callCount = 1}, - new() {metricName = simpleTransactionName, callCount = 1}, - }; + var simpleExpectedMetrics = new List() { new() { metricName = "DotNet/HttpTriggerFunctionUsingSimpleInvocation", callCount = 1 }, new() { metricName = "DotNet/HttpTriggerFunctionUsingSimpleInvocation", metricScope = simpleTransactionName, callCount = 1 }, new() { metricName = simpleTransactionName, callCount = 1 }, }; var transactionSample = _fixture.AgentLog.TryGetTransactionSample(pipelineTransactionName); @@ -263,6 +249,14 @@ public void Test_PipelineMode() if (_fixture.AzureFunctionModeEnabled) { + var supportabilityMetrics = new List() + { + new() { metricName = "Supportability/Dotnet/AzureFunctionMode/enabled" }, + new() { metricName = "Supportability/DotNet/AzureFunction/Worker/Isolated"}, + new() { metricName = "Supportability/DotNet/AzureFunction/Trigger/Http"} + }; + Assertions.MetricsExist(supportabilityMetrics, metrics); + Assertions.MetricsExist(pipelineExpectedMetrics, metrics); Assertions.MetricsExist(simpleExpectedMetrics, metrics); @@ -318,6 +312,12 @@ public void Test_PipelineMode() } else { + var supportabilityMetrics = new List() + { + new() { metricName = "Supportability/Dotnet/AzureFunctionMode/disabled" } + }; + Assertions.MetricsExist(supportabilityMetrics, metrics); + Assertions.MetricsDoNotExist(pipelineExpectedMetrics, metrics); Assertions.MetricsDoNotExist(simpleExpectedMetrics, metrics); Assert.Null(transactionSample); @@ -332,12 +332,133 @@ public void Test_PipelineMode() Assert.NotNull(disabledLogLine); } } + [SkippableFact] + public void Test_InProcess() + { + Skip.IfNot(_testMode == AzureFunctionHttpTriggerTestMode.InProcess, "This test is for In-Process mode only."); - private bool IsSimpleInvocationTest => _testMode == AzureFunctionHttpTriggerTestMode.SimpleInvocation; - private bool IsPipelineTest => _testMode == AzureFunctionHttpTriggerTestMode.AspNetCorePipeline; -} + var firstTransactionExpectedTransactionEventIntrinsicAttributes = new List + { + "faas.coldStart", + "faas.invocation_id", + "faas.name", + "faas.trigger", + "cloud.resource_id" + }; + + var secondTransactionUnexpectedTransactionEventIntrinsicAttributes = new List { "faas.coldStart" }; + + var expectedAgentAttributes = new Dictionary + { + { "request.uri", "/api/httpTriggerFunction" }, + { "request.method", "GET" }, + { "http.statusCode", 200 } + }; + var simpleTransactionName = "WebTransaction/AzureFunction/HttpTriggerFunction"; + var simpleExpectedMetrics = new List { + new() { metricName = "DotNet/HttpTriggerFunction", CallCountAllHarvests = 2 }, + new() { metricName = "DotNet/HttpTriggerFunction", metricScope = simpleTransactionName, CallCountAllHarvests = 2 }, + new() { metricName = "DotNet/Azure In-Proc Pipeline", CallCountAllHarvests = 2 }, + new() { metricName = "DotNet/Azure In-Proc Pipeline", metricScope = simpleTransactionName, CallCountAllHarvests = 2 }, + new() { metricName = simpleTransactionName, CallCountAllHarvests = 2 }, + }; + + var transactionSample = _fixture.AgentLog.TryGetTransactionSample(simpleTransactionName); + + var metrics = _fixture.AgentLog.GetMetrics().ToList(); + + var simpleTransactionEvents = _fixture.AgentLog.GetTransactionEvents() + .Where(@event => @event?.IntrinsicAttributes?["name"]?.ToString() == simpleTransactionName) + .OrderBy(x => x.IntrinsicAttributes?["timestamp"]) + .ToList(); + var firstTransaction = simpleTransactionEvents.FirstOrDefault(); + var secondTransaction = simpleTransactionEvents.Skip(1).FirstOrDefault(); + + if (_fixture.AzureFunctionModeEnabled) + { + var supportabilityMetrics = new List() + { + new() { metricName = "Supportability/Dotnet/AzureFunctionMode/enabled" }, + new() { metricName = "Supportability/DotNet/AzureFunction/Worker/InProcess" }, + new() { metricName = "Supportability/DotNet/AzureFunction/Trigger/Http" } + }; + Assertions.MetricsExist(supportabilityMetrics, metrics); + + Assertions.MetricsExist(simpleExpectedMetrics, metrics); + + Assert.NotNull(transactionSample); + Assert.NotNull(firstTransaction); + Assert.NotNull(secondTransaction); + + Assertions.TransactionTraceHasAttributes(firstTransactionExpectedTransactionEventIntrinsicAttributes, Tests.TestSerializationHelpers.Models.TransactionTraceAttributeType.Intrinsic, transactionSample); + Assertions.TransactionTraceHasAttributes(expectedAgentAttributes, Tests.TestSerializationHelpers.Models.TransactionTraceAttributeType.Agent, transactionSample); + + Assertions.TransactionEventHasAttributes(firstTransactionExpectedTransactionEventIntrinsicAttributes, Tests.TestSerializationHelpers.Models.TransactionEventAttributeType.Intrinsic, firstTransaction); + Assertions.TransactionEventHasAttributes(expectedAgentAttributes, Tests.TestSerializationHelpers.Models.TransactionEventAttributeType.Agent, firstTransaction); + + Assertions.TransactionEventDoesNotHaveAttributes(secondTransactionUnexpectedTransactionEventIntrinsicAttributes, Tests.TestSerializationHelpers.Models.TransactionEventAttributeType.Intrinsic, secondTransaction); + + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("cloud.resource_id", out var cloudResourceIdValue)); + Assert.Equal("/subscriptions/subscription_id/resourceGroups/my_resource_group/providers/Microsoft.Web/sites/IntegrationTestAppName/functions/HttpTriggerFunction", cloudResourceIdValue); + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("faas.name", out var faasNameValue)); + Assert.Equal("IntegrationTestAppName/HttpTriggerFunction", faasNameValue); + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("faas.trigger", out var faasTriggerValue)); + Assert.Equal("http", faasTriggerValue); + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("parent.type", out var parentType)); + Assert.Equal(ParentType, parentType); + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("parent.app", out var appId)); + Assert.Equal(AppId, appId); + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("parent.account", out var accountId)); + Assert.Equal(AccountId, accountId); + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("parent.transportType", out var transportType)); + Assert.Equal("HTTP", transportType); + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("traceId", out var traceId)); + Assert.Equal(TestTraceId, traceId); + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("priority", out var priority)); + Assert.Equal(Priority, priority.ToString().Substring(0, 7)); // keep the values the same length + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("sampled", out var sampled)); + Assert.Equal(Sampled, sampled); + + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("parentId", out var traceParent)); + Assert.Equal(TransactionId, traceParent); + + // changes - just make sure it is present. + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("parent.transportDuration", out var transportDuration)); + Assert.True(firstTransaction.IntrinsicAttributes.TryGetValue("guid", out var guid)); + } + else + { + var supportabilityMetrics = new List() + { + new() { metricName = "Supportability/Dotnet/AzureFunctionMode/disabled" } + }; + Assertions.MetricsExist(supportabilityMetrics, metrics); + + Assertions.MetricsDoNotExist(simpleExpectedMetrics, metrics); + Assert.Null(transactionSample); + + Assert.Empty(simpleTransactionEvents); // there should be no transactions when azure function mode is disabled + } + + if (!_fixture.AzureFunctionModeEnabled) // look for a specific log line that indicates azure function mode is disabled + { + var disabledLogLines = _fixture.AgentLog.TryGetLogLines(AgentLogBase.AzureFunctionModeDisabledLogLineRegex); + Assert.Single(disabledLogLines); + } + } +} + +#region Isolated model tests // the net8 target builds the function app without the aspnetcore pipeline package included [NetCoreTest] public class AzureFunctionHttpTriggerTestsCoreOldest : AzureFunctionHttpTriggerTestsBase @@ -366,3 +487,16 @@ public AzureFunctionHttpTriggerTestsFWLatest(AzureFunctionApplicationFixtureHttp { } } +#endregion + +#region InProc model tests +[NetCoreTest] +public class AzureFunctionHttpTriggerTestsInProcCoreOldest : AzureFunctionHttpTriggerTestsBase +{ + public AzureFunctionHttpTriggerTestsInProcCoreOldest(AzureFunctionApplicationFixtureHttpTriggerInProcCoreOldest fixture, ITestOutputHelper output) + : base(fixture, output, AzureFunctionHttpTriggerTestMode.InProcess) + { + } +} + +#endregion diff --git a/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionServiceBusTriggerTests.cs b/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionServiceBusTriggerTests.cs new file mode 100644 index 0000000000..815db8ef51 --- /dev/null +++ b/tests/Agent/IntegrationTests/IntegrationTests/AzureFunction/AzureFunctionServiceBusTriggerTests.cs @@ -0,0 +1,160 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using NewRelic.Agent.IntegrationTestHelpers; +using NewRelic.Agent.IntegrationTests.RemoteServiceFixtures; +using NewRelic.Agent.IntegrationTests.Shared; +using Xunit; +using Xunit.Abstractions; + +namespace NewRelic.Agent.IntegrationTests.AzureFunction; + +public abstract class AzureFunctionServiceBusTriggerTestsBase : NewRelicIntegrationTest + where TFixture : AzureFunctionApplicationFixture +{ + const string TestTraceId = "12345678901234567890123456789012"; + const bool Sampled = true; + const string Priority = "1.23456"; + + private readonly TFixture _fixture; + + protected AzureFunctionServiceBusTriggerTestsBase(TFixture fixture, ITestOutputHelper output) : base(fixture) + { + _fixture = fixture; + _fixture.TestLogger = output; + + _fixture.SetAdditionalEnvironmentVariable("ServiceBus", AzureServiceBusConfiguration.ConnectionString); + + _fixture.AddActions( + setupConfiguration: () => + { + var configModifier = new NewRelicConfigModifier(fixture.DestinationNewRelicConfigFilePath); + configModifier.SetOrDeleteSpanEventsEnabled(true); + configModifier + .ForceTransactionTraces() + .ConfigureFasterTransactionTracesHarvestCycle(20) + .ConfigureFasterMetricsHarvestCycle(25) + .ConfigureFasterSpanEventsHarvestCycle(15) + .SetLogLevel("finest"); + + // This is a bit of a kludge. When azure function instrumentation is disabled, + // the agent instruments *two* processes: the azure function host (func.exe) and the actual function app. + // Both processes use the same config files, so explicitly setting the log file name forces both + // processes to log to the same file, which makes it easier to verify that the + // actual function app is not being instrumented when the Invoke() method gets hit. + // + // Ideally, we'd prefer to look for the specific log file for the azure function app, but that's less trivial + // and not worth the effort for this one test. + if (!_fixture.AzureFunctionModeEnabled) + { + configModifier.SetLogFileName("azure_function_instrumentation_disabled.log"); + } + }, + exerciseApplication: () => + { + // Invokes a function, sending distributed tracing headers in the HTTP trigger, that then creates a new Service Bus message + // which is picked up by the Service Bus trigger function. This lets us verify that DT information makes it all the way through + // from HTTP to Service Bus message creation to Service Bus message receive. + _fixture.Post("api/HttpTrigger_SendServiceBusMessage", "test message"); + + _fixture.AgentLog.WaitForLogLines(AgentLogBase.TransactionSampleLogLineRegex, TimeSpan.FromMinutes(2)); + _fixture.AgentLog.WaitForLogLines(AgentLogBase.MetricDataLogLineRegex, TimeSpan.FromMinutes(2)); + } + ); + + _fixture.Initialize(); + } + + [Fact] + public void ServiceBusTriggerFunctionTest() + { + // other tests are verifying the expected Azure function attributes; we just need to make sure that we have a transaction + // for sending the service bus message and a transaction for receiving the service bus message + var transactionEvents = _fixture.AgentLog.GetTransactionEvents().ToList(); + + var sendMessageTransactionName = "WebTransaction/AzureFunction/HttpTrigger_SendServiceBusMessage"; + var receiveMessageTransactionName = "OtherTransaction/AzureFunction/ServiceBusTriggerFunction"; + + var sendServiceBusMessageTransaction = transactionEvents.SingleOrDefault(e => e.IntrinsicAttributes["name"].ToString() == sendMessageTransactionName); + var receiveServiceBusMessageTransaction = transactionEvents.SingleOrDefault(e => e.IntrinsicAttributes["name"].ToString() == receiveMessageTransactionName); + + // verify the expected metrics + var metrics = _fixture.AgentLog.GetMetrics().ToList(); + var expectedMetrics = new List { + new() { metricName = "DotNet/HttpTrigger_SendServiceBusMessage"}, + new() { metricName = "DotNet/HttpTrigger_SendServiceBusMessage", metricScope = sendMessageTransactionName}, + new() { metricName = "DotNet/ServiceBusTriggerFunction"}, + new() { metricName = "DotNet/ServiceBusTriggerFunction", metricScope = receiveMessageTransactionName}, + new() { metricName = sendMessageTransactionName}, + new() { metricName = receiveMessageTransactionName}, + }; + + Assert.NotEmpty(transactionEvents); + Assert.NotEmpty(metrics); + + Assert.NotNull(sendServiceBusMessageTransaction); + Assert.NotNull(receiveServiceBusMessageTransaction); + + Assert.True(receiveServiceBusMessageTransaction.IntrinsicAttributes.TryGetValue("faas.trigger", out var faasTriggerValue)); + Assert.Equal("pubsub", faasTriggerValue); + + Assertions.MetricsExist(expectedMetrics, metrics); + } + + [Fact] + public void DistributedTraceHeadersArePropagated() + { + // get the transaction events and verify that all of them have the expected DT attributes + var transactionEvents = _fixture.AgentLog.GetTransactionEvents(); + var spanEvents = _fixture.AgentLog.GetSpanEvents(); + + Assert.NotEmpty(transactionEvents); + Assert.NotEmpty(spanEvents); + + var expectedMetrics = new List + { + new() { metricName = "Supportability/DistributedTrace/CreatePayload/Success"}, + new() { metricName = "Supportability/TraceContext/Create/Success"}, + new() { metricName = "Supportability/TraceContext/Accept/Success"}, + }; + + Assertions.MetricsExist(expectedMetrics, _fixture.AgentLog.GetMetrics()); + + Assert.All(transactionEvents, transactionEvent => + { + Assert.True(transactionEvent.IntrinsicAttributes.TryGetValue("traceId", out var actualTraceId)); + Assert.Equal(TestTraceId, actualTraceId); + + Assert.True(transactionEvent.IntrinsicAttributes.TryGetValue("priority", out var actualPriority)); + Assert.Equal(Priority, actualPriority.ToString().Substring(0, 7)); // keep the values the same length + + Assert.True(transactionEvent.IntrinsicAttributes.TryGetValue("sampled", out var actualSampled)); + Assert.Equal(Sampled, actualSampled); + }); + + // get the span events and verify that all of them have the expected DT attributes + Assert.All(spanEvents, spanEvent => + { + Assert.True(spanEvent.IntrinsicAttributes.TryGetValue("traceId", out var actualTraceId)); + Assert.Equal(TestTraceId, actualTraceId); + + Assert.True(spanEvent.IntrinsicAttributes.TryGetValue("priority", out var actualPriority)); + Assert.Equal(Priority, actualPriority.ToString().Substring(0, 7)); // keep the values the same length + + Assert.True(spanEvent.IntrinsicAttributes.TryGetValue("sampled", out var actualSampled)); + Assert.Equal(Sampled, actualSampled); + }); + } +} + +[NetCoreTest] +public class AzureFunctionServiceBusTriggerTestInProcCoreOldest : AzureFunctionServiceBusTriggerTestsBase +{ + public AzureFunctionServiceBusTriggerTestInProcCoreOldest(AzureFunctionApplicationFixtureServiceBusTriggerInProcCoreOldest fixture, ITestOutputHelper output) + : base(fixture, output) + { + } +} diff --git a/tests/Agent/IntegrationTests/IntegrationTests/RemoteServiceFixtures/AzureFunctionApplicationFixture.cs b/tests/Agent/IntegrationTests/IntegrationTests/RemoteServiceFixtures/AzureFunctionApplicationFixture.cs index 1cabdd216d..1adfddd064 100644 --- a/tests/Agent/IntegrationTests/IntegrationTests/RemoteServiceFixtures/AzureFunctionApplicationFixture.cs +++ b/tests/Agent/IntegrationTests/IntegrationTests/RemoteServiceFixtures/AzureFunctionApplicationFixture.cs @@ -8,7 +8,12 @@ namespace NewRelic.Agent.IntegrationTests.RemoteServiceFixtures; public abstract class AzureFunctionApplicationFixture : RemoteApplicationFixture { - private const string ApplicationDirectoryName = @"AzureFunctionApplication"; + private const string ApplicationDirectoryName = "AzureFunctionApplication"; + private const string ExecutableName = "AzureFunctionApplication.exe"; + + private const string InProcApplicationDirectoryName = "AzureFunctionInProcApplication"; + private const string InProcExecutableName = "AzureFunctionInProcApplication.dll"; + private const string TestTraceId = "12345678901234567890123456789012"; private const string TestTraceParent = "1234567890123456"; private const string TestTracingVendors = "rojo,congo"; @@ -23,14 +28,16 @@ public abstract class AzureFunctionApplicationFixture : RemoteApplicationFixture private const string Priority = "1.23456"; private const string Timestamp = "1518469636025"; - protected AzureFunctionApplicationFixture(string functionNames, string targetFramework, bool enableAzureFunctionMode, bool isCoreApp = true) - : base(new AzureFuncTool(ApplicationDirectoryName, targetFramework, ApplicationType.Bounded, true, isCoreApp, true, enableAzureFunctionMode)) + protected AzureFunctionApplicationFixture(string functionNames, string targetFramework, bool enableAzureFunctionMode, bool isCoreApp = true, bool inProc = false) + : base(new AzureFuncTool(inProc ? InProcApplicationDirectoryName : ApplicationDirectoryName, inProc ? InProcExecutableName : ExecutableName, targetFramework, ApplicationType.Bounded, true, isCoreApp, true, enableAzureFunctionMode, inProc)) { - CommandLineArguments = $"start --no-build --language-worker dotnet-isolated --dotnet-isolated --functions {functionNames} "; + CommandLineArguments = $"start --no-build --functions {functionNames} --language-worker "; + + CommandLineArguments += inProc ? "dotnet --dotnet " : "dotnet-isolated --dotnet-isolated "; #if DEBUG // set a long timeout if you're going to debug into the function - CommandLineArguments += "--timeout 600 "; + CommandLineArguments += "--timeout 600 --verbose "; #endif AzureFunctionModeEnabled = enableAzureFunctionMode; @@ -45,10 +52,23 @@ public void Get(string endpoint) new KeyValuePair ("traceparent", $"00-{TestTraceId}-{TestTraceParent}-00"), new KeyValuePair ("tracestate", $"{AccountId}@nr={Version}-{ParentType}-{AccountId}-{AppId}-{SpanId}-{TransactionId}-{Sampled}-" + Priority + $"-{Timestamp},{TestOtherVendorEntries}") }; - + GetStringAndIgnoreResult(address, headers); } + public void Post(string endpoint, string payload) + { + var address = $"http://{DestinationServerName}:{Port}/{endpoint}"; + var inputPayload = $$"""{"input":"{{payload}}"}"""; + var headers = new List> + { + new KeyValuePair ("traceparent", $"00-{TestTraceId}-{TestTraceParent}-00"), + new KeyValuePair ("tracestate", $"{AccountId}@nr={Version}-{ParentType}-{AccountId}-{AppId}-{SpanId}-{TransactionId}-{Sampled}-" + Priority + $"-{Timestamp},{TestOtherVendorEntries}") + }; + + PostJson(address, inputPayload, headers); + } + public void PostToAzureFuncTool(string triggerName, string payload) { var address = $"http://{DestinationServerName}:{Port}/admin/functions/{triggerName}"; @@ -60,12 +80,15 @@ public void PostToAzureFuncTool(string triggerName, string payload) public bool AzureFunctionModeEnabled { get; } } +#region Isolated model fixtures + public class AzureFunctionApplicationFixtureHttpTriggerCoreOldest : AzureFunctionApplicationFixture { public AzureFunctionApplicationFixtureHttpTriggerCoreOldest() : base("httpTriggerFunctionUsingAspNetCorePipeline httpTriggerFunctionUsingSimpleInvocation", "net8.0", true) { } } + public class AzureFunctionApplicationFixtureHttpTriggerCoreLatest : AzureFunctionApplicationFixture { public AzureFunctionApplicationFixtureHttpTriggerCoreLatest() : base("httpTriggerFunctionUsingAspNetCorePipeline httpTriggerFunctionUsingSimpleInvocation", "net9.0", true) @@ -100,3 +123,20 @@ public AzureFunctionApplicationFixtureQueueTriggerCoreLatest() : base("queueTrig { } } +#endregion + +#region InProc model fixtures +public class AzureFunctionApplicationFixtureHttpTriggerInProcCoreOldest : AzureFunctionApplicationFixture +{ + public AzureFunctionApplicationFixtureHttpTriggerInProcCoreOldest() : base("HttpTriggerFunction", "net8.0", true, inProc: true) + { + } +} + +public class AzureFunctionApplicationFixtureServiceBusTriggerInProcCoreOldest : AzureFunctionApplicationFixture +{ + public AzureFunctionApplicationFixtureServiceBusTriggerInProcCoreOldest() : base("ServiceBusTriggerFunction HttpTrigger_SendServiceBusMessage", "net8.0", true, inProc: true) + { + } +} +#endregion diff --git a/tests/Agent/IntegrationTests/Shared/AzureServiceBusConfiguration.cs b/tests/Agent/IntegrationTests/Shared/AzureServiceBusConfiguration.cs new file mode 100644 index 0000000000..acc5dfaa9c --- /dev/null +++ b/tests/Agent/IntegrationTests/Shared/AzureServiceBusConfiguration.cs @@ -0,0 +1,16 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace NewRelic.Agent.IntegrationTests.Shared; + +public static class AzureServiceBusConfiguration +{ + public static string ConnectionString + { + get + { + var testConfiguration = IntegrationTestConfiguration.GetIntegrationTestConfiguration("AzureServiceBusTests"); + return testConfiguration["ConnectionString"]; + } + } +} diff --git a/tests/Agent/UnitTests/Core.UnitTest/AgentHealth/AgentHealthReporterTests.cs b/tests/Agent/UnitTests/Core.UnitTest/AgentHealth/AgentHealthReporterTests.cs index 34c6c691a8..22a81256ae 100644 --- a/tests/Agent/UnitTests/Core.UnitTest/AgentHealth/AgentHealthReporterTests.cs +++ b/tests/Agent/UnitTests/Core.UnitTest/AgentHealth/AgentHealthReporterTests.cs @@ -544,5 +544,35 @@ public void AwsAccountIdSupportabilityMetricPresent() _agentHealthReporter.CollectMetrics(); Assert.That(_publishedMetrics.Any(x => x.MetricNameModel.Name == "Supportability/Dotnet/AwsAccountId/Config"), Is.True); } + + [Test] + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void AzureFunctionModeSupportabilityMetricPresent_OnlyWhenAzureFunctionModeDetected(bool azureFunctionModeDetected, bool enableAzureFunctionMode) + { + // ARRANGE + var configuration = GetDefaultConfiguration(); + Mock.Arrange(() => configuration.AzureFunctionModeDetected).Returns(azureFunctionModeDetected); + Mock.Arrange(() => configuration.AzureFunctionModeEnabled).Returns(enableAzureFunctionMode); + + _configurationAutoResponder.Dispose(); + _agentHealthReporter.Dispose(); + _configurationAutoResponder = new ConfigurationAutoResponder(configuration); + var metricBuilder = WireModels.Utilities.GetSimpleMetricBuilder(); + _agentHealthReporter = new AgentHealthReporter(metricBuilder, Mock.Create(), Mock.Create(), Mock.Create()); + _publishedMetrics = new List(); + _agentHealthReporter.RegisterPublishMetricHandler(metric => _publishedMetrics.Add(metric)); + + // ACT + _agentHealthReporter.CollectMetrics(); + + // ASSERT + if (enableAzureFunctionMode) + Assert.That(_publishedMetrics.Any(x => x.MetricNameModel.Name == "Supportability/Dotnet/AzureFunctionMode/enabled"), azureFunctionModeDetected ? Is.True : Is.False); + else + Assert.That(_publishedMetrics.Any(x => x.MetricNameModel.Name == "Supportability/Dotnet/AzureFunctionMode/disabled"), azureFunctionModeDetected ? Is.True : Is.False); + } } } diff --git a/tests/Agent/UnitTests/Core.UnitTest/Transactions/TransactionTests.cs b/tests/Agent/UnitTests/Core.UnitTest/Transactions/TransactionTests.cs index 929a38be34..5967cb12b0 100644 --- a/tests/Agent/UnitTests/Core.UnitTest/Transactions/TransactionTests.cs +++ b/tests/Agent/UnitTests/Core.UnitTest/Transactions/TransactionTests.cs @@ -555,6 +555,21 @@ public void AddFaasAttribute_SetAttributeInTransactionMetadata() Assert.That(attributeValue, Is.EqualTo(value)); } + [Test] + public void GetFaasAttribute_ReturnsExpectedValue() + { + // Arrange + var key = "TestAttribute"; + var value = "TestValue"; + + // Act + _transaction.AddFaasAttribute(key, value); + var result = _transaction.GetFaasAttribute(key); + + // Assert + Assert.That(result, Is.EqualTo(value)); + } + [TestCase(" ")] [TestCase("")] [TestCase(null)]