diff --git a/CHANGELOG.md b/CHANGELOG.md index 3278acf530..946d229641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,17 @@ ## dev -Version adds experimental OpenSearch instrumentation, updates framework detection, fixes Falcon dispatcher detection, fixes a bug with Redis instrumentation installation, and addresses a JRuby specific concurrency issue. +Version enhances support for AWS Lambda functions, adds experimental OpenSearch instrumentation, updates framework detection, fixes Falcon dispatcher detection, fixes a bug with Redis instrumentation installation, and addresses a JRuby specific concurrency issue. + +- **Feature: Enhance AWS Lambda function instrumentation** + +When utilized via the latest [New Relic Ruby layer for AWS Lambda](https://layers.newrelic-external.com/), the agent now offers enhanced support for AWS Lambda function instrumentation. +* The agent's instrumentation for AWS Lambda functions now supports distributed tracing. +* Web-triggered invocations are now identified as being "web"-based when an API Gateway call is involved, with support for both API Gateway versions 1.0 and 2.0. +* Web-based calls have the HTTP method, URI, and status code recorded. +* The agent now recognizes and reports on 12 separate AWS resources that are capable of triggering a Lambda function invocation: ALB, API Gateway V1, API Gateway V2, CloudFront, CloudWatch Scheduler, DynamoStreams, Firehose, Kinesis, S3, SES, SNS, and SQS. +* The type of the triggering resource and its ARN will be recorded for each resource, and for many of them, extra resource-specific attributes will be recorded as well. For example, Lambda function invocations triggered by S3 bucket activity will now result in the S3 bucket name being recorded. +[PR#2811](https://github.com/newrelic/newrelic-ruby-agent/pull/2811) - **Feature: Add experimental OpenSearch instrumentation** diff --git a/lib/new_relic/agent/serverless_handler.rb b/lib/new_relic/agent/serverless_handler.rb index c47031a0a5..b31f9eb696 100644 --- a/lib/new_relic/agent/serverless_handler.rb +++ b/lib/new_relic/agent/serverless_handler.rb @@ -4,13 +4,13 @@ require 'json' require 'new_relic/base64' +require 'uri' + +require_relative 'serverless_handler_event_sources' module NewRelic module Agent class ServerlessHandler - ATTRIBUTE_ARN = 'aws.lambda.arn' - ATTRIBUTE_COLD_START = 'aws.lambda.coldStart' - ATTRIBUTE_REQUEST_ID = 'aws.requestId' AGENT_ATTRIBUTE_DESTINATIONS = NewRelic::Agent::AttributeFilter::DST_TRANSACTION_TRACER | NewRelic::Agent::AttributeFilter::DST_TRANSACTION_EVENTS EXECUTION_ENVIRONMENT = "AWS_Lambda_ruby#{RUBY_VERSION.rpartition('.').first}".freeze @@ -22,12 +22,15 @@ class ServerlessHandler SUPPORTABILITY_METRIC = 'Supportability/AWSLambda/HandlerInvocation' FUNCTION_NAME = 'lambda_function' PAYLOAD_VERSION = ENV.fetch('NEW_RELIC_SERVERLESS_PAYLOAD_VERSION', 2) + DIGIT = /\d/ + EVENT_SOURCES = NewRelic::Agent::ServerlessHandlerEventSources.to_hash def self.env_var_set? ENV.key?(LAMBDA_ENVIRONMENT_VARIABLE) end def initialize + @event = nil @context = nil @payloads = {} end @@ -35,12 +38,13 @@ def initialize def invoke_lambda_function_with_new_relic(event:, context:, method_name:, namespace: nil) NewRelic::Agent.increment_metric(SUPPORTABILITY_METRIC) - @context = context + @event, @context = event, context - NewRelic::Agent::Tracer.in_transaction(category: :other, name: function_name) do - add_agent_attributes + NewRelic::Agent::Tracer.in_transaction(category: category, name: function_name) do + prep_transaction - NewRelic::LanguageSupport.constantize(namespace).send(method_name, event: event, context: context) + process_response(NewRelic::LanguageSupport.constantize(namespace) + .send(method_name, event: event, context: context)) end ensure harvest! @@ -86,6 +90,12 @@ def error_data(errors) private + def prep_transaction + process_api_gateway_info + process_headers + add_agent_attributes + end + def harvest! NewRelic::Agent.instance.harvest_and_send_analytic_event_data NewRelic::Agent.instance.harvest_and_send_custom_event_data @@ -109,6 +119,11 @@ def function_name ENV.fetch(LAMBDA_ENVIRONMENT_VARIABLE, FUNCTION_NAME) end + def category + @category ||= + @event&.dig('requestContext', 'http', 'method') || @event&.fetch('httpMethod', nil) ? :web : :other + end + def write_output string = PAYLOAD_VERSION == 1 ? payload_v1 : payload_v2 @@ -120,7 +135,7 @@ def write_output "BEGIN PAYLOAD>>>\n#{string}\n<< metadata, 'data' => @payloads} json = NewRelic::Agent.agent.service.marshaller.dump(payload_hash) gzipped = NewRelic::Agent::NewRelicService::Encoders::Compressed::Gzip.encode(json) @@ -129,7 +144,7 @@ def payload_v1 ::JSON.dump(array) end - def payload_v2 + def payload_v2 # New Relic serverless payload v2 json = NewRelic::Agent.agent.service.marshaller.dump(@payloads) gzipped = NewRelic::Agent::NewRelicService::Encoders::Compressed::Gzip.encode(json) base64_encoded = NewRelic::Base64.strict_encode64(gzipped) @@ -137,6 +152,83 @@ def payload_v2 ::JSON.dump(array) end + def determine_api_gateway_version + return unless @event + + version = @event.fetch('version', '') + if version.start_with?('2.') + return 2 + elsif version.start_with?('1.') + return 1 + end + + headers = headers_from_event + return unless headers + + if @event.dig('requestContext', 'http', 'path') && @event.dig('requestContext', 'http', 'method') + 2 + elsif @event.fetch('path', nil) && @event.fetch('httpMethod', nil) + 1 + end + end + + def process_api_gateway_info + api_v = determine_api_gateway_version + return unless api_v + + info = api_v == 2 ? info_for_api_gateway_v2 : info_for_api_gateway_v1 + info[:query_parameters] = @event.fetch('queryStringParameters', nil) + + @http_method = info[:method] + @http_uri = http_uri(info) + end + + def http_uri(info) + return unless info[:host] && info[:path] + + url_str = "https://#{info[:host]}:#{info[:port]}#{info[:path]}" + if info[:query_parameters] + qp = info[:query_parameters].map { |k, v| "#{k}=#{v}" }.join('&') + url_str += "?#{qp}" + end + + URI.parse(url_str) + end + + def info_for_api_gateway_v2 + ctx = @event.fetch('requestContext', nil) + return {} unless ctx + + {method: ctx.dig('http', 'method'), + path: ctx.dig('http', 'path'), + host: ctx.fetch('domainName', @event.dig('headers', 'Host')), + port: @event.dig('headers', 'X-Forwarded-Port') || 443} + end + + def info_for_api_gateway_v1 + headers = headers_from_event + {method: @event.fetch('httpMethod', nil), + path: @event.fetch('path', nil), + host: headers.fetch('Host', nil), + port: headers.fetch('X-Forwarded-Port', 443)} + end + + def process_headers + return unless ::NewRelic::Agent.config[:'distributed_tracing.enabled'] + + headers = headers_from_event + return unless headers && !headers.empty? + + dt_headers = headers.fetch(NewRelic::NEWRELIC_KEY, nil) + return unless dt_headers + + ::NewRelic::Agent::DistributedTracing::accept_distributed_trace_headers(dt_headers, 'Other') + end + + def headers_from_event + @headers ||= @event&.dig('requestContext', 'http') || @event&.dig('headers') + end + def use_named_pipe? return @use_named_pipe if defined?(@use_named_pipe) @@ -146,15 +238,142 @@ def use_named_pipe? def add_agent_attributes return unless NewRelic::Agent::Tracer.current_transaction - add_agent_attribute(ATTRIBUTE_COLD_START, true) if cold? - add_agent_attribute(ATTRIBUTE_ARN, @context.invoked_function_arn) - add_agent_attribute(ATTRIBUTE_REQUEST_ID, @context.aws_request_id) + add_agent_attribute('aws.lambda.coldStart', true) if cold? + add_agent_attribute('aws.lambda.arn', @context.invoked_function_arn) + add_agent_attribute('aws.requestId', @context.aws_request_id) + + add_event_source_attributes + add_http_attributes if api_gateway_event? + end + + def add_http_attributes + return unless category == :web + + if @http_uri + add_agent_attribute('uri.host', @http_uri.host) + add_agent_attribute('uri.port', @http_uri.port) + if NewRelic::Agent.instance.attribute_filter.allows_key?('http.url', AttributeFilter::DST_SPAN_EVENTS) + add_agent_attribute('http.url', @http_uri.to_s) + end + end + + if @http_method + add_agent_attribute('http.method', @http_method) + add_agent_attribute('http.request.method', @http_method) + end + end + + def api_gateway_event? + return false unless @event + + # '1.0' for API Gateway V1, '2.0' for API Gateway V2 + return true if @event.fetch('version', '').start_with?(DIGIT) + + return false unless headers_from_event + + # API Gateway V1 - look for toplevel 'path' and 'httpMethod' keys if a version is unset + return true if @event.fetch('path', nil) && @event.fetch('httpMethod', nil) + + # API Gateway V2 - look for 'requestContext/http' inner nested 'path' and 'method' keys if a version is unset + return true if @event.dig('requestContext', 'http', 'path') && @event.dig('requestContext', 'http', 'method') + + false + end + + def add_event_source_attributes + arn = event_source_arn + add_agent_attribute('aws.lambda.eventSource.arn', arn) if arn + + info = event_source_event_info + return unless info + + add_agent_attribute('aws.lambda.eventSource.eventType', info['name']) + + info['attributes'].each do |name, elements| + next if elements.empty? + + size = false + if elements.last.eql?('#size') + elements = elements.dup + elements.pop + size = true + end + value = @event.dig(*elements) + value = value.size if size + next unless value + + add_agent_attribute(name, value) + end + end + + def event_source_arn + return unless @event + + # SQS/Kinesis Stream/DynamoDB/CodeCommit/S3/SNS + return event_source_arn_for_records if @event.fetch('Records', nil) + + # Kinesis Firehose + ds_arn = @event.fetch('deliveryStreamArn', nil) if @event.fetch('records', nil) + return ds_arn if ds_arn + + # ELB + elb_arn = @event.dig('requestContext', 'elb', 'targetGroupArn') + return elb_arn if elb_arn + + # (other) + es_arn = @event.dig('resources', 0) + return es_arn if es_arn + + NewRelic::Agent.logger.debug 'Unable to determine an event source arn' + + nil + end + + def event_source_event_info + return unless @event + + # if every required key for a source is found, consider that source + # to be a match + EVENT_SOURCES.each_value do |info| + return info unless info['required_keys'].detect { |r| @event.dig(*r).nil? } + end + + nil + end + + def event_source_arn_for_records + record = @event['Records'].first + unless record + NewRelic::Agent.logger.debug "Unable to find any records in the event's 'Records' array" + return + end + + arn = record.fetch('eventSourceARN', nil) || # SQS/Kinesis Stream/DynamoDB/CodeCommit + record.dig('s3', 'bucket', 'arn') || # S3 + record.fetch('EventSubscriptionArn', nil) # SNS + + unless arn + NewRelic::Agent.logger.debug "Unable to determine an event source arn from the event's 'Records' array" + end + + arn end def add_agent_attribute(attribute, value) NewRelic::Agent::Tracer.current_transaction.add_agent_attribute(attribute, value, AGENT_ATTRIBUTE_DESTINATIONS) end + def process_response(response) + return response unless category == :web && response.respond_to?(:fetch) + + http_status = response.fetch(:statusCode, response.fetch('statusCode', nil)) + return unless http_status + + add_agent_attribute('http.statusCode', http_status) + + response + end + def cold? return @cold if defined?(@cold) @@ -163,7 +382,12 @@ def cold? end def reset! + @event = nil + @category = nil @context = nil + @headers = nil + @http_method = nil + @http_uri = nil @payloads.replace({}) end end diff --git a/lib/new_relic/agent/serverless_handler_event_sources.json b/lib/new_relic/agent/serverless_handler_event_sources.json new file mode 100644 index 0000000000..f0c9a4d054 --- /dev/null +++ b/lib/new_relic/agent/serverless_handler_event_sources.json @@ -0,0 +1,155 @@ +{ + "alb": { + "attributes": {}, + "name": "alb", + "required_keys": [ + "httpMethod", + "requestContext.elb" + ] + }, + + "apiGateway": { + "attributes": { + "aws.lambda.eventSource.accountId": "requestContext.accountId", + "aws.lambda.eventSource.apiId": "requestContext.apiId", + "aws.lambda.eventSource.resourceId": "requestContext.resourceId", + "aws.lambda.eventSource.resourcePath": "requestContext.resourcePath", + "aws.lambda.eventSource.stage": "requestContext.stage" + }, + "name": "apiGateway", + "required_keys": [ + "headers", + "httpMethod", + "path", + "requestContext", + "requestContext.stage" + ] + }, + + "apiGatewayV2": { + "attributes": { + "aws.lambda.eventSource.accountId": "requestContext.accountId", + "aws.lambda.eventSource.apiId": "requestContext.apiId", + "aws.lambda.eventSource.stage": "requestContext.stage" + }, + "name": "apiGatewayV2", + "required_keys": [ + "version", + "headers", + "requestContext.http", + "requestContext.http.path", + "requestContext.http.method", + "requestContext.stage" + ] + }, + + "cloudFront": { + "attributes": {}, + "name": "cloudFront", + "required_keys": [ + "Records[0].cf" + ] + }, + + "cloudWatchScheduled": { + "attributes": { + "aws.lambda.eventSource.account": "account", + "aws.lambda.eventSource.id": "id", + "aws.lambda.eventSource.region": "region", + "aws.lambda.eventSource.resource": "resources[0]", + "aws.lambda.eventSource.time": "time" + }, + "name": "cloudWatch_scheduled", + "required_keys": [ + "detail-type", + "source" + ] + }, + + "dynamoStreams": { + "attributes": { + "aws.lambda.eventSource.length": "Records.length" + }, + "name": "dynamo_streams", + "required_keys": [ + "Records[0].dynamodb" + ] + }, + + "firehose": { + "attributes": { + "aws.lambda.eventSource.length": "records.length", + "aws.lambda.eventSource.region": "region" + }, + "name": "firehose", + "required_keys": [ + "deliveryStreamArn", + "records[0].kinesisRecordMetadata" + ] + }, + + "kinesis": { + "attributes": { + "aws.lambda.eventSource.length": "Records.length", + "aws.lambda.eventSource.region": "Records[0].awsRegion" + }, + "name": "kinesis", + "required_keys": [ + "Records[0].kinesis" + ] + }, + + "s3": { + "attributes": { + "aws.lambda.eventSource.bucketName": "Records[0].s3.bucket.name", + "aws.lambda.eventSource.eventName": "Records[0].eventName", + "aws.lambda.eventSource.eventTime": "Records[0].eventTime", + "aws.lambda.eventSource.length": "Records.length", + "aws.lambda.eventSource.objectKey": "Records[0].s3.object.key", + "aws.lambda.eventSource.objectSequencer": "Records[0].s3.object.sequencer", + "aws.lambda.eventSource.objectSize": "Records[0].s3.object.size", + "aws.lambda.eventSource.region": "Records[0].awsRegion" + }, + "name": "s3", + "required_keys": [ + "Records[0].s3" + ] + }, + + "ses": { + "attributes": { + "aws.lambda.eventSource.date": "Records[0].ses.mail.commonHeaders.date", + "aws.lambda.eventSource.length": "Records.length", + "aws.lambda.eventSource.messageId": "Records[0].ses.mail.commonHeaders.messageId", + "aws.lambda.eventSource.returnPath": "Records[0].ses.mail.commonHeaders.returnPath" + }, + "name": "ses", + "required_keys": [ + "Records[0].ses" + ] + }, + + "sns": { + "attributes": { + "aws.lambda.eventSource.length": "Records.length", + "aws.lambda.eventSource.messageId": "Records[0].Sns.MessageId", + "aws.lambda.eventSource.timestamp": "Records[0].Sns.Timestamp", + "aws.lambda.eventSource.topicArn": "Records[0].Sns.TopicArn", + "aws.lambda.eventSource.type": "Records[0].Sns.Type" + }, + "name": "sns", + "required_keys": [ + "Records[0].Sns" + ] + }, + + "sqs": { + "attributes": { + "aws.lambda.eventSource.length": "Records.length" + }, + "name": "sqs", + "required_keys": [ + "Records[0].receiptHandle" + ] + } +} diff --git a/lib/new_relic/agent/serverless_handler_event_sources.rb b/lib/new_relic/agent/serverless_handler_event_sources.rb new file mode 100644 index 0000000000..59a527edbd --- /dev/null +++ b/lib/new_relic/agent/serverless_handler_event_sources.rb @@ -0,0 +1,49 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require 'json' + +module NewRelic + module Agent + # ServerlessHandlerEventSources - New Relic's language agent devs maintain + # a cross-agent JSON map of all AWS resources with the potential to invoke + # an AWS Lambda function by issuing it an event. This map is used to glean + # source specific attributes while instrumenting the function's invocation. + # + # Given that the event arrives as a Ruby hash argument to the AWS Lambda + # function, the JSON map's values need to be converted into arrays that can + # be passed to `Hash#dig`. So a value such as `'records[0].name'` needs to + # be converted to `['records', 0, 'name']`. This class's `.to_hash` method + # yields the converted data. + # + # Furthermore, `.length` calls are converted to Ruby `#size` notation to + # denote that a method call must be performed on the dug value. + class ServerlessHandlerEventSources + JSON_SOURCE = File.join(File.dirname(__FILE__), 'serverless_handler_event_sources.json').freeze + JSON_RAW = JSON.parse(File.read(JSON_SOURCE)).freeze + + def self.to_hash + JSON_RAW.each_with_object({}) do |(type, info), hash| + hash[type] = {'attributes' => {}, + 'name' => info['name'], + 'required_keys' => []} + info['attributes'].each { |attr, value| hash[type]['attributes'][attr] = transform(value) } + info['required_keys'].each { |key| hash[type]['required_keys'].push(transform(key)) } + end.freeze + end + + def self.transform(value) + value.gsub(/\[(\d+)\]/, '.\1').split('.').map do |e| + if e.match?(/^\d+$/) + e.to_i + elsif e == 'length' + '#size' + else + e + end + end + end + end + end +end diff --git a/lib/new_relic/agent/transaction/trace_context.rb b/lib/new_relic/agent/transaction/trace_context.rb index 26b267161f..dfca576427 100644 --- a/lib/new_relic/agent/transaction/trace_context.rb +++ b/lib/new_relic/agent/transaction/trace_context.rb @@ -95,7 +95,7 @@ def create_trace_state def create_trace_state_payload unless Agent.config[:'distributed_tracing.enabled'] - NewRelic::Agent.logger.warn('Not configured to create WC3 trace context payload') + NewRelic::Agent.logger.warn('Not configured to create W3C trace context payload') return end diff --git a/test/new_relic/agent/serverless_handler_event_sources_test.rb b/test/new_relic/agent/serverless_handler_event_sources_test.rb new file mode 100644 index 0000000000..b09587df68 --- /dev/null +++ b/test/new_relic/agent/serverless_handler_event_sources_test.rb @@ -0,0 +1,40 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require 'json' + +require_relative '../../test_helper' + +module NewRelic::Agent + class ServerlessHandlerEventSourcesTest < Minitest::Test + def test_hash_sanity + hash = ServerlessHandlerEventSources.to_hash + resources = %w[alb apiGateway apiGatewayV2 cloudFront cloudWatchScheduled dynamoStreams firehose kinesis s3 ses + sns sqs] + + assert_equal(resources.sort, hash.keys.sort) + assert_equal(%w[records #size], hash['firehose']['attributes']['aws.lambda.eventSource.length']) + assert_equal(['Records', 0, 's3', 'object', 'size'], + hash['s3']['attributes']['aws.lambda.eventSource.objectSize']) + end + + def test_transform_dig_functionality + input = 'action.collection[1138].key' + + assert_equal(['action', 'collection', 1138, 'key'], ServerlessHandlerEventSources.transform(input)) + end + + def test_transform_length_functionality + input = 'a.collection.length' + + assert_equal(['a', 'collection', '#size'], ServerlessHandlerEventSources.transform(input)) + end + + def test_transform_pass_through_functionality + input = 'simple' + + assert_equal([input], ServerlessHandlerEventSources.transform(input)) + end + end +end diff --git a/test/new_relic/agent/serverless_handler_test.rb b/test/new_relic/agent/serverless_handler_test.rb index 41dc447d25..0c051b24ca 100644 --- a/test/new_relic/agent/serverless_handler_test.rb +++ b/test/new_relic/agent/serverless_handler_test.rb @@ -2,6 +2,7 @@ # See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. # frozen_string_literal: true +require 'json' require 'logger' require 'stringio' require 'tempfile' @@ -32,7 +33,49 @@ def self.customer_lambda_function(event:, context:) module NewRelic::Agent class ServerlessHandler class ServerlessHandlerTest < Minitest::Test + EVENT_SOURCES = JSON.parse(File.read(File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'cross_agent_tests', 'lambda', 'event_source_info.json'))) + + AWS_TYPE_SPECIFIC_ATTRIBUTES = { + 's3' => {'aws.lambda.eventSource.bucketName' => 'example-bucket', + 'aws.lambda.eventSource.eventName' => 'ObjectCreated:Put', + 'aws.lambda.eventSource.eventTime' => '1970-01-01T00:00:00.000Z', + 'aws.lambda.eventSource.length' => 1, + 'aws.lambda.eventSource.objectKey' => 'test/key', + 'aws.lambda.eventSource.objectSequencer' => '0A1B2C3D4E5F678901', + 'aws.lambda.eventSource.objectSize' => 1024, + 'aws.lambda.eventSource.region' => 'us-west-2'}, + 'dynamo_streams' => {'aws.lambda.eventSource.length' => 3}, + 'firehose' => {'aws.lambda.eventSource.length' => 1, + 'aws.lambda.eventSource.region' => 'us-west-2'}, + 'cloudFront' => {}, + 'sqs' => {'aws.lambda.eventSource.length' => 1}, + 'apiGateway' => {'aws.lambda.eventSource.accountId' => '123456789012', + 'aws.lambda.eventSource.apiId' => '1234567890', + 'aws.lambda.eventSource.resourceId' => '123456', + 'aws.lambda.eventSource.resourcePath' => '/{proxy+}', + 'aws.lambda.eventSource.stage' => 'prod'}, + 'cloudWatch_scheduled' => {'aws.lambda.eventSource.account' => '{{{account-id}}}', + 'aws.lambda.eventSource.id' => 'cdc73f9d-aea9-11e3-9d5a-835b769c0d9c', + 'aws.lambda.eventSource.region' => 'us-west-2', + 'aws.lambda.eventSource.resource' => 'arn:aws:events:us-west-2:123456789012:rule/ExampleRule', + 'aws.lambda.eventSource.time' => '1970-01-01T00:00:00Z'}, + 'ses' => {'aws.lambda.eventSource.date' => 'Wed, 7 Oct 2015 12:34:56 -0700', + 'aws.lambda.eventSource.length' => 1, + 'aws.lambda.eventSource.messageId' => '<0123456789example.com>', + 'aws.lambda.eventSource.returnPath' => 'janedoe@example.com'}, + 'sns' => {'aws.lambda.eventSource.length' => 1, + 'aws.lambda.eventSource.messageId' => '95df01b4-ee98-5cb9-9903-4c221d41eb5e', + 'aws.lambda.eventSource.timestamp' => '1970-01-01T00:00:00.000Z', + 'aws.lambda.eventSource.topicArn' => 'arn:aws:sns:us-west-2:123456789012:ExampleTopic', + 'aws.lambda.eventSource.type' => 'Notification'}, + 'alb' => {}, + 'kinesis' => {'aws.lambda.eventSource.length' => 1, + 'aws.lambda.eventSource.region' => 'us-west-2'} + } + def setup + skip 'Serverless usage is limited to Ruby v3.2+' unless ruby_version_float >= 3.2 + config_hash = {:'serverless_mode.enabled' => true} @test_config = NewRelic::Agent::Configuration::DottedHash.new(config_hash, true) NewRelic::Agent.config.add_config_for_testing(@test_config, true) @@ -40,6 +83,8 @@ def setup end def teardown + skip unless defined?(@test_config) + NewRelic::Agent.config.remove_config(@test_config) end @@ -162,6 +207,77 @@ def test_support_for_payload_format_v1 end end + def test_distributed_tracing_for_api_gateway_v1 + event = {'version' => '1.0', + 'httpMethod' => 'POST', + 'headers' => {NewRelic::NEWRELIC_KEY => { + NewRelic::TRACEPARENT_KEY => '00-a8e67265afe2773a3c611b94306ee5c2-fb1010463ea28a38-01', + NewRelic::TRACESTATE_KEY => '190@nr=0-0-190-2827902-7d3efb1b173fecfa-e8b91a159289ff74-1-1.23456-1518469636035' + }}} + perform_distributed_tracing_based_invocation(event) + end + + def test_distributed_tracing_for_api_gateway_v2 + event = {'version' => '2.0', + 'httpMethod' => 'POST', + 'requestContext' => {'http' => {NewRelic::NEWRELIC_KEY => { + NewRelic::TRACEPARENT_KEY => '00-a8e67265afe2773a3c611b94306ee5c2-fb1010463ea28a38-01', + NewRelic::TRACESTATE_KEY => '190@nr=0-0-190-2827902-7d3efb1b173fecfa-e8b91a159289ff74-1-1.23456-1518469636035' + }}}} + perform_distributed_tracing_based_invocation(event) + end + + def test_reports_web_attributes_for_api_gateway_v1 + event = {'version' => '1.0', + 'resource' => '/RG35XXSP', + 'path' => '/default/RG35XXSP', + 'httpMethod' => 'POST', + 'headers' => {'Content-Length' => '1138', + 'Content-Type' => 'application/json', + 'Host' => 'garbanz0.execute-api.us-west-1.amazonaws.com', + 'User-Agent' => 'curl/8.4.0', + 'X-Amzn-Trace-Id' => 'Root=1-08675309-3e0mfbschanamasala8302xv1', + 'X-Forwarded-For' => '123.456.769.101', + 'X-Forwarded-Port' => '443', + 'X-Forwarded-Proto' => 'https', + 'accept' => '*/*'}, + 'queryStringParameters' => {'param1': 'value1', 'param2': 'value2'}, + 'pathParameters' => nil, + 'stageVariables' => nil, + 'body' => '{"thekey1":"thevalue1"}', + 'isBase64Encoded' => false} + perform_http_attribute_based_invocation(event) + end + + def test_reports_web_attributes_for_api_gateway_v2 + event = {'version' => '2.0', + 'headers' => {'X-Forwarded-Port' => 443}, + 'queryStringParameters' => {'param1': 'value1', 'param2': 'value2'}, + 'requestContext' => {'http' => {'method' => 'POST', + 'path' => '/default/RG35XXSP'}, + 'domainName' => 'garbanz0.execute-api.us-west-1.amazonaws.com'}} + perform_http_attribute_based_invocation(event) + end + + EVENT_SOURCES.each do |type, info| + define_method(:"test_event_type_#{type}") do + output = with_output do + handler.invoke_lambda_function_with_new_relic(method_name: :customer_lambda_function, + event: info['event'], + context: testing_context) + end + attributes = output.last['analytic_event_data'].last.last.last + + assert_equal info['expected_arn'], attributes['aws.lambda.eventSource.arn'] + assert_equal info['expected_type'], attributes['aws.lambda.eventSource.eventType'] + + AWS_TYPE_SPECIFIC_ATTRIBUTES[type].each do |key, value| + assert_equal value, attributes[key], + "Expected agent attribute of '#{key}' with a value of '#{value}'. Got '#{attributes['key']}'" + end + end + end + # unit style def test_named_pipe_check_true @@ -345,6 +461,51 @@ def with_output(&block) temp.close temp.unlink end + + def distributed_tracing_config + { + :account_id => 190, + :primary_application_id => '2827902', + :trusted_account_key => 190, + :'span_events.enabled' => true, + :'distributed_tracing.enabled' => true + } + end + + def perform_distributed_tracing_based_invocation(event) + output = nil + with_config(distributed_tracing_config) do + NewRelic::Agent.config.notify_server_source_added + output = with_output do + handler.invoke_lambda_function_with_new_relic(method_name: :customer_lambda_function, + event: event, + context: testing_context) + end + end + success = output.last['metric_data'].last.detect do |metrics| + metrics.first['name'] == 'Supportability/TraceContext/Accept/Success' + end + + assert success, 'Failed to detect the supportability metric representing DT success' + end + + def perform_http_attribute_based_invocation(event) + output = with_output do + NewRelic::Agent.instance.attribute_filter.stub(:allows_key?, true, ['http.url', AttributeFilter::DST_SPAN_EVENTS]) do + handler.invoke_lambda_function_with_new_relic(method_name: :customer_lambda_function, + event: event, + context: testing_context) + end + end + attrs = output.last['analytic_event_data'].last.last.last + + assert attrs, 'Unable to glean event attributes from the response output' + assert_equal 'POST', attrs.fetch('http.method', nil) + assert_equal 'POST', attrs.fetch('http.request.method', nil) + assert_equal 200, attrs.fetch('http.statusCode', nil) + assert_equal 'https://garbanz0.execute-api.us-west-1.amazonaws.com/default/RG35XXSP?param1=value1¶m2=value2', + attrs.fetch('http.url', nil) + end end end end