diff --git a/.github/workflows/all_solutions.yml b/.github/workflows/all_solutions.yml index a6cd12ab06..cd2ed221c5 100644 --- a/.github/workflows/all_solutions.yml +++ b/.github/workflows/all_solutions.yml @@ -254,6 +254,7 @@ jobs: AwsLambda.Sns, AwsLambda.Sqs, AwsLambda.WebRequest, + AwsSdk, AzureFunction, BasicInstrumentation, CatInbound, diff --git a/src/Agent/NewRelic/Agent/Core/AgentHealth/AgentHealthReporter.cs b/src/Agent/NewRelic/Agent/Core/AgentHealth/AgentHealthReporter.cs index 7e0ae88485..541b5fe870 100644 --- a/src/Agent/NewRelic/Agent/Core/AgentHealth/AgentHealthReporter.cs +++ b/src/Agent/NewRelic/Agent/Core/AgentHealth/AgentHealthReporter.cs @@ -685,6 +685,7 @@ private void CollectOneTimeMetrics() ReportIfLoggingDisabled(); ReportIfInstrumentationIsDisabled(); ReportIfGCSamplerV2IsEnabled(); + ReportIfAwsAccountIdProvided(); } public void CollectMetrics() @@ -847,8 +848,14 @@ private void ReportIfGCSamplerV2IsEnabled() { ReportSupportabilityCountMetric(MetricNames.SupportabilityGCSamplerV2Enabled); } - } + private void ReportIfAwsAccountIdProvided() + { + if (!string.IsNullOrEmpty(_configuration.AwsAccountId)) + { + ReportSupportabilityCountMetric(MetricNames.SupportabilityAwsAccountIdProvided); + } + } } } diff --git a/src/Agent/NewRelic/Agent/Core/Attributes/AttributeDefinitionService.cs b/src/Agent/NewRelic/Agent/Core/Attributes/AttributeDefinitionService.cs index c51ea26b61..ab45080fee 100644 --- a/src/Agent/NewRelic/Agent/Core/Attributes/AttributeDefinitionService.cs +++ b/src/Agent/NewRelic/Agent/Core/Attributes/AttributeDefinitionService.cs @@ -126,6 +126,7 @@ public interface IAttributeDefinitions AttributeDefinition GetLambdaAttribute(string name); AttributeDefinition GetFaasAttribute(string name); + AttributeDefinition GetCloudSdkAttribute(string name); AttributeDefinition GetRequestParameterAttribute(string paramName); @@ -190,6 +191,7 @@ public AttributeDefinitions(IAttributeFilter attribFilter) private readonly ConcurrentDictionary> _requestHeadersAttributes = new ConcurrentDictionary>(); private readonly ConcurrentDictionary> _lambdaAttributes = new ConcurrentDictionary>(); private readonly ConcurrentDictionary> _faasAttributes = new(); + private readonly ConcurrentDictionary> _cloudSdkAttributes = new(); private readonly ConcurrentDictionary> _typeAttributes = new ConcurrentDictionary>(); @@ -281,6 +283,20 @@ public AttributeDefinition GetFaasAttribute(string name) } + private AttributeDefinition CreateCloudSdkAttribute(string attribName) + { + return AttributeDefinitionBuilder + .Create(attribName, AttributeClassification.AgentAttributes) + .AppliesTo(AttributeDestinations.TransactionTrace) + .AppliesTo(AttributeDestinations.SpanEvent) + .WithConvert(x => x) + .Build(_attribFilter); + } + + public AttributeDefinition GetCloudSdkAttribute(string name) + { + return _cloudSdkAttributes.GetOrAdd(name, CreateCloudSdkAttribute); + } public AttributeDefinition GetCustomAttributeForTransaction(string name) { return _trxCustomAttributes.GetOrAdd(name, CreateCustomAttributeForTransaction); diff --git a/src/Agent/NewRelic/Agent/Core/Metrics/MetricNames.cs b/src/Agent/NewRelic/Agent/Core/Metrics/MetricNames.cs index 2f399603a7..adb0c82471 100644 --- a/src/Agent/NewRelic/Agent/Core/Metrics/MetricNames.cs +++ b/src/Agent/NewRelic/Agent/Core/Metrics/MetricNames.cs @@ -838,6 +838,7 @@ 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"; #endregion Supportability diff --git a/src/Agent/NewRelic/Agent/Core/Segments/NoOpSegment.cs b/src/Agent/NewRelic/Agent/Core/Segments/NoOpSegment.cs index f38e5ddfb6..3b9ad1da87 100644 --- a/src/Agent/NewRelic/Agent/Core/Segments/NoOpSegment.cs +++ b/src/Agent/NewRelic/Agent/Core/Segments/NoOpSegment.cs @@ -61,6 +61,10 @@ public ISpan AddCustomAttribute(string key, object value) { return this; } + public ISpan AddCloudSdkAttribute(string key, object value) + { + return this; + } public ISpan SetName(string name) { diff --git a/src/Agent/NewRelic/Agent/Core/Segments/Segment.cs b/src/Agent/NewRelic/Agent/Core/Segments/Segment.cs index 7323b80277..968e7d654b 100644 --- a/src/Agent/NewRelic/Agent/Core/Segments/Segment.cs +++ b/src/Agent/NewRelic/Agent/Core/Segments/Segment.cs @@ -38,7 +38,7 @@ public class Segment : IInternalSpan, ISegmentDataState public IAttributeDefinitions AttribDefs => _transactionSegmentState.AttribDefs; public string TypeName => MethodCallData.TypeName; - private SpanAttributeValueCollection _customAttribValues; + private SpanAttributeValueCollection _attribValues; public Segment(ITransactionSegmentState transactionSegmentState, MethodCallData methodCallData) { @@ -318,7 +318,7 @@ public TimeSpan ExclusiveDurationOrZero public SpanAttributeValueCollection GetAttributeValues() { - var attribValues = _customAttribValues ?? new SpanAttributeValueCollection(); + var attribValues = _attribValues ?? new SpanAttributeValueCollection(); AttribDefs.Duration.TrySetValue(attribValues, DurationOrZero); AttribDefs.NameForSpan.TrySetValue(attribValues, GetTransactionTraceName()); @@ -434,14 +434,14 @@ public ISegmentExperimental MakeLeaf() return this; } - private readonly object _customAttribValuesSyncRoot = new object(); + private readonly object _attribValuesSyncRoot = new object(); public ISpan AddCustomAttribute(string key, object value) { SpanAttributeValueCollection customAttribValues; - lock (_customAttribValuesSyncRoot) + lock (_attribValuesSyncRoot) { - customAttribValues = _customAttribValues ?? (_customAttribValues = new SpanAttributeValueCollection()); + customAttribValues = _attribValues ?? (_attribValues = new SpanAttributeValueCollection()); } AttribDefs.GetCustomAttributeForSpan(key).TrySetValue(customAttribValues, value); @@ -449,6 +449,19 @@ public ISpan AddCustomAttribute(string key, object value) return this; } + public ISpan AddCloudSdkAttribute(string key, object value) + { + SpanAttributeValueCollection attribValues; + lock (_attribValuesSyncRoot) + { + attribValues = _attribValues ?? (_attribValues = new SpanAttributeValueCollection()); + } + + AttribDefs.GetCloudSdkAttribute(key).TrySetValue(attribValues, value); + + return this; + } + public ISpan SetName(string name) { SegmentNameOverride = name; diff --git a/src/Agent/NewRelic/Agent/Core/Transactions/NoOpTransaction.cs b/src/Agent/NewRelic/Agent/Core/Transactions/NoOpTransaction.cs index 404cbac7f4..91c3723e52 100644 --- a/src/Agent/NewRelic/Agent/Core/Transactions/NoOpTransaction.cs +++ b/src/Agent/NewRelic/Agent/Core/Transactions/NoOpTransaction.cs @@ -331,5 +331,10 @@ public void AddFaasAttribute(string name, object value) { return; } + + 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 9cbe293901..46f82f827c 100644 --- a/src/Agent/NewRelic/Agent/Core/Transactions/Transaction.cs +++ b/src/Agent/NewRelic/Agent/Core/Transactions/Transaction.cs @@ -1374,7 +1374,7 @@ public void AddLambdaAttribute(string name, object value) { if (string.IsNullOrWhiteSpace(name)) { - Log.Debug($"AddLambdaAttribute - Unable to set Lambda value on transaction because the key is null/empty"); + Log.Debug($"AddLambdaAttribute - Name cannot be null/empty"); return; } @@ -1386,7 +1386,7 @@ public void AddFaasAttribute(string name, object value) { if (string.IsNullOrWhiteSpace(name)) { - Log.Debug($"AddFaasAttribute - Unable to set FaaS value on transaction because the key is null/empty"); + Log.Debug($"AddFaasAttribute - Name cannot be null/empty"); return; } diff --git a/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Api/ISpan.cs b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Api/ISpan.cs index 86bc4d6451..6bca5d9ab9 100644 --- a/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Api/ISpan.cs +++ b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Api/ISpan.cs @@ -11,6 +11,8 @@ public interface ISpan { ISpan AddCustomAttribute(string key, object value); + ISpan AddCloudSdkAttribute(string key, object value); + ISpan SetName(string name); } } diff --git a/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/AwsSdk/ArnBuilder.cs b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/AwsSdk/ArnBuilder.cs new file mode 100644 index 0000000000..d40a53aaaa --- /dev/null +++ b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/AwsSdk/ArnBuilder.cs @@ -0,0 +1,153 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Linq; +using System.Text.RegularExpressions; + +namespace NewRelic.Agent.Extensions.AwsSdk +{ + public class ArnBuilder + { + public readonly string Partition; + public readonly string Region; + public readonly string AccountId; + + public ArnBuilder(string partition, string region, string accountId) + { + Partition = string.IsNullOrEmpty(partition) ? "aws" : partition; + Region = string.IsNullOrEmpty(region) ? "(unknown)" : region; + AccountId = accountId ?? ""; + } + + public string Build(string service, string resource) => ConstructArn(Partition, service, Region, AccountId, resource); + + // This is the full regex pattern for a Lambda ARN: + // (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\d{1}:)?(\d{12}:)?(function:)?([a-zA-Z0-9-_\.]+)(:(\$LATEST|[a-zA-Z0-9-_]+))? + + // If it's a full ARN, it has to start with 'arn:' + // A partial ARN can contain up to 5 segments separated by ':' + // 1. Region + // 2. Account ID + // 3. 'function' (fixed string) + // 4. Function name + // 5. Alias or version + // Only the function name is required, the rest are all optional. e.g. you could have region and function name and nothing else + public string BuildFromPartialLambdaArn(string invocationName) + { + if (invocationName.StartsWith("arn:")) + { + return invocationName; + } + var segments = invocationName.Split(':'); + string functionName = null; + string alias = null; + string fallback = null; + string region = null; + string accountId = null; + + // If there's only one segment, assume it's the function name + if (segments.Length == 1) + { + functionName = segments[0]; + } + else + { + // All we should need is the function name, but if we find a region or account ID, we'll use it + // since it should be more accurate + foreach (var segment in segments) + { + // A string that looks like a region or account ID could also be the function name + // Assume it's the former, unless we never find a function name + if (LooksLikeARegion(segment)) + { + if (string.IsNullOrEmpty(region)) + { + region = segment; + } + else + { + fallback = segment; + } + continue; + } + else if (LooksLikeAnAccountId(segment)) + { + if (string.IsNullOrEmpty(accountId)) + { + accountId = segment; + } + else + { + fallback = segment; + } + continue; + } + else if (segment == "function") + { + continue; + } + else if (functionName == null) + { + functionName = segment; + } + else if (alias == null) + { + alias = segment; + } + else + { + return null; + } + } + } + + if (string.IsNullOrEmpty(functionName)) + { + if (!string.IsNullOrEmpty(fallback)) + { + functionName = fallback; + } + else + { + return null; + } + } + + accountId = !string.IsNullOrEmpty(accountId) ? accountId : AccountId; + if (string.IsNullOrEmpty(accountId)) + { + return null; + } + + // The member Region cannot be blank (it has a default) so we don't need to check it here + region = !string.IsNullOrEmpty(region) ? region : Region; + + if (!string.IsNullOrEmpty(alias)) + { + functionName += $":{alias}"; + } + return ConstructArn(Partition, "lambda", region, accountId, $"function:{functionName}"); + } + + public override string ToString() + { + string idPresent = string.IsNullOrEmpty(AccountId) ? "[Missing]" : "[Present]"; + + return $"Partition: {Partition}, Region: {Region}, AccountId: {idPresent}"; + } + + private static Regex RegionRegex = new Regex(@"^[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\d{1}$", RegexOptions.Compiled); + private static bool LooksLikeARegion(string text) => RegionRegex.IsMatch(text); + private static bool LooksLikeAnAccountId(string text) => (text.Length == 12) && text.All(c => c >= '0' && c <= '9'); + + private string ConstructArn(string partition, string service, string region, string accountId, string resource) + { + if (string.IsNullOrEmpty(partition) || string.IsNullOrEmpty(region) || string.IsNullOrEmpty(accountId) + || string.IsNullOrEmpty(service) || string.IsNullOrEmpty(resource)) + { + return null; + } + return "arn:" + partition + ":" + service + ":" + region + ":" + accountId + ":" + resource; + } + } +} diff --git a/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/AwsSdk/AwsAccountIdDecoder.cs b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/AwsSdk/AwsAccountIdDecoder.cs new file mode 100644 index 0000000000..a6e3dea2fb --- /dev/null +++ b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/AwsSdk/AwsAccountIdDecoder.cs @@ -0,0 +1,70 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; + +namespace NewRelic.Agent.Extensions.AwsSdk +{ + public static class AwsAccountIdDecoder + { + // magic number + private const long Mask = 0x7FFFFFFFFF80; + + public static string GetAccountId(string awsAccessKeyId) + { + if (string.IsNullOrEmpty(awsAccessKeyId)) + { + throw new ArgumentNullException(nameof(awsAccessKeyId), "AWS Access Key ID cannot be null or empty."); + } + + if (awsAccessKeyId.Length < 14) + { + throw new ArgumentOutOfRangeException(nameof(awsAccessKeyId), "AWS Access Key ID must be at least 14 characters long."); + } + + string accessKeyWithoutPrefix = awsAccessKeyId.Substring(4).ToLowerInvariant(); + long encodedAccount = Base32Decode(accessKeyWithoutPrefix); + + return ((encodedAccount & Mask) >> 7).ToString(); + } + + /// + /// Performs a Base-32 decode of the specified input string. + /// Allowed character range is a-z and 2-7. 'a' being 0 and '7' is 31. + /// + /// public to allow for unit testing + /// + /// The string to be decoded. Must be at least 10 characters. + /// A long containing first 6 bytes of the base 32 decoded data. + /// If src has less than 10 characters. + /// If src contains invalid characters for Base-32 + public static long Base32Decode(string src) + { + if (string.IsNullOrEmpty(src)) + { + throw new ArgumentNullException(nameof(src), "The input string cannot be null or empty."); + } + + if (src.Length < 10) + { + throw new ArgumentException("The input string must be at least 10 characters long.", nameof(src)); + } + + long baseValue = 0; + for (int i = 0; i < 10; i++) + { + baseValue <<= 5; + char c = src[i]; + baseValue += c switch + { + >= 'a' and <= 'z' => c - 'a', + >= '2' and <= '7' => c - '2' + 26, + _ => throw new ArgumentOutOfRangeException(nameof(src), + "The input string must contain only characters in the range a-z and 2-7.") + }; + } + + return baseValue >> 2; + } + } +} diff --git a/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Collections/ConcurrentHashSet.cs b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Collections/ConcurrentHashSet.cs index 28e8133cf2..145d4391be 100644 --- a/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Collections/ConcurrentHashSet.cs +++ b/src/Agent/NewRelic/Agent/Extensions/NewRelic.Agent.Extensions/Collections/ConcurrentHashSet.cs @@ -47,7 +47,13 @@ public void Add(T item) _hashSet.Add(item); } } - + public bool TryAdd(T item) + { + using (_writeLock()) + { + return _hashSet.Add(item); + } + } public void Clear() { using (_writeLock()) diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsLambda/HandlerMethodWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsLambda/HandlerMethodWrapper.cs index 1fdc696c79..ac2a32c79b 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsLambda/HandlerMethodWrapper.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsLambda/HandlerMethodWrapper.cs @@ -193,10 +193,9 @@ private void InitLambdaData(InstrumentedMethodCall instrumentedMethodCall, IAgen { agent.Logger.Log(Agent.Extensions.Logging.Level.Debug, $"Supported Event Type found: {_functionDetails.EventType}"); } - else if (!_unsupportedInputTypes.Contains(name)) + else if (_unsupportedInputTypes.TryAdd(name)) { agent.Logger.Log(Agent.Extensions.Logging.Level.Warn, $"Unsupported input object type: {name}. Unable to provide additional instrumentation."); - _unsupportedInputTypes.Add(name); } } @@ -344,10 +343,9 @@ private void CaptureResponseData(ITransaction transaction, object response, IAge || (_functionDetails.EventType == AwsLambdaEventType.ApplicationLoadBalancerRequest && responseType != "Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse")) { - if (!_unexpectedResponseTypes.Contains(responseType)) + if (_unexpectedResponseTypes.TryAdd(responseType)) { agent.Logger.Log(Agent.Extensions.Logging.Level.Warn, $"Unexpected response type {responseType} for request event type {_functionDetails.EventType}. Not capturing any response data."); - _unexpectedResponseTypes.Add(responseType); } return; diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/AmazonServiceClientWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/AmazonServiceClientWrapper.cs new file mode 100644 index 0000000000..615a9ea8e5 --- /dev/null +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/AmazonServiceClientWrapper.cs @@ -0,0 +1,52 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using NewRelic.Agent.Api; +using NewRelic.Agent.Extensions.AwsSdk; +using NewRelic.Agent.Extensions.Providers.Wrapper; + +namespace NewRelic.Providers.Wrapper.AwsSdk +{ + public class AmazonServiceClientWrapper : IWrapper + { + /// + /// The AWS account id. + /// Parsed from the access key in the credentials of the client - or fall back to the configuration value if parsing fails. + /// Assumes only a single account id is used in the application. + /// + public static string AwsAccountId { get; private set; } + + public bool IsTransactionRequired => false; + + public CanWrapResponse CanWrap(InstrumentedMethodInfo instrumentedMethodInfo) + { + return new CanWrapResponse(instrumentedMethodInfo.RequestedWrapperName == nameof(AmazonServiceClientWrapper)); + } + + public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, ITransaction transaction) + { + if (AwsAccountId != null) + return Delegates.NoOp; + + try + { + // get the AWSCredentials parameter + dynamic awsCredentials = instrumentedMethodCall.MethodCall.MethodArguments[0]; + + dynamic immutableCredentials = awsCredentials.GetCredentials(); + string accessKey = immutableCredentials.AccessKey; + + // convert the access key to an account id + AwsAccountId = AwsAccountIdDecoder.GetAccountId(accessKey); + } + catch (Exception e) + { + agent.Logger.Info($"Unable to parse AWS Account ID from AccessKey. Using AccountId from configuration instead. Exception: {e.Message}"); + AwsAccountId = agent.Configuration.AwsAccountId; + } + + return Delegates.NoOp; + } + } +} diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/AwsSdkPipelineWrapper.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/AwsSdkPipelineWrapper.cs index 33c19c9514..d8e7f02642 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/AwsSdkPipelineWrapper.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/AwsSdkPipelineWrapper.cs @@ -1,10 +1,13 @@ // Copyright 2020 New Relic, Inc. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -using System.Collections.Generic; +using System; +using System.Linq; using NewRelic.Agent.Api; +using NewRelic.Agent.Extensions.AwsSdk; using NewRelic.Agent.Extensions.Collections; using NewRelic.Agent.Extensions.Providers.Wrapper; +using NewRelic.Providers.Wrapper.AwsSdk.RequestHandlers; namespace NewRelic.Providers.Wrapper.AwsSdk { @@ -14,12 +17,61 @@ public class AwsSdkPipelineWrapper : IWrapper private const string WrapperName = "AwsSdkPipelineWrapper"; private static ConcurrentHashSet _unsupportedRequestTypes = new(); + private static bool _reportBadAccountId = true; + private static bool _reportBadArnBuilder = false; public CanWrapResponse CanWrap(InstrumentedMethodInfo methodInfo) { return new CanWrapResponse(WrapperName.Equals(methodInfo.RequestedWrapperName)); } + private ArnBuilder CreateArnBuilder(IAgent agent, dynamic requestContext) + { + string partition = null; + string systemName = null; + string accountId = null; + try + { + accountId = GetAccountId(agent); + var clientConfig = requestContext.ClientConfig; + if (clientConfig.RegionEndpoint != null) + { + var regionEndpoint = clientConfig.RegionEndpoint; + systemName = regionEndpoint.SystemName; + partition = regionEndpoint.PartitionName; + } + } + catch (Exception e) + { + if (_reportBadArnBuilder) + { + agent.Logger.Debug(e, $"AwsSdkPipelineWrapper: Unable to get required ARN components from requestContext."); + _reportBadArnBuilder = false; + } + } + + return new ArnBuilder(partition, systemName, accountId); + } + + private string GetAccountId(IAgent agent) + { + string accountId = AmazonServiceClientWrapper.AwsAccountId; + + if (accountId != null) + { + if ((accountId.Length != 12) || accountId.Any(c => (c < '0') || (c > '9'))) + { + if (_reportBadAccountId) + { + agent.Logger.Warn("Supplied AWS Account ID appears to be invalid"); + _reportBadAccountId = false; + } + } + } + + return accountId; + } + public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, ITransaction transaction) { // Get the IExecutionContext (the only parameter) @@ -49,18 +101,25 @@ public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall ins } dynamic request = requestContext.OriginalRequest; string requestType = request.GetType().FullName; + ArnBuilder builder = CreateArnBuilder(agent, requestContext); if (requestType.StartsWith("Amazon.SQS")) { return SQSRequestHandler.HandleSQSRequest(instrumentedMethodCall, agent, transaction, request, isAsync, executionContext); } - else if (requestType.StartsWith("Amazon.DynamoDBv2")) + + if (requestType == "Amazon.Lambda.Model.InvokeRequest") { - return DynamoDbRequestHandler.HandleDynamoDbRequest(instrumentedMethodCall, agent, transaction, request, isAsync, executionContext); + return LambdaInvokeRequestHandler.HandleInvokeRequest(instrumentedMethodCall, agent, transaction, request, isAsync, builder); + } + + if (requestType.StartsWith("Amazon.DynamoDBv2")) + { + return DynamoDbRequestHandler.HandleDynamoDbRequest(instrumentedMethodCall, agent, transaction, request, isAsync, builder); } if (!_unsupportedRequestTypes.Contains(requestType)) // log once per unsupported request type - { + { agent.Logger.Debug($"AwsSdkPipelineWrapper: Unsupported request type: {requestType}. Returning NoOp delegate."); _unsupportedRequestTypes.Add(requestType); } diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/Instrumentation.xml b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/Instrumentation.xml index c909ea4cd5..556e0d0d82 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/Instrumentation.xml +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/Instrumentation.xml @@ -13,5 +13,14 @@ SPDX-License-Identifier: Apache-2.0 + + + + + + diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/DynamoDbRequestHandler.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/DynamoDbRequestHandler.cs similarity index 87% rename from src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/DynamoDbRequestHandler.cs rename to src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/DynamoDbRequestHandler.cs index 023ee2281e..4e3b9922aa 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/DynamoDbRequestHandler.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/DynamoDbRequestHandler.cs @@ -4,15 +4,16 @@ using System.Collections.Concurrent; using System.Threading.Tasks; using NewRelic.Agent.Api; +using NewRelic.Agent.Extensions.AwsSdk; using NewRelic.Agent.Extensions.Parsing; using NewRelic.Agent.Extensions.Providers.Wrapper; -namespace NewRelic.Providers.Wrapper.AwsSdk +namespace NewRelic.Providers.Wrapper.AwsSdk.RequestHandlers { internal static class DynamoDbRequestHandler { - private static ConcurrentDictionary _operationNameCache = new ConcurrentDictionary(); + private static ConcurrentDictionary _operationNameCache = new ConcurrentDictionary(); public static AfterWrappedMethodDelegate HandleDynamoDbRequest(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, ITransaction transaction, dynamic request, bool isAsync, dynamic executionContext) { @@ -30,6 +31,7 @@ public static AfterWrappedMethodDelegate HandleDynamoDbRequest(InstrumentedMetho model = request.TableName; var segment = transaction.StartDatastoreSegment(instrumentedMethodCall.MethodCall, new ParsedSqlStatement(DatastoreVendor.DynamoDB, model, operation), isLeaf: true); + return isAsync ? Delegates.GetAsyncDelegateFor(agent, segment) : diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/LambdaInvokeRequestHandler.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/LambdaInvokeRequestHandler.cs new file mode 100644 index 0000000000..b0bf16a8a7 --- /dev/null +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/LambdaInvokeRequestHandler.cs @@ -0,0 +1,125 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Concurrent; +using System; +using System.Threading.Tasks; +using NewRelic.Agent.Api; +using NewRelic.Agent.Extensions.Providers.Wrapper; +using NewRelic.Reflection; +using NewRelic.Agent.Extensions.AwsSdk; + +namespace NewRelic.Providers.Wrapper.AwsSdk.RequestHandlers +{ + internal static class LambdaInvokeRequestHandler + { + private static Func _getResultFromGenericTask; + private static readonly ConcurrentDictionary _arnCache = new(); + private static bool _reportMissingRequestId = true; + private static bool _reportBadInvocationName = true; + private const int MAX_CACHE_SIZE = 25; // Shouldn't ever get this big, but just in case + + private static object GetTaskResult(object task) + { + if (((Task)task).IsFaulted) + { + return null; + } + + var getResponse = _getResultFromGenericTask ??= VisibilityBypasser.Instance.GeneratePropertyAccessor(task.GetType(), "Result"); + return getResponse(task); + } + + private static void SetRequestIdIfAvailable(IAgent agent, ISegment segment, dynamic response) + { + try + { + dynamic metadata = response.ResponseMetadata; + string requestId = metadata.RequestId; + segment.AddCloudSdkAttribute("aws.requestId", requestId); + } + catch (Exception e) + { + if (_reportMissingRequestId) + { + agent.Logger.Debug(e, "Unable to get RequestId from response metadata."); + _reportMissingRequestId = false; + } + } + } + + public static AfterWrappedMethodDelegate HandleInvokeRequest(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, ITransaction transaction, dynamic request, bool isAsync, ArnBuilder builder) + { + string functionName = request.FunctionName; + string qualifier = request.Qualifier; + if (!string.IsNullOrEmpty(qualifier) && !functionName.EndsWith(qualifier)) + { + functionName = $"{functionName}:{qualifier}"; + } + string arn; + if (functionName.StartsWith("arn:")) + { + arn = functionName; + } + else + { + if (!_arnCache.TryGetValue(functionName, out arn)) + { + arn = builder.BuildFromPartialLambdaArn(functionName); + if (_arnCache.Count < MAX_CACHE_SIZE) + { + _arnCache.TryAdd(functionName, arn); + } + } + } + var segment = transaction.StartTransactionSegment(instrumentedMethodCall.MethodCall, "InvokeRequest"); + + segment.AddCloudSdkAttribute("cloud.platform", "aws_lambda"); + segment.AddCloudSdkAttribute("aws.operation", "InvokeRequest"); + segment.AddCloudSdkAttribute("aws.region", builder.Region); + + + if (!string.IsNullOrEmpty(arn)) + { + segment.AddCloudSdkAttribute("cloud.resource_id", arn); + } + else if (_reportBadInvocationName) + { + agent.Logger.Debug($"Unable to resolve Lambda invocation named '{functionName}' [{builder.ToString()}]"); + _reportBadInvocationName = false; + } + + if (isAsync) + { + return Delegates.GetAsyncDelegateFor(agent, segment, true, InvokeTryProcessResponse, TaskContinuationOptions.ExecuteSynchronously); + + void InvokeTryProcessResponse(Task responseTask) + { + try + { + if (responseTask.Status == TaskStatus.Faulted) + { + transaction.NoticeError(responseTask.Exception); + } + SetRequestIdIfAvailable(agent, segment, GetTaskResult(responseTask)); + } + finally + { + segment?.End(); + } + } + } + else + { + return Delegates.GetDelegateFor( + onFailure: ex => segment.End(ex), + onSuccess: response => + { + SetRequestIdIfAvailable(agent, segment, response); + segment.End(); + } + ); + } + } + } +} diff --git a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/SQSRequestHandler.cs b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/SQSRequestHandler.cs similarity index 95% rename from src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/SQSRequestHandler.cs rename to src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/SQSRequestHandler.cs index 45f4fecffb..587f4ba69f 100644 --- a/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/SQSRequestHandler.cs +++ b/src/Agent/NewRelic/Agent/Extensions/Providers/Wrapper/AwsSdk/RequestHandlers/SQSRequestHandler.cs @@ -11,7 +11,7 @@ using NewRelic.Agent.Extensions.Providers.Wrapper; using NewRelic.Reflection; -namespace NewRelic.Providers.Wrapper.AwsSdk +namespace NewRelic.Providers.Wrapper.AwsSdk.RequestHandlers { internal static class SQSRequestHandler { @@ -48,7 +48,7 @@ public static AfterWrappedMethodDelegate HandleSQSRequest(InstrumentedMethodCall var dtHeaders = agent.GetConfiguredDTHeaders(); string requestQueueUrl = request.QueueUrl; - ISegment segment = SqsHelper.GenerateSegment(transaction, instrumentedMethodCall.MethodCall, requestQueueUrl, action); + var segment = SqsHelper.GenerateSegment(transaction, instrumentedMethodCall.MethodCall, requestQueueUrl, action); switch (action) { case MessageBrokerAction.Produce when requestType == "SendMessageRequest": @@ -117,7 +117,7 @@ public static AfterWrappedMethodDelegate HandleSQSRequest(InstrumentedMethodCall void ProcessResponse(Task responseTask) { - if (!ValidTaskResponse(responseTask) || (segment == null) || action != MessageBrokerAction.Consume) + if (!ValidTaskResponse(responseTask) || segment == null || action != MessageBrokerAction.Consume) return; // taskResult is a ReceiveMessageResponse diff --git a/src/Agent/NewRelic/Home/Home.csproj b/src/Agent/NewRelic/Home/Home.csproj index 58165ba6a3..29cf52ca73 100644 --- a/src/Agent/NewRelic/Home/Home.csproj +++ b/src/Agent/NewRelic/Home/Home.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplicationFixture.cs b/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplicationFixture.cs index c665fe3052..bfb7db67fe 100644 --- a/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplicationFixture.cs +++ b/tests/Agent/IntegrationTests/IntegrationTestHelpers/RemoteServiceFixtures/RemoteApplicationFixture.cs @@ -351,6 +351,7 @@ public virtual void Initialize() catch (Exception ex) { TestLogger?.WriteLine("Exception occurred in Initialize: " + ex.ToString()); + AgentLogExpected = false; throw; } finally diff --git a/tests/Agent/IntegrationTests/IntegrationTests/AwsSdk/InvokeLambdaTests.cs b/tests/Agent/IntegrationTests/IntegrationTests/AwsSdk/InvokeLambdaTests.cs new file mode 100644 index 0000000000..4d42f15097 --- /dev/null +++ b/tests/Agent/IntegrationTests/IntegrationTests/AwsSdk/InvokeLambdaTests.cs @@ -0,0 +1,138 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using NewRelic.Agent.IntegrationTestHelpers; +using NewRelic.Agent.IntegrationTestHelpers.RemoteServiceFixtures; +using Xunit; +using Xunit.Abstractions; + +namespace NewRelic.Agent.IntegrationTests.AwsSdk +{ + public abstract class InvokeLambdaTestBase : NewRelicIntegrationTest + where TFixture : ConsoleDynamicMethodFixture + { + private readonly TFixture _fixture; + private readonly string _function; + private readonly string _qualifier; + private readonly string _arn; + private readonly bool _isAsync; + + public InvokeLambdaTestBase(TFixture fixture, ITestOutputHelper output, bool useAsync, string function, string qualifier, string arn) : base(fixture) + { + _function = function; + _qualifier = qualifier; + _arn = arn; + _isAsync = useAsync; + + _fixture = fixture; + _fixture.SetTimeout(TimeSpan.FromMinutes(2)); + + _fixture.TestLogger = output; + _fixture.AddActions( + setupConfiguration: () => + { + new NewRelicConfigModifier(fixture.DestinationNewRelicConfigFilePath) + .ForceTransactionTraces() + .SetLogLevel("finest"); + }, + exerciseApplication: () => + { + _fixture.AgentLog.WaitForLogLines(AgentLogBase.TransactionTransformCompletedLogLineRegex, TimeSpan.FromMinutes(2),2); + } + ); + + if (useAsync) + { + _fixture.AddCommand($"InvokeLambdaExerciser InvokeLambdaAsync {_function}:{_qualifier} \"fakepayload\""); + _fixture.AddCommand($"InvokeLambdaExerciser InvokeLambdaAsyncWithQualifier {_function} {_qualifier} \"fakepayload\""); + } + else + { + _fixture.AddCommand($"InvokeLambdaExerciser InvokeLambdaSync {_function} \"fakepayload\""); + } + _fixture.Initialize(); + } + + [Fact] + public void InvokeLambda() + { + var metrics = _fixture.AgentLog.GetMetrics().ToList(); + + var expectedCount = _isAsync ? 2 : 1; + var expectedMetrics = new List + { + new Assertions.ExpectedMetric {metricName = @"DotNet/InvokeRequest", CallCountAllHarvests = expectedCount}, + }; + Assertions.MetricsExist(expectedMetrics, metrics); + + var transactions = _fixture.AgentLog.GetTransactionEvents().ToList(); + Assert.Equal(expectedCount, transactions.Count()); + + foreach (var transaction in transactions) + { + Assert.StartsWith("OtherTransaction/Custom/MultiFunctionApplicationHelpers.NetStandardLibraries.AwsSdk.InvokeLambdaExerciser/InvokeLambda", transaction.IntrinsicAttributes["name"].ToString()); + } + + var allSpans = _fixture.AgentLog.GetSpanEvents() + .Where(e => e.AgentAttributes.ContainsKey("cloud.platform")) + .ToList(); + Assert.Equal(expectedCount, allSpans.Count); + + foreach (var span in allSpans) + { + Assert.Equal("aws_lambda", span.AgentAttributes["cloud.platform"]); + Assert.Equal("InvokeRequest", span.AgentAttributes["aws.operation"]); + Assert.Equal("us-west-2", span.AgentAttributes["aws.region"]); + } + + // There should be one fewer span in this list, because there's one where there wasn't + // enough info to create an ARN + var spansWithArn = _fixture.AgentLog.GetSpanEvents() + .Where(e => e.AgentAttributes.ContainsKey("cloud.resource_id")) + .ToList(); + Assert.Equal(expectedCount, spansWithArn.Count); + foreach (var span in spansWithArn) + { + Assert.Equal(_arn, span.AgentAttributes["cloud.resource_id"]); + } + } + } + [NetFrameworkTest] + public class InvokeLambdaTest_Sync_FW462 : InvokeLambdaTestBase + { + public InvokeLambdaTest_Sync_FW462(ConsoleDynamicMethodFixtureFW462 fixture, ITestOutputHelper output) + : base(fixture, output, false, "342444490463:NotARealFunction", null, "arn:aws:lambda:us-west-2:342444490463:function:NotARealFunction") + { + } + } + [NetFrameworkTest] + public class InvokeLambdaTest_Sync_FWLatest : InvokeLambdaTestBase + { + public InvokeLambdaTest_Sync_FWLatest(ConsoleDynamicMethodFixtureFWLatest fixture, ITestOutputHelper output) + : base(fixture, output, false, "342444490463:NotARealFunction", null, "arn:aws:lambda:us-west-2:342444490463:function:NotARealFunction") + { + } + } + [NetCoreTest] + public class InvokeLambdaTest_Async_CoreOldest : InvokeLambdaTestBase + { + public InvokeLambdaTest_Async_CoreOldest(ConsoleDynamicMethodFixtureCoreOldest fixture, ITestOutputHelper output) + : base(fixture, output, true, "342444490463:NotARealFunction", "NotARealAlias", "arn:aws:lambda:us-west-2:342444490463:function:NotARealFunction:NotARealAlias") + { + } + } + + [NetCoreTest] + public class InvokeLambdaTest_Async_CoreLatest : InvokeLambdaTestBase + { + public InvokeLambdaTest_Async_CoreLatest(ConsoleDynamicMethodFixtureCoreLatest fixture, ITestOutputHelper output) + : base(fixture, output, true, "342444490463:NotARealFunction", "NotARealAlias", "arn:aws:lambda:us-west-2:342444490463:function:NotARealFunction:NotARealAlias") + { + } + } + +} diff --git a/tests/Agent/IntegrationTests/Shared/AwsBedrockConfiguration.cs b/tests/Agent/IntegrationTests/Shared/AwsConfiguration.cs similarity index 96% rename from tests/Agent/IntegrationTests/Shared/AwsBedrockConfiguration.cs rename to tests/Agent/IntegrationTests/Shared/AwsConfiguration.cs index fc446cb945..26158c52b0 100644 --- a/tests/Agent/IntegrationTests/Shared/AwsBedrockConfiguration.cs +++ b/tests/Agent/IntegrationTests/Shared/AwsConfiguration.cs @@ -3,7 +3,7 @@ namespace NewRelic.Agent.IntegrationTests.Shared { - public class AwsBedrockConfiguration + public class AwsConfiguration { public static string AwsAccessKeyId { diff --git a/tests/Agent/IntegrationTests/SharedApplications/Common/MFALatestPackages/MFALatestPackages.csproj b/tests/Agent/IntegrationTests/SharedApplications/Common/MFALatestPackages/MFALatestPackages.csproj index c736c06fd9..7827c51495 100644 --- a/tests/Agent/IntegrationTests/SharedApplications/Common/MFALatestPackages/MFALatestPackages.csproj +++ b/tests/Agent/IntegrationTests/SharedApplications/Common/MFALatestPackages/MFALatestPackages.csproj @@ -5,6 +5,9 @@ + + + diff --git a/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/MultiFunctionApplicationHelpers.csproj b/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/MultiFunctionApplicationHelpers.csproj index 567c4edd4d..b3740eec81 100644 --- a/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/MultiFunctionApplicationHelpers.csproj +++ b/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/MultiFunctionApplicationHelpers.csproj @@ -269,6 +269,14 @@ + + + + + + + + diff --git a/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/NetStandardLibraries/AwsSdk/InvokeLambdaExerciser.cs b/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/NetStandardLibraries/AwsSdk/InvokeLambdaExerciser.cs new file mode 100644 index 0000000000..269e812fcb --- /dev/null +++ b/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/NetStandardLibraries/AwsSdk/InvokeLambdaExerciser.cs @@ -0,0 +1,97 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.IO; +using System.Security.AccessControl; +using System.Threading.Tasks; +using Amazon; +using Amazon.Lambda.Model; +using NewRelic.Agent.IntegrationTests.Shared.ReflectionHelpers; +using NewRelic.Api.Agent; + +namespace MultiFunctionApplicationHelpers.NetStandardLibraries.AwsSdk +{ + [Library] + public class InvokeLambdaExerciser + { + [LibraryMethod] + [Transaction] + public void InvokeLambdaSync(string function, string payload) + { +#if NETFRAMEWORK + var client = new Amazon.Lambda.AmazonLambdaClient(RegionEndpoint.USWest2); + var request = new Amazon.Lambda.Model.InvokeRequest + { + FunctionName = function, + Payload = payload + }; + + // Note that we aren't invoking a lambda that exists! This is guaranteed to fail, but all we care + // about is that the agent is able to instrument the call. + try + { + var response = client.Invoke(request); + } + catch + { + } +#else + throw new Exception($"Synchronous calls are only supported on .NET Framework!"); +#endif + } + + [LibraryMethod] + [Transaction] + public async Task InvokeLambdaAsync(string function, string payload) + { + var client = new Amazon.Lambda.AmazonLambdaClient(RegionEndpoint.USWest2); + var request = new Amazon.Lambda.Model.InvokeRequest + { + FunctionName = function, + Payload = payload + }; + + // Note that we aren't invoking a lambda that exists! This is guaranteed to fail, but all we care + // about is that the agent is able to instrument the call. + try + { + var response = await client.InvokeAsync(request); + MemoryStream stream = response.Payload; + string returnValue = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + return returnValue; + } + catch + { + } + return null; + } + + [LibraryMethod] + [Transaction] + public async Task InvokeLambdaAsyncWithQualifier(string function, string qualifier, string payload) + { + var client = new Amazon.Lambda.AmazonLambdaClient(RegionEndpoint.USWest2); + var request = new Amazon.Lambda.Model.InvokeRequest + { + FunctionName = function, + Qualifier = qualifier, + Payload = payload + }; + + // Note that we aren't invoking a lambda that exists! This is guaranteed to fail, but all we care + // about is that the agent is able to instrument the call. + try + { + var response = await client.InvokeAsync(request); + MemoryStream stream = response.Payload; + string returnValue = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + return returnValue; + } + catch + { + } + return null; + } + } +} diff --git a/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/NetStandardLibraries/LLM/BedrockModels.cs b/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/NetStandardLibraries/LLM/BedrockModels.cs index f7d16f8a9e..d2731d06d5 100644 --- a/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/NetStandardLibraries/LLM/BedrockModels.cs +++ b/tests/Agent/IntegrationTests/SharedApplications/Common/MultiFunctionApplicationHelpers/NetStandardLibraries/LLM/BedrockModels.cs @@ -20,7 +20,7 @@ namespace MultiFunctionApplicationHelpers.NetStandardLibraries.LLM internal class BedrockModels { private static readonly AmazonBedrockRuntimeClient _amazonBedrockRuntimeClient = - new AmazonBedrockRuntimeClient(AwsBedrockConfiguration.AwsAccessKeyId, AwsBedrockConfiguration.AwsSecretAccessKey, AwsBedrockConfiguration.AwsRegion.ToRegionId()); + new AmazonBedrockRuntimeClient(AwsConfiguration.AwsAccessKeyId, AwsConfiguration.AwsSecretAccessKey, AwsConfiguration.AwsRegion.ToRegionId()); [MethodImpl(MethodImplOptions.NoInlining)] public static async Task InvokeAmazonEmbedAsync(string prompt, bool generateError) => await InvokeTitanAsync(prompt, true, generateError); diff --git a/tests/Agent/UnitTests/CompositeTests/AgentApiTests.cs b/tests/Agent/UnitTests/CompositeTests/AgentApiTests.cs index c0da43590b..5da764b13e 100644 --- a/tests/Agent/UnitTests/CompositeTests/AgentApiTests.cs +++ b/tests/Agent/UnitTests/CompositeTests/AgentApiTests.cs @@ -2280,5 +2280,50 @@ public void SpanCustomAttributes() } #endregion + + #region Span Cloud SDK Attributes + [Test] + public void SpanCloudSdkAttributes() + { + var agentWrapperApi = _compositeTestAgent.GetAgent(); + var dtm1 = DateTime.Now; + var dtm2 = DateTimeOffset.Now; + + // ACT + var transaction = agentWrapperApi.CreateTransaction( + isWeb: true, + category: EnumNameCache.GetName(WebTransactionType.Action), + transactionDisplayName: "name", + doNotTrackAsUnitOfWork: true); + + var segment = agentWrapperApi.StartTransactionSegmentOrThrow("segment"); + + segment.AddCloudSdkAttribute("cloud.platform", "aws_lambda"); + segment.AddCloudSdkAttribute("aws.region", "us-west-2"); + segment.AddCloudSdkAttribute("cloud.resource_id", "arn:aws:lambda:us-west-2:123456789012:function:myfunction"); + + var expectedAttributes = new[] + { + new ExpectedAttribute(){ Key = "cloud.platform", Value = "aws_lambda"}, + new ExpectedAttribute(){ Key = "aws.region", Value = "us-west-2"}, + new ExpectedAttribute(){ Key = "cloud.resource_id", Value = "arn:aws:lambda:us-west-2:123456789012:function:myfunction"}, + }; + + segment.End(); + transaction.End(); + + _compositeTestAgent.Harvest(); + + var allSpans = _compositeTestAgent.SpanEvents; + var testSpan = allSpans.LastOrDefault(); + + NrAssert.Multiple + ( + () => Assert.That(allSpans, Has.Count.EqualTo(2)), + () => SpanAssertions.HasAttributes(expectedAttributes, AttributeClassification.AgentAttributes, testSpan) + ); + } + + #endregion } } diff --git a/tests/Agent/UnitTests/Core.UnitTest/AgentHealth/AgentHealthReporterTests.cs b/tests/Agent/UnitTests/Core.UnitTest/AgentHealth/AgentHealthReporterTests.cs index 528470beaf..1da15a9534 100644 --- a/tests/Agent/UnitTests/Core.UnitTest/AgentHealth/AgentHealthReporterTests.cs +++ b/tests/Agent/UnitTests/Core.UnitTest/AgentHealth/AgentHealthReporterTests.cs @@ -61,6 +61,7 @@ private IConfiguration GetDefaultConfiguration() Mock.Arrange(() => configuration.LoggingEnabled).Returns(() => _enableLogging); Mock.Arrange(() => configuration.IgnoredInstrumentation).Returns(() => _ignoredInstrumentation); Mock.Arrange(() => configuration.GCSamplerV2Enabled).Returns(true); + Mock.Arrange(() => configuration.AwsAccountId).Returns("123456789012"); Mock.Arrange(() => configuration.LabelsEnabled).Returns(true); return configuration; @@ -534,5 +535,12 @@ public void GCSamplerV2EnabledSupportabiliityMetricPresent() _agentHealthReporter.CollectMetrics(); Assert.That(_publishedMetrics.Any(x => x.MetricNameModel.Name == "Supportability/Dotnet/GCSamplerV2/Enabled"), Is.True); } + + [Test] + public void AwsAccountIdSupportabiliityMetricPresent() + { + _agentHealthReporter.CollectMetrics(); + Assert.That(_publishedMetrics.Any(x => x.MetricNameModel.Name == "Supportability/Dotnet/AwsAccountId/Config"), Is.True); + } } } diff --git a/tests/Agent/UnitTests/Core.UnitTest/Segments/SegmentTests.cs b/tests/Agent/UnitTests/Core.UnitTest/Segments/SegmentTests.cs index 7a9e87fbe0..6c7c59aab8 100644 --- a/tests/Agent/UnitTests/Core.UnitTest/Segments/SegmentTests.cs +++ b/tests/Agent/UnitTests/Core.UnitTest/Segments/SegmentTests.cs @@ -58,5 +58,57 @@ public void DurationOrZero_ReturnsDuration_IfDurationIsSet() Assert.That(duration, Is.EqualTo(TimeSpan.FromSeconds(1))); } + [Test] + public void Misc_Segment_Setters() + { + var segment = new Segment(TransactionSegmentStateHelpers.GetItransactionSegmentState(), new MethodCallData("Type", "Method", 1)); + Assert.That(segment.IsLeaf, Is.False); + segment.MakeLeaf(); + Assert.That(segment.IsLeaf, Is.True); + + segment.SetName("foo"); + Assert.That(segment.GetTransactionTraceName(), Is.EqualTo("foo")); + + Assert.That(segment.Combinable, Is.False); + segment.MakeCombinable(); + Assert.That(segment.Combinable, Is.True); + + Assert.That(segment.IsExternal, Is.False); + } + + [Test] + public void NoOpSegment() + { + var segment = new NoOpSegment(); + Assert.That(segment.IsDone, Is.True); + Assert.That(segment.IsValid, Is.False); + Assert.That(segment.IsDone, Is.True); + Assert.That(segment.DurationShouldBeDeductedFromParent, Is.False); + Assert.That(segment.IsLeaf, Is.False); + Assert.That(segment.IsExternal, Is.False); + Assert.That(segment.SpanId, Is.Null); + Assert.That(segment.SegmentData, Is.Not.Null); + Assert.That(segment.AttribDefs, Is.Not.Null); + Assert.That(segment.AttribValues, Is.Not.Null); + Assert.That(segment.TypeName, Is.EqualTo(string.Empty)); + Assert.That(segment.UserCodeFunction, Is.EqualTo(string.Empty)); + Assert.That(segment.UserCodeNamespace, Is.EqualTo(string.Empty)); + Assert.That(segment.SegmentNameOverride, Is.Null); + + Assert.DoesNotThrow(() => segment.End()); + Assert.DoesNotThrow(() => segment.End(new Exception())); + Assert.DoesNotThrow(() => segment.EndStackExchangeRedis()); + Assert.DoesNotThrow(() => segment.MakeCombinable()); + Assert.DoesNotThrow(() => segment.MakeLeaf()); + Assert.DoesNotThrow(() => segment.RemoveSegmentFromCallStack()); + Assert.DoesNotThrow(() => segment.SetMessageBrokerDestination("destination")); + Assert.DoesNotThrow(() => segment.SetSegmentData(null)); + Assert.DoesNotThrow(() => segment.AddCustomAttribute("key", "value")); + Assert.DoesNotThrow(() => segment.AddCloudSdkAttribute("key", "value")); + Assert.DoesNotThrow(() => segment.SetName("name")); + Assert.That(segment.GetCategory(), Is.EqualTo(string.Empty)); + Assert.That(segment.DurationOrZero, Is.EqualTo(TimeSpan.Zero)); + + } } } diff --git a/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Collections/ConcurrentHashSet.cs b/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Collections/ConcurrentHashSet.cs index 85a69a634d..2f4e589362 100644 --- a/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Collections/ConcurrentHashSet.cs +++ b/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Collections/ConcurrentHashSet.cs @@ -96,6 +96,24 @@ public void ConcurrentHashSet_IsThreadSafe() tasks.ForEach(task => task.Wait()); } + [Test] + [TestCase(new[] { 1, 2, 3 }, new[] { true, true, true })] + [TestCase(new[] { 1, 2, 2, 3 }, new[] { true, true, false, true })] + [TestCase(new[] { 4, 4, 4, 4 }, new[] { true, false, false, false })] + [TestCase(new[] { 5, 6, 7, 8, 9 }, new[] { true, true, true, true, true })] + [TestCase(new[] { 10, 10, 11, 11, 12 }, new[] { true, false, true, false, true })] + public void ConcurrentHashSet_TryAdd(int[] values, bool[] results) + { + ConcurrentHashSet set = new(); + for (var i = 0; i < values.Length; i++) + { + var value = values[i]; + var result = results[i]; + + Assert.That(set.TryAdd(value), Is.EqualTo(result)); + } + } + private static void ExerciseFullApi(ConcurrentHashSet hashSet, int[] numbersToAdd) { dynamic _; diff --git a/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/AwsAccountIdDecoderTests.cs b/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/AwsAccountIdDecoderTests.cs new file mode 100644 index 0000000000..c884ed0f16 --- /dev/null +++ b/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/AwsAccountIdDecoderTests.cs @@ -0,0 +1,102 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using NewRelic.Agent.Extensions.AwsSdk; +using NUnit.Framework; +using Telerik.JustMock; + +namespace Agent.Extensions.Tests.Helpers +{ + [TestFixture] + internal class AwsAccountIdDecoderTests + { + [Test] + public void GetAccountId_ValidAwsAccessKeyId_ReturnsExpectedAccountId() + { + // Arrange + string awsAccessKeyId = "AKIAIOSFODNN7EXAMPLE"; // not a real AWS access key! + string expectedAccountId = "581039954779"; + + // Act + string actualAccountId = AwsAccountIdDecoder.GetAccountId(awsAccessKeyId); + + // Assert + Assert.That(expectedAccountId, Is.EqualTo(actualAccountId)); + } + + [Test] + public void GetAccountId_NullOrEmptyAwsAccessKeyId_ThrowsArgumentNullException() + { + // Arrange + string awsAccessKeyId = null; + + // Act & Assert + var ex = Assert.Throws(() => AwsAccountIdDecoder.GetAccountId(awsAccessKeyId)); + Assert.That(ex.ParamName, Is.EqualTo("awsAccessKeyId")); + } + + [Test] + public void GetAccountId_ShortAwsAccessKeyId_ThrowsArgumentOutOfRangeException() + { + // Arrange + string awsAccessKeyId = "AKIAIOSFODN"; + + // Act & Assert + var ex = Assert.Throws(() => AwsAccountIdDecoder.GetAccountId(awsAccessKeyId)); + Assert.That(ex.ParamName, Is.EqualTo("awsAccessKeyId")); + } + + [Test] + public void Base32Decode_ShortString_ThrowsArgumentException() + { + // Arrange + string shortString = "shortstr"; + + // Act & Assert + var ex = Assert.Throws(() => AwsAccountIdDecoder.Base32Decode(shortString)); + Assert.That(ex.ParamName, Is.EqualTo("src")); + } + + [Test] + public void Base32Decode_InvalidCharacters_ThrowsArgumentOutOfRangeException() + { + // Arrange + string invalidBase32String = "someBogusbase32string"; + + // Act & Assert + var ex = Assert.Throws(() => AwsAccountIdDecoder.Base32Decode(invalidBase32String)); + Assert.That(ex.ParamName, Is.EqualTo("src")); + } + + [Test] + public void Base32Decode_NullOrEmptyString_ThrowsArgumentNullException() + { + // Arrange + string nullString = null; + string emptyString = string.Empty; + + // Act & Assert + var exNull = Assert.Throws(() => AwsAccountIdDecoder.Base32Decode(nullString)); + Assert.That(exNull.ParamName, Is.EqualTo("src")); + + var exEmpty = Assert.Throws(() => AwsAccountIdDecoder.Base32Decode(emptyString)); + Assert.That(exEmpty.ParamName, Is.EqualTo("src")); + } + + [Test] + public void Base32Decode_ValidBase32String_ReturnsDecodedLong() + { + // Arrange + string validBase32String = "iosfodnn7example"; // Example valid Base32 string (10 characters) + long expectedDecodedValue = 74373114211833L; + + // Act + long decodedValue = AwsAccountIdDecoder.Base32Decode(validBase32String); + + // Assert + Assert.That(decodedValue, Is.EqualTo(expectedDecodedValue)); // Adjust expected value based on actual decoding logic + } + + } +} diff --git a/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/AwsSdkHelperTests.cs b/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/AwsSdkHelperTests.cs new file mode 100644 index 0000000000..e44e2d5722 --- /dev/null +++ b/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/AwsSdkHelperTests.cs @@ -0,0 +1,84 @@ +// Copyright 2020 New Relic, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +using NUnit.Framework; +using NewRelic.Agent.Extensions.AwsSdk; + +namespace Agent.Extensions.Tests.Helpers +{ + public class AwsSdkArnBuilderTests + { + [Test] + [TestCase("myfunction", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:myfunction")] + [TestCase("myfunction", "us-west-2", "", null)] + [TestCase("myfunction", "", "123456789012", "arn:aws:lambda:(unknown):123456789012:function:myfunction")] + [TestCase("myfunction:alias", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:myfunction:alias")] + [TestCase("myfunction:alias", "us-west-2", "", null)] + [TestCase("123456789012:function:my-function", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:my-function")] + [TestCase("123456789012:function:my-function:myalias", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:my-function:myalias")] + [TestCase("123456789012:function:my-function:myalias:extra", "us-west-2", "123456789012", null)] + [TestCase("123456789012:function:my-function:myalias:extra:lots:of:extra:way:too:many", "us-west-2", "123456789012", null)] + [TestCase("arn:aws:", "us-west-2", "123456789012", "arn:aws:")] + [TestCase("arn:aws:lambda:us-west-2:123456789012:function:myfunction", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:myfunction")] + [TestCase("arn:aws:lambda:us-west-2:123456789012:function:myfunction", "us-west-2", "", "arn:aws:lambda:us-west-2:123456789012:function:myfunction")] + [TestCase("arn:aws:lambda:us-west-2:123456789012:function:myfunction", "", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:myfunction")] + [TestCase("myfunction", "us-east-1", "987654321098", "arn:aws:lambda:us-east-1:987654321098:function:myfunction")] + [TestCase("myfunction:prod", "eu-west-1", "111122223333", "arn:aws:lambda:eu-west-1:111122223333:function:myfunction:prod")] + [TestCase("my-function", "ap-southeast-1", "444455556666", "arn:aws:lambda:ap-southeast-1:444455556666:function:my-function")] + [TestCase("my-function:beta", "ca-central-1", "777788889999", "arn:aws:lambda:ca-central-1:777788889999:function:my-function:beta")] + [TestCase("arn:aws:lambda:eu-central-1:222233334444:function:myfunction", "eu-central-1", "222233334444", "arn:aws:lambda:eu-central-1:222233334444:function:myfunction")] + [TestCase("us-west-2:myfunction", null, "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:myfunction")] + [TestCase("us-west-2:myfunction", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:myfunction")] + [TestCase("us-west-2:myfunction", "us-west-2", "", null)] + [TestCase("us-west-2:myfunction:alias", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:myfunction:alias")] + [TestCase("us-west-2:myfunction:alias", "us-west-2", "", null)] + [TestCase("123456789012:my-function", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:my-function")] + [TestCase("123456789012:my-function:myalias", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:my-function:myalias")] + [TestCase("123456789012:my-function:myalias:extra", "us-west-2", "123456789012", null)] + [TestCase("123456789012:my-function:myalias:extra:lots:of:extra:way:too:many", "us-west-2", "123456789012", null)] + [TestCase("eu-west-1:us-west-2", "eu-west-1", "123456789012", "arn:aws:lambda:eu-west-1:123456789012:function:us-west-2")] + [TestCase("", "us-west-2", "123456789012", null)] + // Edge cases: functions that look like account IDs or region names + [TestCase("123456789012:444455556666", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:444455556666")] + [TestCase("444455556666", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:444455556666")] + [TestCase("us-west-2", "us-west-2", "123456789012", "arn:aws:lambda:us-west-2:123456789012:function:us-west-2")] + public void ConstructLambdaArn(string name, string region, string accountId, string arn) + { + var arnBuilder = new ArnBuilder("aws", region, accountId); + var constructedArn = arnBuilder.BuildFromPartialLambdaArn(name); + Assert.That(constructedArn, Is.EqualTo(arn), "Did not get expected ARN"); + } + + [Test] + [TestCase("aws", "s3", "us-west-2", "123456789012", "bucket_name", "arn:aws:s3:us-west-2:123456789012:bucket_name")] + [TestCase("aws", "dynamodb", "us-east-1", "987654321098", "table_name", "arn:aws:dynamodb:us-east-1:987654321098:table_name")] + [TestCase("aws", "dynamodb", "us-east-1", "987654321098", "table/tabletName", "arn:aws:dynamodb:us-east-1:987654321098:table/tabletName")] + [TestCase("aws", "ec2", "eu-west-1", "111122223333", "instance_id", "arn:aws:ec2:eu-west-1:111122223333:instance_id")] + [TestCase("aws", "sqs", "ap-southeast-1", "444455556666", "queue_name", "arn:aws:sqs:ap-southeast-1:444455556666:queue_name")] + [TestCase("aws", "sns", "ca-central-1", "777788889999", "topic_name", "arn:aws:sns:ca-central-1:777788889999:topic_name")] + [TestCase("aws-cn", "lambda", "cn-north-1", "222233334444", "function_name", "arn:aws-cn:lambda:cn-north-1:222233334444:function_name")] + [TestCase("aws-us-gov", "iam", "us-gov-west-1", "555566667777", "role_name", "arn:aws-us-gov:iam:us-gov-west-1:555566667777:role_name")] + [TestCase("aws", "rds", "sa-east-1", "888899990000", "db_instance", "arn:aws:rds:sa-east-1:888899990000:db_instance")] + [TestCase("aws", "s3", "", "123456789012", "bucket_name", "arn:aws:s3:(unknown):123456789012:bucket_name")] + [TestCase("aws", "s3", "us-west-2", "", "bucket_name", null)] + public void ConstructGenericArn(string partition, string service, string region, string accountId, string resource, string expectedArn) + { + var arnBuilder = new ArnBuilder(partition, region, accountId); + var constructedArn = arnBuilder.Build(service, resource); + Assert.That(constructedArn, Is.EqualTo(expectedArn), "Did not get expected ARN"); + } + + [Test] + [TestCase("aws", "us-west-2", "123456789012", "Partition: aws, Region: us-west-2, AccountId: [Present]")] + [TestCase("aws", "", "123456789012", "Partition: aws, Region: (unknown), AccountId: [Present]")] + [TestCase("aws", "us-west-2", "", "Partition: aws, Region: us-west-2, AccountId: [Missing]")] + [TestCase("aws", "us-west-2", null, "Partition: aws, Region: us-west-2, AccountId: [Missing]")] + [TestCase("", "", "", "Partition: aws, Region: (unknown), AccountId: [Missing]")] + [TestCase(null, null, null, "Partition: aws, Region: (unknown), AccountId: [Missing]")] + public void ArnBuilderToString(string partition, string region, string accountId, string expected) + { + var arnBuilder = new ArnBuilder(partition, region, accountId); + Assert.That(arnBuilder.ToString(), Is.EqualTo(expected), "Did not get expected string"); + } + } +} diff --git a/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/SqsHelperTests.cs b/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/SqsHelperTests.cs index e064d5c351..1e16c5e0f3 100644 --- a/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/SqsHelperTests.cs +++ b/tests/Agent/UnitTests/NewRelic.Agent.Extensions.Tests/Helpers/SqsHelperTests.cs @@ -217,6 +217,10 @@ public ISpan AddCustomAttribute(string key, object value) { throw new NotImplementedException(); } + public ISpan AddCloudSdkAttribute(string key, object value) + { + throw new NotImplementedException(); + } public ISpan SetName(string name) {